From 8d84edd3b79a179bc37bd5790c55ed6deff4facf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 28 Jul 2021 21:41:11 +0200 Subject: [PATCH 001/903] Add renault integration (#39605) --- .strict-typing | 1 + CODEOWNERS | 1 + homeassistant/components/renault/__init__.py | 45 +++ .../components/renault/config_flow.py | 92 +++++ homeassistant/components/renault/const.py | 15 + .../components/renault/manifest.json | 13 + .../components/renault/renault_coordinator.py | 72 ++++ .../components/renault/renault_entities.py | 103 ++++++ .../components/renault/renault_hub.py | 78 +++++ .../components/renault/renault_vehicle.py | 146 ++++++++ homeassistant/components/renault/sensor.py | 277 +++++++++++++++ homeassistant/components/renault/strings.json | 27 ++ .../components/renault/translations/en.json | 27 ++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/renault/__init__.py | 159 +++++++++ tests/components/renault/const.py | 328 ++++++++++++++++++ tests/components/renault/test_config_flow.py | 137 ++++++++ tests/components/renault/test_init.py | 85 +++++ tests/components/renault/test_sensor.py | 212 +++++++++++ .../renault/battery_status_charging.json | 18 + .../renault/battery_status_not_charging.json | 15 + .../fixtures/renault/charge_mode_always.json | 7 + .../renault/charge_mode_schedule.json | 7 + tests/fixtures/renault/cockpit_ev.json | 9 + tests/fixtures/renault/cockpit_fuel.json | 11 + tests/fixtures/renault/hvac_status.json | 7 + .../fixtures/renault/vehicle_captur_fuel.json | 108 ++++++ .../fixtures/renault/vehicle_captur_phev.json | 110 ++++++ tests/fixtures/renault/vehicle_zoe_40.json | 189 ++++++++++ tests/fixtures/renault/vehicle_zoe_50.json | 161 +++++++++ 33 files changed, 2478 insertions(+) create mode 100644 homeassistant/components/renault/__init__.py create mode 100644 homeassistant/components/renault/config_flow.py create mode 100644 homeassistant/components/renault/const.py create mode 100644 homeassistant/components/renault/manifest.json create mode 100644 homeassistant/components/renault/renault_coordinator.py create mode 100644 homeassistant/components/renault/renault_entities.py create mode 100644 homeassistant/components/renault/renault_hub.py create mode 100644 homeassistant/components/renault/renault_vehicle.py create mode 100644 homeassistant/components/renault/sensor.py create mode 100644 homeassistant/components/renault/strings.json create mode 100644 homeassistant/components/renault/translations/en.json create mode 100644 tests/components/renault/__init__.py create mode 100644 tests/components/renault/const.py create mode 100644 tests/components/renault/test_config_flow.py create mode 100644 tests/components/renault/test_init.py create mode 100644 tests/components/renault/test_sensor.py create mode 100644 tests/fixtures/renault/battery_status_charging.json create mode 100644 tests/fixtures/renault/battery_status_not_charging.json create mode 100644 tests/fixtures/renault/charge_mode_always.json create mode 100644 tests/fixtures/renault/charge_mode_schedule.json create mode 100644 tests/fixtures/renault/cockpit_ev.json create mode 100644 tests/fixtures/renault/cockpit_fuel.json create mode 100644 tests/fixtures/renault/hvac_status.json create mode 100644 tests/fixtures/renault/vehicle_captur_fuel.json create mode 100644 tests/fixtures/renault/vehicle_captur_phev.json create mode 100644 tests/fixtures/renault/vehicle_zoe_40.json create mode 100644 tests/fixtures/renault/vehicle_zoe_50.json diff --git a/.strict-typing b/.strict-typing index e32af5db563..6066c158b99 100644 --- a/.strict-typing +++ b/.strict-typing @@ -81,6 +81,7 @@ homeassistant.components.recorder.purge homeassistant.components.recorder.repack homeassistant.components.recorder.statistics homeassistant.components.remote.* +homeassistant.components.renault.* homeassistant.components.rituals_perfume_genie.* homeassistant.components.scene.* homeassistant.components.select.* diff --git a/CODEOWNERS b/CODEOWNERS index bfd74e97f71..c4cb6d242d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -411,6 +411,7 @@ homeassistant/components/rainmachine/* @bachya homeassistant/components/random/* @fabaff homeassistant/components/recollect_waste/* @bachya homeassistant/components/rejseplanen/* @DarkFox +homeassistant/components/renault/* @epenet homeassistant/components/repetier/* @MTrab homeassistant/components/rflink/* @javicalle homeassistant/components/rfxtrx/* @danielhiversen @elupus @RobBie1221 diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py new file mode 100644 index 00000000000..80433b2106e --- /dev/null +++ b/homeassistant/components/renault/__init__.py @@ -0,0 +1,45 @@ +"""Support for Renault devices.""" +import aiohttp + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_LOCALE, DOMAIN, PLATFORMS +from .renault_hub import RenaultHub + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Load a config entry.""" + renault_hub = RenaultHub(hass, config_entry.data[CONF_LOCALE]) + try: + login_success = await renault_hub.attempt_login( + config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD] + ) + except aiohttp.ClientConnectionError as exc: + raise ConfigEntryNotReady() from exc + + if not login_success: + return False + + hass.data.setdefault(DOMAIN, {}) + await renault_hub.async_initialise(config_entry) + + hass.data[DOMAIN][config_entry.unique_id] = renault_hub + + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.unique_id) + + return unload_ok diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py new file mode 100644 index 00000000000..09a69f1f95f --- /dev/null +++ b/homeassistant/components/renault/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow to configure Renault component.""" +from __future__ import annotations + +from typing import Any + +from renault_api.const import AVAILABLE_LOCALES +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN +from .renault_hub import RenaultHub + + +class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Renault config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the Renault config flow.""" + self.renault_config: dict[str, Any] = {} + self.renault_hub: RenaultHub | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a Renault config flow start. + + Ask the user for API keys. + """ + if user_input: + locale = user_input[CONF_LOCALE] + self.renault_config.update(user_input) + self.renault_config.update(AVAILABLE_LOCALES[locale]) + self.renault_hub = RenaultHub(self.hass, locale) + if not await self.renault_hub.attempt_login( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ): + return self._show_user_form({"base": "invalid_credentials"}) + return await self.async_step_kamereon() + return self._show_user_form() + + def _show_user_form(self, errors: dict[str, Any] | None = None) -> FlowResult: + """Show the API keys form.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors or {}, + ) + + async def async_step_kamereon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select Kamereon account.""" + if user_input: + await self.async_set_unique_id(user_input[CONF_KAMEREON_ACCOUNT_ID]) + self._abort_if_unique_id_configured() + + self.renault_config.update(user_input) + return self.async_create_entry( + title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config + ) + + assert self.renault_hub + accounts = await self.renault_hub.get_account_ids() + if len(accounts) == 0: + return self.async_abort(reason="kamereon_no_account") + if len(accounts) == 1: + await self.async_set_unique_id(accounts[0]) + self._abort_if_unique_id_configured() + + self.renault_config[CONF_KAMEREON_ACCOUNT_ID] = accounts[0] + return self.async_create_entry( + title=self.renault_config[CONF_KAMEREON_ACCOUNT_ID], + data=self.renault_config, + ) + + return self.async_show_form( + step_id="kamereon", + data_schema=vol.Schema( + {vol.Required(CONF_KAMEREON_ACCOUNT_ID): vol.In(accounts)} + ), + ) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py new file mode 100644 index 00000000000..51f6c10c6f1 --- /dev/null +++ b/homeassistant/components/renault/const.py @@ -0,0 +1,15 @@ +"""Constants for the Renault component.""" +DOMAIN = "renault" + +CONF_LOCALE = "locale" +CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" + +DEFAULT_SCAN_INTERVAL = 300 # 5 minutes + +PLATFORMS = [ + "sensor", +] + +DEVICE_CLASS_PLUG_STATE = "renault__plug_state" +DEVICE_CLASS_CHARGE_STATE = "renault__charge_state" +DEVICE_CLASS_CHARGE_MODE = "renault__charge_mode" diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json new file mode 100644 index 00000000000..118848ad6dd --- /dev/null +++ b/homeassistant/components/renault/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "renault", + "name": "Renault", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/renault", + "requirements": [ + "renault-api==0.1.4" + ], + "codeowners": [ + "@epenet" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/renault/renault_coordinator.py b/homeassistant/components/renault/renault_coordinator.py new file mode 100644 index 00000000000..b47a8507030 --- /dev/null +++ b/homeassistant/components/renault/renault_coordinator.py @@ -0,0 +1,72 @@ +"""Proxy to handle account communication with Renault servers.""" +from __future__ import annotations + +from collections.abc import Awaitable +from datetime import timedelta +import logging +from typing import Callable, TypeVar + +from renault_api.kamereon.exceptions import ( + AccessDeniedException, + KamereonResponseException, + NotSupportedException, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +T = TypeVar("T") + + +class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): + """Handle vehicle communication with Renault servers.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + *, + name: str, + update_interval: timedelta, + update_method: Callable[[], Awaitable[T]], + ) -> None: + """Initialise coordinator.""" + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + update_method=update_method, + ) + self.access_denied = False + self.not_supported = False + + async def _async_update_data(self) -> T: + """Fetch the latest data from the source.""" + if self.update_method is None: + raise NotImplementedError("Update method not implemented") + try: + return await self.update_method() + except AccessDeniedException as err: + # Disable because the account is not allowed to access this Renault endpoint. + self.update_interval = None + self.access_denied = True + raise UpdateFailed(f"This endpoint is denied: {err}") from err + + except NotSupportedException as err: + # Disable because the vehicle does not support this Renault endpoint. + self.update_interval = None + self.not_supported = True + raise UpdateFailed(f"This endpoint is not supported: {err}") from err + + except KamereonResponseException as err: + # Other Renault errors. + raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def async_config_entry_first_refresh(self) -> None: + """Refresh data for the first time when a config entry is setup. + + Contrary to base implementation, we are not raising ConfigEntryNotReady + but only updating the `access_denied` and `not_supported` flags. + """ + await self._async_refresh(log_failures=False, raise_on_auth_failed=True) diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py new file mode 100644 index 00000000000..9188a1f0757 --- /dev/null +++ b/homeassistant/components/renault/renault_entities.py @@ -0,0 +1,103 @@ +"""Base classes for Renault entities.""" +from __future__ import annotations + +from typing import Any, Generic, Optional, TypeVar + +from renault_api.kamereon.enums import ChargeState, PlugState +from renault_api.kamereon.models import ( + KamereonVehicleBatteryStatusData, + KamereonVehicleChargeModeData, + KamereonVehicleCockpitData, + KamereonVehicleHvacStatusData, +) + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from .renault_vehicle import RenaultVehicleProxy + +ATTR_LAST_UPDATE = "last_update" + +T = TypeVar("T") + + +class RenaultDataEntity(Generic[T], CoordinatorEntity[Optional[T]], Entity): + """Implementation of a Renault entity with a data coordinator.""" + + def __init__( + self, vehicle: RenaultVehicleProxy, entity_type: str, coordinator_key: str + ) -> None: + """Initialise entity.""" + super().__init__(vehicle.coordinators[coordinator_key]) + self.vehicle = vehicle + self._entity_type = entity_type + self._attr_device_info = self.vehicle.device_info + self._attr_name = entity_type + self._attr_unique_id = slugify( + f"{self.vehicle.details.vin}-{self._entity_type}" + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + # Data can succeed, but be empty + return super().available and self.coordinator.data is not None + + @property + def data(self) -> T | None: + """Return collected data.""" + return self.coordinator.data + + +class RenaultBatteryDataEntity(RenaultDataEntity[KamereonVehicleBatteryStatusData]): + """Implementation of a Renault entity with battery coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "battery") + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of this entity.""" + last_update = self.data.timestamp if self.data else None + return {ATTR_LAST_UPDATE: last_update} + + @property + def is_charging(self) -> bool: + """Return charge state as boolean.""" + return ( + self.data is not None + and self.data.get_charging_status() == ChargeState.CHARGE_IN_PROGRESS + ) + + @property + def is_plugged_in(self) -> bool: + """Return plug state as boolean.""" + return ( + self.data is not None and self.data.get_plug_status() == PlugState.PLUGGED + ) + + +class RenaultChargeModeDataEntity(RenaultDataEntity[KamereonVehicleChargeModeData]): + """Implementation of a Renault entity with charge_mode coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "charge_mode") + + +class RenaultCockpitDataEntity(RenaultDataEntity[KamereonVehicleCockpitData]): + """Implementation of a Renault entity with cockpit coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "cockpit") + + +class RenaultHVACDataEntity(RenaultDataEntity[KamereonVehicleHvacStatusData]): + """Implementation of a Renault entity with hvac_status coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "hvac_status") diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py new file mode 100644 index 00000000000..51e356934bb --- /dev/null +++ b/homeassistant/components/renault/renault_hub.py @@ -0,0 +1,78 @@ +"""Proxy to handle account communication with Renault servers.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.renault_account import RenaultAccount +from renault_api.renault_client import RenaultClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL +from .renault_vehicle import RenaultVehicleProxy + +LOGGER = logging.getLogger(__name__) + + +class RenaultHub: + """Handle account communication with Renault servers.""" + + def __init__(self, hass: HomeAssistant, locale: str) -> None: + """Initialise proxy.""" + LOGGER.debug("Creating RenaultHub") + self._hass = hass + self._client = RenaultClient( + websession=async_get_clientsession(self._hass), locale=locale + ) + self._account: RenaultAccount | None = None + self._vehicles: dict[str, RenaultVehicleProxy] = {} + + async def attempt_login(self, username: str, password: str) -> bool: + """Attempt login to Renault servers.""" + try: + await self._client.session.login(username, password) + except InvalidCredentialsException as ex: + LOGGER.error("Login to Renault failed: %s", ex.error_details) + else: + return True + return False + + async def async_initialise(self, config_entry: ConfigEntry) -> None: + """Set up proxy.""" + account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] + scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) + + self._account = await self._client.get_api_account(account_id) + vehicles = await self._account.get_vehicles() + if vehicles.vehicleLinks: + for vehicle_link in vehicles.vehicleLinks: + if vehicle_link.vin and vehicle_link.vehicleDetails: + # Generate vehicle proxy + vehicle = RenaultVehicleProxy( + hass=self._hass, + vehicle=await self._account.get_api_vehicle(vehicle_link.vin), + details=vehicle_link.vehicleDetails, + scan_interval=scan_interval, + ) + await vehicle.async_initialise() + self._vehicles[vehicle_link.vin] = vehicle + + async def get_account_ids(self) -> list[str]: + """Get Kamereon account ids.""" + accounts = [] + for account in await self._client.get_api_accounts(): + vehicles = await account.get_vehicles() + + # Only add the account if it has linked vehicles. + if vehicles.vehicleLinks: + accounts.append(account.account_id) + return accounts + + @property + def vehicles(self) -> dict[str, RenaultVehicleProxy]: + """Get list of vehicles.""" + return self._vehicles diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py new file mode 100644 index 00000000000..09e3de9adab --- /dev/null +++ b/homeassistant/components/renault/renault_vehicle.py @@ -0,0 +1,146 @@ +"""Proxy to handle account communication with Renault servers.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +from typing import cast + +from renault_api.kamereon import models +from renault_api.renault_vehicle import RenaultVehicle + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN +from .renault_coordinator import RenaultDataUpdateCoordinator + +LOGGER = logging.getLogger(__name__) + + +class RenaultVehicleProxy: + """Handle vehicle communication with Renault servers.""" + + def __init__( + self, + hass: HomeAssistant, + vehicle: RenaultVehicle, + details: models.KamereonVehicleDetails, + scan_interval: timedelta, + ) -> None: + """Initialise vehicle proxy.""" + self.hass = hass + self._vehicle = vehicle + self._details = details + self._device_info: DeviceInfo = { + "identifiers": {(DOMAIN, cast(str, details.vin))}, + "manufacturer": (details.get_brand_label() or "").capitalize(), + "model": (details.get_model_label() or "").capitalize(), + "name": details.registrationNumber or "", + "sw_version": details.get_model_code() or "", + } + self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} + self.hvac_target_temperature = 21 + self._scan_interval = scan_interval + + @property + def details(self) -> models.KamereonVehicleDetails: + """Return the specs of the vehicle.""" + return self._details + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return self._device_info + + async def async_initialise(self) -> None: + """Load available sensors.""" + if await self.endpoint_available("cockpit"): + self.coordinators["cockpit"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} cockpit", + update_method=self.get_cockpit, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + if await self.endpoint_available("hvac-status"): + self.coordinators["hvac_status"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} hvac_status", + update_method=self.get_hvac_status, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + if self.details.uses_electricity(): + if await self.endpoint_available("battery-status"): + self.coordinators["battery"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} battery", + update_method=self.get_battery_status, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + if await self.endpoint_available("charge-mode"): + self.coordinators["charge_mode"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} charge_mode", + update_method=self.get_charge_mode, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + # Check all coordinators + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in self.coordinators.values() + ) + ) + for key in list(self.coordinators): + # list() to avoid Runtime iteration error + coordinator = self.coordinators[key] + if coordinator.not_supported: + # Remove endpoint as it is not supported for this vehicle. + LOGGER.error( + "Ignoring endpoint %s as it is not supported for this vehicle: %s", + coordinator.name, + coordinator.last_exception, + ) + del self.coordinators[key] + elif coordinator.access_denied: + # Remove endpoint as it is denied for this vehicle. + LOGGER.error( + "Ignoring endpoint %s as it is denied for this vehicle: %s", + coordinator.name, + coordinator.last_exception, + ) + del self.coordinators[key] + + async def endpoint_available(self, endpoint: str) -> bool: + """Ensure the endpoint is available to avoid unnecessary queries.""" + return await self._vehicle.supports_endpoint( + endpoint + ) and await self._vehicle.has_contract_for_endpoint(endpoint) + + async def get_battery_status(self) -> models.KamereonVehicleBatteryStatusData: + """Get battery status information from vehicle.""" + return await self._vehicle.get_battery_status() + + async def get_charge_mode(self) -> models.KamereonVehicleChargeModeData: + """Get charge mode information from vehicle.""" + return await self._vehicle.get_charge_mode() + + async def get_cockpit(self) -> models.KamereonVehicleCockpitData: + """Get cockpit information from vehicle.""" + return await self._vehicle.get_cockpit() + + async def get_hvac_status(self) -> models.KamereonVehicleHvacStatusData: + """Get hvac status information from vehicle.""" + return await self._vehicle.get_hvac_status() diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py new file mode 100644 index 00000000000..8403a04d001 --- /dev/null +++ b/homeassistant/components/renault/sensor.py @@ -0,0 +1,277 @@ +"""Support for Renault sensors.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_TEMPERATURE, + LENGTH_KILOMETERS, + PERCENTAGE, + POWER_KILO_WATT, + TEMP_CELSIUS, + TIME_MINUTES, + VOLUME_LITERS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.util import slugify + +from .const import ( + DEVICE_CLASS_CHARGE_MODE, + DEVICE_CLASS_CHARGE_STATE, + DEVICE_CLASS_PLUG_STATE, + DOMAIN, +) +from .renault_entities import ( + RenaultBatteryDataEntity, + RenaultChargeModeDataEntity, + RenaultCockpitDataEntity, + RenaultDataEntity, + RenaultHVACDataEntity, +) +from .renault_hub import RenaultHub +from .renault_vehicle import RenaultVehicleProxy + +ATTR_BATTERY_AVAILABLE_ENERGY = "battery_available_energy" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + proxy: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] + entities = await get_entities(proxy) + async_add_entities(entities) + + +async def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: + """Create Renault entities for all vehicles.""" + entities = [] + for vehicle in proxy.vehicles.values(): + entities.extend(await get_vehicle_entities(vehicle)) + return entities + + +async def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultDataEntity]: + """Create Renault entities for single vehicle.""" + entities: list[RenaultDataEntity] = [] + if "cockpit" in vehicle.coordinators: + entities.append(RenaultMileageSensor(vehicle, "Mileage")) + if vehicle.details.uses_fuel(): + entities.append(RenaultFuelAutonomySensor(vehicle, "Fuel Autonomy")) + entities.append(RenaultFuelQuantitySensor(vehicle, "Fuel Quantity")) + if "hvac_status" in vehicle.coordinators: + entities.append(RenaultOutsideTemperatureSensor(vehicle, "Outside Temperature")) + if "battery" in vehicle.coordinators: + entities.append(RenaultBatteryLevelSensor(vehicle, "Battery Level")) + entities.append(RenaultChargeStateSensor(vehicle, "Charge State")) + entities.append( + RenaultChargingRemainingTimeSensor(vehicle, "Charging Remaining Time") + ) + entities.append(RenaultChargingPowerSensor(vehicle, "Charging Power")) + entities.append(RenaultPlugStateSensor(vehicle, "Plug State")) + entities.append(RenaultBatteryAutonomySensor(vehicle, "Battery Autonomy")) + entities.append(RenaultBatteryTemperatureSensor(vehicle, "Battery Temperature")) + if "charge_mode" in vehicle.coordinators: + entities.append(RenaultChargeModeSensor(vehicle, "Charge Mode")) + return entities + + +class RenaultBatteryAutonomySensor(RenaultBatteryDataEntity, SensorEntity): + """Battery autonomy sensor.""" + + _attr_icon = "mdi:ev-station" + _attr_unit_of_measurement = LENGTH_KILOMETERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.batteryAutonomy if self.data else None + + +class RenaultBatteryLevelSensor(RenaultBatteryDataEntity, SensorEntity): + """Battery Level sensor.""" + + _attr_device_class = DEVICE_CLASS_BATTERY + _attr_unit_of_measurement = PERCENTAGE + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.batteryLevel if self.data else None + + @property + def icon(self) -> str: + """Icon handling.""" + return icon_for_battery_level( + battery_level=self.state, charging=self.is_charging + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of this entity.""" + attrs = super().extra_state_attributes + attrs[ATTR_BATTERY_AVAILABLE_ENERGY] = ( + self.data.batteryAvailableEnergy if self.data else None + ) + return attrs + + +class RenaultBatteryTemperatureSensor(RenaultBatteryDataEntity, SensorEntity): + """Battery Temperature sensor.""" + + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_unit_of_measurement = TEMP_CELSIUS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.batteryTemperature if self.data else None + + +class RenaultChargeModeSensor(RenaultChargeModeDataEntity, SensorEntity): + """Charge Mode sensor.""" + + _attr_device_class = DEVICE_CLASS_CHARGE_MODE + + @property + def state(self) -> str | None: + """Return the state of this entity.""" + return self.data.chargeMode if self.data else None + + @property + def icon(self) -> str: + """Icon handling.""" + if self.data and self.data.chargeMode == "schedule_mode": + return "mdi:calendar-clock" + return "mdi:calendar-remove" + + +class RenaultChargeStateSensor(RenaultBatteryDataEntity, SensorEntity): + """Charge State sensor.""" + + _attr_device_class = DEVICE_CLASS_CHARGE_STATE + + @property + def state(self) -> str | None: + """Return the state of this entity.""" + charging_status = self.data.get_charging_status() if self.data else None + return slugify(charging_status.name) if charging_status is not None else None + + @property + def icon(self) -> str: + """Icon handling.""" + return "mdi:flash" if self.is_charging else "mdi:flash-off" + + +class RenaultChargingRemainingTimeSensor(RenaultBatteryDataEntity, SensorEntity): + """Charging Remaining Time sensor.""" + + _attr_icon = "mdi:timer" + _attr_unit_of_measurement = TIME_MINUTES + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.chargingRemainingTime if self.data else None + + +class RenaultChargingPowerSensor(RenaultBatteryDataEntity, SensorEntity): + """Charging Power sensor.""" + + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_unit_of_measurement = POWER_KILO_WATT + + @property + def state(self) -> float | None: + """Return the state of this entity.""" + if not self.data or self.data.chargingInstantaneousPower is None: + return None + if self.vehicle.details.reports_charging_power_in_watts(): + # Need to convert to kilowatts + return self.data.chargingInstantaneousPower / 1000 + return self.data.chargingInstantaneousPower + + +class RenaultFuelAutonomySensor(RenaultCockpitDataEntity, SensorEntity): + """Fuel autonomy sensor.""" + + _attr_icon = "mdi:gas-station" + _attr_unit_of_measurement = LENGTH_KILOMETERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return ( + round(self.data.fuelAutonomy) + if self.data and self.data.fuelAutonomy is not None + else None + ) + + +class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): + """Fuel quantity sensor.""" + + _attr_icon = "mdi:fuel" + _attr_unit_of_measurement = VOLUME_LITERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return ( + round(self.data.fuelQuantity) + if self.data and self.data.fuelQuantity is not None + else None + ) + + +class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): + """Mileage sensor.""" + + _attr_icon = "mdi:sign-direction" + _attr_unit_of_measurement = LENGTH_KILOMETERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return ( + round(self.data.totalMileage) + if self.data and self.data.totalMileage is not None + else None + ) + + +class RenaultOutsideTemperatureSensor(RenaultHVACDataEntity, SensorEntity): + """HVAC Outside Temperature sensor.""" + + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_unit_of_measurement = TEMP_CELSIUS + + @property + def state(self) -> float | None: + """Return the state of this entity.""" + return self.data.externalTemperature if self.data else None + + +class RenaultPlugStateSensor(RenaultBatteryDataEntity, SensorEntity): + """Plug State sensor.""" + + _attr_device_class = DEVICE_CLASS_PLUG_STATE + + @property + def state(self) -> str | None: + """Return the state of this entity.""" + plug_status = self.data.get_plug_status() if self.data else None + return slugify(plug_status.name) if plug_status is not None else None + + @property + def icon(self) -> str: + """Icon handling.""" + return "mdi:power-plug" if self.is_plugged_in else "mdi:power-plug-off" diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json new file mode 100644 index 00000000000..942c8b4a06c --- /dev/null +++ b/homeassistant/components/renault/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "kamereon_no_account": "Unable to find Kamereon account." + }, + "error": { + "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon account id" + }, + "title": "Select Kamereon account id" + }, + "user": { + "data": { + "locale": "Locale", + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "title": "Set Renault credentials" + } + } + } +} diff --git a/homeassistant/components/renault/translations/en.json b/homeassistant/components/renault/translations/en.json new file mode 100644 index 00000000000..bb65493a3b3 --- /dev/null +++ b/homeassistant/components/renault/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Account already configured", + "kamereon_no_account": "Unable to find Kamereon account." + }, + "error": { + "invalid_credentials": "Invalid credentials." + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon account id" + }, + "title": "Select Kamereon account id" + }, + "user": { + "data": { + "locale": "Locale", + "username": "Email", + "password": "Password" + }, + "title": "Set Renault credentials" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a6c97cbbab3..89b1a9ba8ae 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -216,6 +216,7 @@ FLOWS = [ "rachio", "rainmachine", "recollect_waste", + "renault", "rfxtrx", "ring", "risco", diff --git a/mypy.ini b/mypy.ini index 32800994e42..e38897bf303 100644 --- a/mypy.ini +++ b/mypy.ini @@ -902,6 +902,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.renault.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.rituals_perfume_genie.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 0acdff35fb9..bfa61df2f9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2015,6 +2015,9 @@ raspyrfm-client==1.2.8 # homeassistant.components.rainmachine regenmaschine==3.1.5 +# homeassistant.components.renault +renault-api==0.1.4 + # homeassistant.components.python_script restrictedpython==5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b9513a7e54..7d2e39327df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1109,6 +1109,9 @@ rachiopy==1.0.3 # homeassistant.components.rainmachine regenmaschine==3.1.5 +# homeassistant.components.renault +renault-api==0.1.4 + # homeassistant.components.python_script restrictedpython==5.1 diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py new file mode 100644 index 00000000000..e4edc3b8539 --- /dev/null +++ b/tests/components/renault/__init__.py @@ -0,0 +1,159 @@ +"""Tests for the Renault integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Any +from unittest.mock import patch + +from renault_api.kamereon import models, schemas +from renault_api.renault_vehicle import RenaultVehicle + +from homeassistant.components.renault.const import ( + CONF_KAMEREON_ACCOUNT_ID, + CONF_LOCALE, + DOMAIN, +) +from homeassistant.components.renault.renault_vehicle import RenaultVehicleProxy +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import MOCK_VEHICLES + +from tests.common import MockConfigEntry, load_fixture + + +async def setup_renault_integration(hass: HomeAssistant): + """Create the Renault integration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source="user", + data={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + CONF_KAMEREON_ACCOUNT_ID: "account_id_2", + }, + unique_id="account_id_2", + options={}, + entry_id="1", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.renault.RenaultHub.attempt_login", return_value=True + ), patch("homeassistant.components.renault.RenaultHub.async_initialise"): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +def get_fixtures(vehicle_type: str) -> dict[str, Any]: + """Create a vehicle proxy for testing.""" + mock_vehicle = MOCK_VEHICLES[vehicle_type] + return { + "battery_status": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['battery_status']}") + if "battery_status" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleBatteryStatusDataSchema), + "charge_mode": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['charge_mode']}") + if "charge_mode" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleChargeModeDataSchema), + "cockpit": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['cockpit']}") + if "cockpit" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleCockpitDataSchema), + "hvac_status": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['hvac_status']}") + if "hvac_status" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleHvacStatusDataSchema), + } + + +async def create_vehicle_proxy( + hass: HomeAssistant, vehicle_type: str +) -> RenaultVehicleProxy: + """Create a vehicle proxy for testing.""" + mock_vehicle = MOCK_VEHICLES[vehicle_type] + mock_fixtures = get_fixtures(vehicle_type) + + vehicles_response: models.KamereonVehiclesResponse = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ) + vehicle_details = vehicles_response.vehicleLinks[0].vehicleDetails + vehicle = RenaultVehicle( + vehicles_response.accountId, + vehicle_details.vin, + websession=aiohttp_client.async_get_clientsession(hass), + ) + + vehicle_proxy = RenaultVehicleProxy( + hass, vehicle, vehicle_details, timedelta(seconds=300) + ) + with patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.endpoint_available", + side_effect=mock_vehicle["endpoints_available"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_battery_status", + return_value=mock_fixtures["battery_status"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_charge_mode", + return_value=mock_fixtures["charge_mode"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_cockpit", + return_value=mock_fixtures["cockpit"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_hvac_status", + return_value=mock_fixtures["hvac_status"], + ): + await vehicle_proxy.async_initialise() + return vehicle_proxy + + +async def create_vehicle_proxy_with_side_effect( + hass: HomeAssistant, vehicle_type: str, side_effect: Any +) -> RenaultVehicleProxy: + """Create a vehicle proxy for testing unavailable entities.""" + mock_vehicle = MOCK_VEHICLES[vehicle_type] + + vehicles_response: models.KamereonVehiclesResponse = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ) + vehicle_details = vehicles_response.vehicleLinks[0].vehicleDetails + vehicle = RenaultVehicle( + vehicles_response.accountId, + vehicle_details.vin, + websession=aiohttp_client.async_get_clientsession(hass), + ) + + vehicle_proxy = RenaultVehicleProxy( + hass, vehicle, vehicle_details, timedelta(seconds=300) + ) + with patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.endpoint_available", + side_effect=mock_vehicle["endpoints_available"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_battery_status", + side_effect=side_effect, + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_charge_mode", + side_effect=side_effect, + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_cockpit", + side_effect=side_effect, + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_hvac_status", + side_effect=side_effect, + ): + await vehicle_proxy.async_initialise() + return vehicle_proxy diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py new file mode 100644 index 00000000000..be2adafd7be --- /dev/null +++ b/tests/components/renault/const.py @@ -0,0 +1,328 @@ +"""Constants for the Renault integration tests.""" +from homeassistant.components.renault.const import ( + CONF_KAMEREON_ACCOUNT_ID, + CONF_LOCALE, + DEVICE_CLASS_CHARGE_MODE, + DEVICE_CLASS_CHARGE_STATE, + DEVICE_CLASS_PLUG_STATE, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_TEMPERATURE, + LENGTH_KILOMETERS, + PERCENTAGE, + POWER_KILO_WATT, + STATE_UNKNOWN, + TEMP_CELSIUS, + TIME_MINUTES, + VOLUME_LITERS, +) + +# Mock config data to be used across multiple tests +MOCK_CONFIG = { + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + CONF_KAMEREON_ACCOUNT_ID: "account_id_1", + CONF_LOCALE: "fr_FR", +} + +MOCK_VEHICLES = { + "zoe_40": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777999")}, + "manufacturer": "Renault", + "model": "Zoe", + "name": "REG-NUMBER", + "sw_version": "X101VE", + }, + "endpoints_available": [ + True, # cockpit + True, # hvac-status + True, # battery-status + True, # charge-mode + ], + "endpoints": { + "battery_status": "battery_status_charging.json", + "charge_mode": "charge_mode_always.json", + "cockpit": "cockpit_ev.json", + "hvac_status": "hvac_status.json", + }, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.battery_autonomy", + "unique_id": "vf1aaaaa555777999_battery_autonomy", + "result": "141", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.battery_level", + "unique_id": "vf1aaaaa555777999_battery_level", + "result": "60", + "unit": PERCENTAGE, + "class": DEVICE_CLASS_BATTERY, + }, + { + "entity_id": "sensor.battery_temperature", + "unique_id": "vf1aaaaa555777999_battery_temperature", + "result": "20", + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "always", + "class": DEVICE_CLASS_CHARGE_MODE, + }, + { + "entity_id": "sensor.charge_state", + "unique_id": "vf1aaaaa555777999_charge_state", + "result": "charge_in_progress", + "class": DEVICE_CLASS_CHARGE_STATE, + }, + { + "entity_id": "sensor.charging_power", + "unique_id": "vf1aaaaa555777999_charging_power", + "result": "0.027", + "unit": POWER_KILO_WATT, + "class": DEVICE_CLASS_ENERGY, + }, + { + "entity_id": "sensor.charging_remaining_time", + "unique_id": "vf1aaaaa555777999_charging_remaining_time", + "result": "145", + "unit": TIME_MINUTES, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777999_mileage", + "result": "49114", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.outside_temperature", + "unique_id": "vf1aaaaa555777999_outside_temperature", + "result": "8.0", + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.plug_state", + "unique_id": "vf1aaaaa555777999_plug_state", + "result": "plugged", + "class": DEVICE_CLASS_PLUG_STATE, + }, + ], + }, + "zoe_50": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777999")}, + "manufacturer": "Renault", + "model": "Zoe", + "name": "REG-NUMBER", + "sw_version": "X102VE", + }, + "endpoints_available": [ + True, # cockpit + False, # hvac-status + True, # battery-status + True, # charge-mode + ], + "endpoints": { + "battery_status": "battery_status_not_charging.json", + "charge_mode": "charge_mode_schedule.json", + "cockpit": "cockpit_ev.json", + }, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.battery_autonomy", + "unique_id": "vf1aaaaa555777999_battery_autonomy", + "result": "128", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.battery_level", + "unique_id": "vf1aaaaa555777999_battery_level", + "result": "50", + "unit": PERCENTAGE, + "class": DEVICE_CLASS_BATTERY, + }, + { + "entity_id": "sensor.battery_temperature", + "unique_id": "vf1aaaaa555777999_battery_temperature", + "result": STATE_UNKNOWN, + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "schedule_mode", + "class": DEVICE_CLASS_CHARGE_MODE, + }, + { + "entity_id": "sensor.charge_state", + "unique_id": "vf1aaaaa555777999_charge_state", + "result": "charge_error", + "class": DEVICE_CLASS_CHARGE_STATE, + }, + { + "entity_id": "sensor.charging_power", + "unique_id": "vf1aaaaa555777999_charging_power", + "result": STATE_UNKNOWN, + "unit": POWER_KILO_WATT, + "class": DEVICE_CLASS_ENERGY, + }, + { + "entity_id": "sensor.charging_remaining_time", + "unique_id": "vf1aaaaa555777999_charging_remaining_time", + "result": STATE_UNKNOWN, + "unit": TIME_MINUTES, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777999_mileage", + "result": "49114", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.plug_state", + "unique_id": "vf1aaaaa555777999_plug_state", + "result": "unplugged", + "class": DEVICE_CLASS_PLUG_STATE, + }, + ], + }, + "captur_phev": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777123")}, + "manufacturer": "Renault", + "model": "Captur ii", + "name": "REG-NUMBER", + "sw_version": "XJB1SU", + }, + "endpoints_available": [ + True, # cockpit + False, # hvac-status + True, # battery-status + True, # charge-mode + ], + "endpoints": { + "battery_status": "battery_status_charging.json", + "charge_mode": "charge_mode_always.json", + "cockpit": "cockpit_fuel.json", + }, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.battery_autonomy", + "unique_id": "vf1aaaaa555777123_battery_autonomy", + "result": "141", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.battery_level", + "unique_id": "vf1aaaaa555777123_battery_level", + "result": "60", + "unit": PERCENTAGE, + "class": DEVICE_CLASS_BATTERY, + }, + { + "entity_id": "sensor.battery_temperature", + "unique_id": "vf1aaaaa555777123_battery_temperature", + "result": "20", + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.charge_mode", + "unique_id": "vf1aaaaa555777123_charge_mode", + "result": "always", + "class": DEVICE_CLASS_CHARGE_MODE, + }, + { + "entity_id": "sensor.charge_state", + "unique_id": "vf1aaaaa555777123_charge_state", + "result": "charge_in_progress", + "class": DEVICE_CLASS_CHARGE_STATE, + }, + { + "entity_id": "sensor.charging_power", + "unique_id": "vf1aaaaa555777123_charging_power", + "result": "27.0", + "unit": POWER_KILO_WATT, + "class": DEVICE_CLASS_ENERGY, + }, + { + "entity_id": "sensor.charging_remaining_time", + "unique_id": "vf1aaaaa555777123_charging_remaining_time", + "result": "145", + "unit": TIME_MINUTES, + }, + { + "entity_id": "sensor.fuel_autonomy", + "unique_id": "vf1aaaaa555777123_fuel_autonomy", + "result": "35", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.fuel_quantity", + "unique_id": "vf1aaaaa555777123_fuel_quantity", + "result": "3", + "unit": VOLUME_LITERS, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777123_mileage", + "result": "5567", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.plug_state", + "unique_id": "vf1aaaaa555777123_plug_state", + "result": "plugged", + "class": DEVICE_CLASS_PLUG_STATE, + }, + ], + }, + "captur_fuel": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777123")}, + "manufacturer": "Renault", + "model": "Captur ii", + "name": "REG-NUMBER", + "sw_version": "XJB1SU", + }, + "endpoints_available": [ + True, # cockpit + False, # hvac-status + # Ignore, # battery-status + # Ignore, # charge-mode + ], + "endpoints": {"cockpit": "cockpit_fuel.json"}, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.fuel_autonomy", + "unique_id": "vf1aaaaa555777123_fuel_autonomy", + "result": "35", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.fuel_quantity", + "unique_id": "vf1aaaaa555777123_fuel_quantity", + "result": "3", + "unit": VOLUME_LITERS, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777123_mileage", + "result": "5567", + "unit": LENGTH_KILOMETERS, + }, + ], + }, +} diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py new file mode 100644 index 00000000000..c8b9c8c3e12 --- /dev/null +++ b/tests/components/renault/test_config_flow.py @@ -0,0 +1,137 @@ +"""Test the Renault config flow.""" +from unittest.mock import AsyncMock, PropertyMock, patch + +from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.kamereon import schemas + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.renault.const import ( + CONF_KAMEREON_ACCOUNT_ID, + CONF_LOCALE, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import load_fixture + + +async def test_config_flow_single_account(hass: HomeAssistant): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Failed credentials + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_credentials"} + + renault_account = AsyncMock() + type(renault_account).account_id = PropertyMock(return_value="account_id_1") + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) + ) + + # Account list single + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_account.RenaultAccount.account_id", return_value="123" + ), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "account_id_1" + assert result["data"][CONF_USERNAME] == "email@test.com" + assert result["data"][CONF_PASSWORD] == "test" + assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" + assert result["data"][CONF_LOCALE] == "fr_FR" + + +async def test_config_flow_no_account(hass: HomeAssistant): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Account list empty + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "homeassistant.components.renault.config_flow.RenaultHub.get_account_ids", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "kamereon_no_account" + + +async def test_config_flow_multiple_accounts(hass: HomeAssistant): + """Test what happens if multiple Kamereon accounts are available.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Multiple accounts + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "homeassistant.components.renault.config_flow.RenaultHub.get_account_ids", + return_value=["account_id_1", "account_id_2"], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "kamereon" + + # Account selected + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_KAMEREON_ACCOUNT_ID: "account_id_2"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "account_id_2" + assert result["data"][CONF_USERNAME] == "email@test.com" + assert result["data"][CONF_PASSWORD] == "test" + assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2" + assert result["data"][CONF_LOCALE] == "fr_FR" diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py new file mode 100644 index 00000000000..974155c3df9 --- /dev/null +++ b/tests/components/renault/test_init.py @@ -0,0 +1,85 @@ +"""Tests for Renault setup process.""" +from unittest.mock import AsyncMock, patch + +import aiohttp +import pytest +from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.kamereon import schemas + +from homeassistant.components.renault import ( + RenaultHub, + async_setup_entry, + async_unload_entry, +) +from homeassistant.components.renault.const import DOMAIN +from homeassistant.components.renault.renault_vehicle import RenaultVehicleProxy +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import MOCK_CONFIG + +from tests.common import MockConfigEntry, load_fixture + + +async def test_setup_unload_and_reload_entry(hass): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 + ) + renault_account = AsyncMock() + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) + ) + + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ): + # Set up the entry and assert that the values set during setup are where we expect + # them to be. + assert await async_setup_entry(hass, config_entry) + assert DOMAIN in hass.data and config_entry.unique_id in hass.data[DOMAIN] + assert isinstance(hass.data[DOMAIN][config_entry.unique_id], RenaultHub) + + renault_hub: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] + assert len(renault_hub.vehicles) == 1 + assert isinstance( + renault_hub.vehicles["VF1AAAAA555777999"], RenaultVehicleProxy + ) + + # Unload the entry and verify that the data has been removed + assert await async_unload_entry(hass, config_entry) + assert config_entry.unique_id not in hass.data[DOMAIN] + + +async def test_setup_entry_bad_password(hass): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 + ) + + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), + ): + # Set up the entry and assert that the values set during setup are where we expect + # them to be. + assert not await async_setup_entry(hass, config_entry) + + +async def test_setup_entry_exception(hass): + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 + ) + + # In this case we are testing the condition where async_setup_entry raises + # ConfigEntryNotReady. + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=aiohttp.ClientConnectionError, + ), pytest.raises(ConfigEntryNotReady): + assert await async_setup_entry(hass, config_entry) diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py new file mode 100644 index 00000000000..8956fa7e7e6 --- /dev/null +++ b/tests/components/renault/test_sensor.py @@ -0,0 +1,212 @@ +"""Tests for Renault sensors.""" +from unittest.mock import PropertyMock, patch + +import pytest +from renault_api.kamereon import exceptions + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.setup import async_setup_component + +from . import ( + create_vehicle_proxy, + create_vehicle_proxy_with_side_effect, + setup_renault_integration, +) +from .const import MOCK_VEHICLES + +from tests.common import mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_sensors(hass, vehicle_type): + """Test for Renault sensors.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_proxy = await create_vehicle_proxy(hass, vehicle_type) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + assert len(device_registry.devices) == 1 + expected_device = mock_vehicle["expected_device"] + registry_entry = device_registry.async_get_device(expected_device["identifiers"]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device["identifiers"] + assert registry_entry.manufacturer == expected_device["manufacturer"] + assert registry_entry.name == expected_device["name"] + assert registry_entry.model == expected_device["model"] + assert registry_entry.sw_version == expected_device["sw_version"] + + expected_entities = mock_vehicle[SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == expected_entity["result"] + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_sensor_empty(hass, vehicle_type): + """Test for Renault sensors with empty data from Renault.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect(hass, vehicle_type, {}) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + assert len(device_registry.devices) == 1 + expected_device = mock_vehicle["expected_device"] + registry_entry = device_registry.async_get_device(expected_device["identifiers"]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device["identifiers"] + assert registry_entry.manufacturer == expected_device["manufacturer"] + assert registry_entry.name == expected_device["name"] + assert registry_entry.model == expected_device["model"] + assert registry_entry.sw_version == expected_device["sw_version"] + + expected_entities = mock_vehicle[SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_sensor_errors(hass, vehicle_type): + """Test for Renault sensors with temporary failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + invalid_upstream_exception = exceptions.InvalidUpstreamException( + "err.tech.500", + "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", + ) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + assert len(device_registry.devices) == 1 + expected_device = mock_vehicle["expected_device"] + registry_entry = device_registry.async_get_device(expected_device["identifiers"]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device["identifiers"] + assert registry_entry.manufacturer == expected_device["manufacturer"] + assert registry_entry.name == expected_device["name"] + assert registry_entry.model == expected_device["model"] + assert registry_entry.sw_version == expected_device["sw_version"] + + expected_entities = mock_vehicle[SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_access_denied(hass): + """Test for Renault sensors with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + access_denied_exception = exceptions.AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect( + hass, "zoe_40", access_denied_exception + ) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == 0 + + +async def test_sensor_not_supported(hass): + """Test for Renault sensors with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + not_supported_exception = exceptions.NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect( + hass, "zoe_40", not_supported_exception + ) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == 0 diff --git a/tests/fixtures/renault/battery_status_charging.json b/tests/fixtures/renault/battery_status_charging.json new file mode 100644 index 00000000000..dbde4597e93 --- /dev/null +++ b/tests/fixtures/renault/battery_status_charging.json @@ -0,0 +1,18 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "timestamp": "2020-01-12T21:40:16Z", + "batteryLevel": 60, + "batteryTemperature": 20, + "batteryAutonomy": 141, + "batteryCapacity": 0, + "batteryAvailableEnergy": 31, + "plugStatus": 1, + "chargingStatus": 1.0, + "chargingRemainingTime": 145, + "chargingInstantaneousPower": 27 + } + } +} diff --git a/tests/fixtures/renault/battery_status_not_charging.json b/tests/fixtures/renault/battery_status_not_charging.json new file mode 100644 index 00000000000..750d0081ed9 --- /dev/null +++ b/tests/fixtures/renault/battery_status_not_charging.json @@ -0,0 +1,15 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "timestamp": "2020-11-17T09:06:48+01:00", + "batteryLevel": 50, + "batteryAutonomy": 128, + "batteryCapacity": 0, + "batteryAvailableEnergy": 0, + "plugStatus": 0, + "chargingStatus": -1.0 + } + } +} diff --git a/tests/fixtures/renault/charge_mode_always.json b/tests/fixtures/renault/charge_mode_always.json new file mode 100644 index 00000000000..6f146a2f72f --- /dev/null +++ b/tests/fixtures/renault/charge_mode_always.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { "chargeMode": "always" } + } +} diff --git a/tests/fixtures/renault/charge_mode_schedule.json b/tests/fixtures/renault/charge_mode_schedule.json new file mode 100644 index 00000000000..778994746ff --- /dev/null +++ b/tests/fixtures/renault/charge_mode_schedule.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { "chargeMode": "schedule_mode" } + } +} diff --git a/tests/fixtures/renault/cockpit_ev.json b/tests/fixtures/renault/cockpit_ev.json new file mode 100644 index 00000000000..c5a390f3dda --- /dev/null +++ b/tests/fixtures/renault/cockpit_ev.json @@ -0,0 +1,9 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "totalMileage": 49114.27 + } + } +} diff --git a/tests/fixtures/renault/cockpit_fuel.json b/tests/fixtures/renault/cockpit_fuel.json new file mode 100644 index 00000000000..575a4236c19 --- /dev/null +++ b/tests/fixtures/renault/cockpit_fuel.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777123", + "attributes": { + "fuelAutonomy": 35.0, + "fuelQuantity": 3.0, + "totalMileage": 5566.78 + } + } +} diff --git a/tests/fixtures/renault/hvac_status.json b/tests/fixtures/renault/hvac_status.json new file mode 100644 index 00000000000..f48cbae68ae --- /dev/null +++ b/tests/fixtures/renault/hvac_status.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { "externalTemperature": 8.0, "hvacStatus": "off" } + } +} diff --git a/tests/fixtures/renault/vehicle_captur_fuel.json b/tests/fixtures/renault/vehicle_captur_fuel.json new file mode 100644 index 00000000000..3aa854c61ea --- /dev/null +++ b/tests/fixtures/renault/vehicle_captur_fuel.json @@ -0,0 +1,108 @@ +{ + "accountId": "account-id-1", + "country": "LU", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777123", + "status": "ACTIVE", + "linkType": "USER", + "garageBrand": "RENAULT", + "mileage": 346, + "startDate": "2020-06-12", + "createdDate": "2020-06-12T15:02:00.555432Z", + "lastModifiedDate": "2020-06-15T06:21:43.762467Z", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2020-06-15T06:20:39.107794Z", + "lastModifiedDate": "2020-06-15T06:20:39.107794Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777123", + "engineType": "H5H", + "engineRatio": "470", + "modelSCR": "CP1", + "deliveryCountry": { + "code": "BE", + "label": "BELGIQUE" + }, + "family": { + "code": "XJB", + "label": "FAMILLE B+X OVER", + "group": "007" + }, + "tcu": { + "code": "AIVCT", + "label": "AVEC BOITIER CONNECT AIVC", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "", + "label": "", + "group": "" + }, + "battery": { + "code": "SANBAT", + "label": "SANS BATTERIE", + "group": "968" + }, + "radioType": { + "code": "NA406", + "label": "A-IVIMINDL, 2BO + 2BI + 2T, MICRO-DOUBLE, FM1/DAB+FM2", + "group": "425" + }, + "registrationCountry": { + "code": "BE" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "XJB1SU", + "label": "CAPTUR II", + "group": "971" + }, + "gearbox": { + "code": "BVA7", + "label": "BOITE DE VITESSE AUTOMATIQUE 7 RAPPORTS", + "group": "427" + }, + "version": { + "code": "ITAMFHA 6TH" + }, + "energy": { + "code": "ESS", + "label": "ESSENCE", + "group": "019" + }, + "registrationNumber": "REG-NUMBER", + "vcd": "ADR00/DLIGM2/PGPRT2/FEUAR3/CDVIT1/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET04/DANGMO/ECOMOD/SSRCAR/AIVCT/AVGSI/TPRPE/TSGNE/2TON/ITPK7/MLEXP1/SPERTA/SSPERG/SPERTP/VOLCHA/SREACT/AVOSP1/SWALBO/DWGE01/AVC1A/1234Y/AEBS07/PRAHL/AVCAM/STANDA/XJB/HJB/EA3/MF/ESS/DG/TEMP/TR4X2/AFURGE/RVDIST/ABS/SBARTO/CA02/TOPAN/PBNCH/LAC/VSTLAR/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV02/SGAR02/BIXPE/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/SCACBA/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETIN2/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGACHA/BEL01/APL03/FSTPO/ALOUC5/CMAR3P/FIPOU2/NA406/BVA7/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRGAC/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06T/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/SSTYAD/HYB01/SSCABA/SANBAT/VEC012/XJB1SU/SSNBT/H5H", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "NONE", + "easyConnectStore": false, + "electrical": false, + "rlinkStore": false, + "deliveryDate": "2020-06-17", + "retrievedFromDhs": false, + "engineEnergyType": "OTHER", + "radioCode": "1234" + } + } + ] +} diff --git a/tests/fixtures/renault/vehicle_captur_phev.json b/tests/fixtures/renault/vehicle_captur_phev.json new file mode 100644 index 00000000000..03066c8238f --- /dev/null +++ b/tests/fixtures/renault/vehicle_captur_phev.json @@ -0,0 +1,110 @@ +{ + "accountId": "account-id-2", + "country": "IT", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777123", + "status": "ACTIVE", + "linkType": "OWNER", + "garageBrand": "RENAULT", + "startDate": "2020-10-07", + "createdDate": "2020-10-07T09:17:44.692802Z", + "lastModifiedDate": "2021-03-28T10:44:01.139649Z", + "ownershipStartDate": "2020-09-30", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2020-10-08T17:36:39.445523Z", + "lastModifiedDate": "2020-10-08T17:36:39.445523Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777123", + "registrationDate": "2020-09-30", + "firstRegistrationDate": "2020-09-30", + "engineType": "H4M", + "engineRatio": "630", + "modelSCR": "", + "deliveryCountry": { + "code": "IT", + "label": "ITALY" + }, + "family": { + "code": "XJB", + "label": "B+X OVER FAMILY", + "group": "007" + }, + "tcu": { + "code": "AIVCT", + "label": "WITH AIVC CONNECTION UNIT", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "", + "label": "", + "group": "" + }, + "battery": { + "code": "BT9AE1", + "label": "BATTERY BT9AE1", + "group": "968" + }, + "radioType": { + "code": "NA418", + "label": "FULL NAV DAB ETH - AUDI", + "group": "425" + }, + "registrationCountry": { + "code": "IT" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "XJB1SU", + "label": "CAPTUR II", + "group": "971" + }, + "gearbox": { + "code": "BVH4", + "label": "HYBRID 4 SPEED GEARBOX", + "group": "427" + }, + "version": { + "code": "ITAMMHH 6UP" + }, + "energy": { + "code": "ESS", + "label": "PETROL", + "group": "019" + }, + "registrationNumber": "REG-NUMBER", + "vcd": "STANDA/XJB/HJB/EA3/MM/ESS/DG/TEMP/TR4X2/AFURGE/RV/ABS/SBARTO/CA02/TN/PBNCH/LAC/VT/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV01/SGAR02/BIYPC/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/CACBL3/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETRCR/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGSCHA/ITA01/APL03/FSTPO/ALOUC5/PART01/CMAR3P/FIPOU2/NA418/BVH4/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRFLY/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06U/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/5DHS/HYB06/010KWH/BT9AE1/VEC237/XJB1SU/NBT018/H4M/NOADR/DLIGM2/PGPRT2/FEUAR3/SCDVIT/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET05/SDANGM/ECOMOD/SSRCAR/AIVCT/AVGSI/TPQNW/TSGNE/2TON/ITPK4/MLEXP1/SPERTA/SSPERG/SPERTP/VOLNCH/SREACT/AVTSR1/SWALBO/DWGE01/AVC1A/VSPTA/1234Y/AEBS07/PRAHL/RRCAM", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=HJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRV%2FSBARTO%2FCA02%2FTN%2FPBNCH%2FVT%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIYPC%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETRCR%2FREPNTC%2FLVAVIP%2FLVAREI%2FALOUC5%2FNA418%2FBVH4%2FECLHB4%2FRDIF10%2FCSRFLY%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB06%2FH4M%2FNOADR%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FSSCCPC%2FRCALL%2FMET05%2FSDANGM%2FSSRCAR%2FAVGSI%2FITPK4%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLNCH%2FSREACT%2FDWGE01%2FRRCAM&databaseId=b2b4fefb-d131-4f8f-9a24-4223c38bc710&bookmarkSet=CARPICKER&bookmark=EXT_34_RIGHT_FRONT&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=HJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRV%2FSBARTO%2FCA02%2FTN%2FPBNCH%2FVT%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIYPC%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETRCR%2FREPNTC%2FLVAVIP%2FLVAREI%2FALOUC5%2FNA418%2FBVH4%2FECLHB4%2FRDIF10%2FCSRFLY%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB06%2FH4M%2FNOADR%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FSSCCPC%2FRCALL%2FMET05%2FSDANGM%2FSSRCAR%2FAVGSI%2FITPK4%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLNCH%2FSREACT%2FDWGE01%2FRRCAM&databaseId=b2b4fefb-d131-4f8f-9a24-4223c38bc710&bookmarkSet=CARPICKER&bookmark=EXT_34_RIGHT_FRONT&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "NONE", + "easyConnectStore": false, + "electrical": false, + "rlinkStore": false, + "deliveryDate": "2020-09-30", + "retrievedFromDhs": false, + "engineEnergyType": "PHEV", + "radioCode": "1234" + } + } + ] +} diff --git a/tests/fixtures/renault/vehicle_zoe_40.json b/tests/fixtures/renault/vehicle_zoe_40.json new file mode 100644 index 00000000000..ab80d586652 --- /dev/null +++ b/tests/fixtures/renault/vehicle_zoe_40.json @@ -0,0 +1,189 @@ +{ + "accountId": "account-id-1", + "country": "FR", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777999", + "status": "ACTIVE", + "linkType": "OWNER", + "garageBrand": "RENAULT", + "annualMileage": 16000, + "mileage": 26464, + "startDate": "2017-08-07", + "createdDate": "2019-05-23T21:38:16.409008Z", + "lastModifiedDate": "2020-11-17T08:41:40.497400Z", + "ownershipStartDate": "2017-08-01", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2019-06-17T09:49:06.880627Z", + "lastModifiedDate": "2019-06-17T09:49:06.880627Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777999", + "registrationDate": "2017-08-01", + "firstRegistrationDate": "2017-08-01", + "engineType": "5AQ", + "engineRatio": "601", + "modelSCR": "ZOE", + "deliveryCountry": { + "code": "FR", + "label": "FRANCE" + }, + "family": { + "code": "X10", + "label": "FAMILLE X10", + "group": "007" + }, + "tcu": { + "code": "TCU0G2", + "label": "TCU VER 0 GEN 2", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "NAV3G5", + "label": "LEVEL 3 TYPE 5 NAVIGATION", + "group": "408" + }, + "battery": { + "code": "BT4AR1", + "label": "BATTERIE BT4AR1", + "group": "968" + }, + "radioType": { + "code": "RAD37A", + "label": "RADIO 37A", + "group": "425" + }, + "registrationCountry": { + "code": "FR" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "X101VE", + "label": "ZOE", + "group": "971" + }, + "gearbox": { + "code": "BVEL", + "label": "BOITE A VARIATEUR ELECTRIQUE", + "group": "427" + }, + "version": { + "code": "INT MB 10R" + }, + "energy": { + "code": "ELEC", + "label": "ELECTRIQUE", + "group": "019" + }, + "registrationNumber": "REG-NUMBER", + "vcd": "SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + }, + { + "assetType": "PDF", + "assetRole": "GUIDE", + "title": "PDF Guide", + "description": "", + "renditions": [ + { + "url": "https://cdn.group.renault.com/ren/gb/myr/assets/x101ve/manual.pdf.asset.pdf/1558704861676.pdf" + } + ] + }, + { + "assetType": "URL", + "assetRole": "GUIDE", + "title": "e-guide", + "description": "", + "renditions": [ + { + "url": "http://gb.e-guide.renault.com/eng/Zoe" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "10 Fundamentals about getting the best out of your electric vehicle", + "description": "", + "renditions": [ + { + "url": "39r6QEKcOM4" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Automatic Climate Control", + "description": "", + "renditions": [ + { + "url": "Va2FnZFo_GE" + } + ] + }, + { + "assetType": "URL", + "assetRole": "CAR", + "title": "More videos", + "description": "", + "renditions": [ + { + "url": "https://www.youtube.com/watch?v=wfpCMkK1rKI" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Charging the battery", + "description": "", + "renditions": [ + { + "url": "RaEad8DjUJs" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Charging the battery at a station with a flap", + "description": "", + "renditions": [ + { + "url": "zJfd7fJWtr0" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "RLINK1", + "easyConnectStore": false, + "electrical": true, + "rlinkStore": false, + "deliveryDate": "2017-08-11", + "retrievedFromDhs": false, + "engineEnergyType": "ELEC", + "radioCode": "1234" + } + } + ] +} diff --git a/tests/fixtures/renault/vehicle_zoe_50.json b/tests/fixtures/renault/vehicle_zoe_50.json new file mode 100644 index 00000000000..560b2a2246a --- /dev/null +++ b/tests/fixtures/renault/vehicle_zoe_50.json @@ -0,0 +1,161 @@ +{ + "country": "GB", + "vehicleLinks": [ + { + "preferredDealer": { + "brand": "RENAULT", + "createdDate": "2019-05-23T20:42:01.086661Z", + "lastModifiedDate": "2019-05-23T20:42:01.086662Z", + "dealerId": "dealer-id-1" + }, + "garageBrand": "RENAULT", + "vehicleDetails": { + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=DLIGM2%2FKITPOU%2FDANGMO%2FITPK4%2FVOLNCH%2FREACTI%2FSSAEBS%2FPRAHL%2FRRCAM%2FX10%2FB10%2FEA3%2FDG%2FCAREG%2FVSTLAR%2FRET03%2FPROJAB%2FRALU16%2FDRAP13%2F3ATRPH%2FTELNJ%2FALEVA%2FVLCUIR%2FRETRCR%2FRETC%2FLVAREL%2FSGSCHA%2FNA418%2FRDIF01%2FTL01A%2FNBT022&databaseId=a864e752-b1b9-405e-9c3e-880073e36cc9&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=DLIGM2%2FKITPOU%2FDANGMO%2FITPK4%2FVOLNCH%2FREACTI%2FSSAEBS%2FPRAHL%2FRRCAM%2FX10%2FB10%2FEA3%2FDG%2FCAREG%2FVSTLAR%2FRET03%2FPROJAB%2FRALU16%2FDRAP13%2F3ATRPH%2FTELNJ%2FALEVA%2FVLCUIR%2FRETRCR%2FRETC%2FLVAREL%2FSGSCHA%2FNA418%2FRDIF01%2FTL01A%2FNBT022&databaseId=a864e752-b1b9-405e-9c3e-880073e36cc9&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + }, + { + "title": "PDF Guide", + "description": "", + "assetType": "PDF", + "assetRole": "GUIDE", + "renditions": [ + { + "url": "https://cdn.group.renault.com/ren/gb/myr/assets/x102ve/manual.pdf.asset.pdf/1558696740707.pdf" + } + ] + }, + { + "title": "e-guide", + "description": "", + "assetType": "URL", + "assetRole": "GUIDE", + "renditions": [ + { + "url": "https://gb.e-guide.renault.com/eng/Zoe-ph2" + } + ] + }, + { + "title": "All-New ZOE: Welcome to your new car", + "description": "", + "assetType": "VIDEO", + "assetRole": "CAR", + "renditions": [ + { + "url": "1OGwwmWHB6o" + } + ] + }, + { + "title": "Renault ZOE: All you need to know", + "description": "", + "assetType": "VIDEO", + "assetRole": "CAR", + "renditions": [ + { + "url": "_BVH-Rd6e5I" + } + ] + } + ], + "engineType": "5AQ", + "registrationCountry": { + "code": "FR" + }, + "radioType": { + "group": "425", + "code": "NA418", + "label": " FULL NAV DAB ETH - AUDI" + }, + "tcu": { + "group": "E70", + "code": "AIVCT", + "label": "AVEC BOITIER CONNECT AIVC" + }, + "brand": { + "label": "RENAULT" + }, + "deliveryDate": "2020-01-22", + "engineEnergyType": "ELEC", + "registrationDate": "2020-01-13", + "gearbox": { + "group": "427", + "code": "BVEL", + "label": "BOITE A VARIATEUR ELECTRIQUE" + }, + "model": { + "group": "971", + "code": "X102VE", + "label": "ZOE" + }, + "electrical": true, + "energy": { + "group": "019", + "code": "ELEC", + "label": "ELECTRIQUE" + }, + "navigationAssistanceLevel": { + "group": "408", + "code": "SAN408", + "label": "CRITERE DE CONTEXTE" + }, + "yearsOfMaintenance": 12, + "rlinkStore": false, + "radioCode": "1234", + "registrationNumber": "REG-NUMBER", + "modelSCR": "ZOE", + "easyConnectStore": false, + "engineRatio": "605", + "battery": { + "group": "968", + "code": "BT4AR1", + "label": "BATTERIE BT4AR1" + }, + "vin": "VF1AAAAA555777999", + "retrievedFromDhs": false, + "vcd": "ASCOD0/DLIGM2/SSTINC/KITPOU/SKTPGR/SSCCPC/SDPSEC/FDIU2/SSMAP/SSCALL/FACBA1/DANGMO/SSRCAR/SSCABD/AIVCT/AVGSI/ITPK4/VOLNCH/REACTI/AVOSP1/SWALBO/SSDWGE/1234Y/SSAEBS/PRAHL/RRCAM/STANDA/X10/B10/EA3/MD/ELEC/DG/TEMP/TR4X2/AFURGE/RV/ABS/CAREG/LAC/VSTLAR/CPETIR/RET03/PROJAB/RALU16/CEAVRH/ADAC/AIRBA2/SERIE/DRA/DRAP13/HARM02/3ATRPH/SGAV01/BARRAB/TELNJ/SFBANA/KM/DPRPN/AVREPL/SSDECA/ABLAV/ASRESP/ALEVA/SCACBA/SOP02C/STHPLG/SKTGRV/VLCUIR/RETRCR/TRSEV1/RETC/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/FRA01/APL03/FSTPO/ALOUC5/CMAR3P/SAN408/NA418/BVEL/AUTAUG/SPREST/RDIF01/ISOFIX/EQPEUR/HRGM01/SDPCLV/CHASTD/TL01A/SPRODI/SAN613/AIRBDE/PSMREC/ELC1/SSPTLP/SANCML/SEXTIN/PE2019/PHAS2/SAN913/THABT2/SSTYAD/SSHYB/052KWH/BT4AR1/VEC018/X102VE/NBT022/5AQ", + "firstRegistrationDate": "2020-01-13", + "deliveryCountry": { + "code": "FR", + "label": "FRANCE" + }, + "connectivityTechnology": "RLINK1", + "family": { + "group": "007", + "code": "X10", + "label": "FAMILLE X10" + }, + "version": { + "code": "INT A MD 1L" + } + }, + "status": "ACTIVE", + "createdDate": "2020-08-21T16:48:00.243967Z", + "cancellationReason": {}, + "linkType": "OWNER", + "connectedDriver": { + "role": "MAIN_DRIVER", + "lastModifiedDate": "2020-08-22T09:41:53.477398Z", + "createdDate": "2020-08-22T09:41:53.477398Z" + }, + "vin": "VF1AAAAA555777999", + "lastModifiedDate": "2020-11-29T22:01:21.162572Z", + "brand": "RENAULT", + "startDate": "2020-08-21", + "ownershipStartDate": "2020-01-13", + "ownershipEndDate": "2020-08-21" + } + ], + "accountId": "account-id-1" +} From a8536e3ce5796aab9c3e4fc252c24b2ac76bb1ce Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 28 Jul 2021 22:56:34 +0200 Subject: [PATCH 002/903] Bump version to 2021.9.0dev0 (#53638) --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 0b8523bfa6f..ccd42ca32bb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Final MAJOR_VERSION: Final = 2021 -MINOR_VERSION: Final = 8 +MINOR_VERSION: Final = 9 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" From e1c6ccb1988e02e80e65e61ef36d7d4d4e2b0ead Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 28 Jul 2021 17:15:27 -0400 Subject: [PATCH 003/903] Add zwave_js.reset_meter service (#53390) * Add zwave_js.meter_reset service * fix log statement * Add endpoint attribute to service call and rename service * Make service an entity service * remove endpoint from service description --- homeassistant/components/zwave_js/const.py | 22 +++---- homeassistant/components/zwave_js/sensor.py | 38 +++++++++++- .../components/zwave_js/services.yaml | 23 ++++++++ tests/components/zwave_js/test_sensor.py | 59 +++++++++++++++++++ 4 files changed, 130 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index ae5607745f6..7848af146b5 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -49,24 +49,24 @@ ATTR_NODE = "node" ATTR_ZWAVE_VALUE = "zwave_value" # service constants -ATTR_NODES = "nodes" - +SERVICE_SET_VALUE = "set_value" +SERVICE_RESET_METER = "reset_meter" +SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" +SERVICE_PING = "ping" +SERVICE_REFRESH_VALUE = "refresh_value" SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS = "bulk_set_partial_config_parameters" +ATTR_NODES = "nodes" +# config parameter ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_PARAMETER_BITMASK = "bitmask" ATTR_CONFIG_VALUE = "value" - -SERVICE_REFRESH_VALUE = "refresh_value" - +# refresh value ATTR_REFRESH_ALL_VALUES = "refresh_all_values" - -SERVICE_SET_VALUE = "set_value" -SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" - +# multicast ATTR_BROADCAST = "broadcast" - -SERVICE_PING = "ping" +# meter reset +ATTR_METER_TYPE = "meter_type" ADDON_SLUG = "core_zwave_js" diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index f20a12a519a..7c41ad035be 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from typing import cast +import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, ConfigurationValueType from zwave_js_server.model.node import Node as ZwaveNode @@ -26,10 +27,11 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import ATTR_METER_TYPE, ATTR_VALUE, DATA_CLIENT, DOMAIN, SERVICE_RESET_METER from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity from .helpers import get_device_id @@ -89,6 +91,16 @@ async def async_setup_entry( ) ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_RESET_METER, + { + vol.Optional(ATTR_METER_TYPE): vol.Coerce(int), + vol.Optional(ATTR_VALUE): vol.Coerce(int), + }, + "async_reset_meter", + ) + class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): """Basic Representation of a Z-Wave sensor.""" @@ -218,6 +230,30 @@ class ZWaveNumericSensor(ZwaveSensorBase): return str(self.info.primary_value.metadata.unit) + async def async_reset_meter( + self, meter_type: int | None = None, value: int | None = None + ) -> None: + """Reset meter(s) on device.""" + node = self.info.node + primary_value = self.info.primary_value + if primary_value.command_class != CommandClass.METER: + raise TypeError("Reset only available for Meter sensors") + options = {} + if meter_type is not None: + options["type"] = meter_type + if value is not None: + options["targetValue"] = value + args = [options] if options else [] + await node.endpoints[primary_value.endpoint].async_invoke_cc_api( + CommandClass.METER, "reset", *args, wait_for_result=False + ) + LOGGER.debug( + "Meters on node %s endpoint %s reset with the following options: %s", + node, + primary_value.endpoint, + options, + ) + class ZWaveListSensor(ZwaveSensorBase): """Representation of a Z-Wave Numeric sensor with multiple states.""" diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index f737f159806..b41a893c7e4 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -229,3 +229,26 @@ ping: target: entity: integration: zwave_js + +reset_meter: + name: Reset meter(s) on a node + description: Resets the meter(s) on a node. + target: + entity: + domain: sensor + integration: zwave_js + fields: + meter_type: + name: Meter Type + description: The type of meter to reset. Not all meters support the ability to pick a meter type to reset. + example: 1 + required: false + selector: + text: + value: + name: Target Value + description: The value that meter(s) should be reset to. Not all meters support the ability to be reset to a specific value. + example: 5 + required: false + selector: + text: diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index fc6d274235d..e368ec1b026 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,7 +1,14 @@ """Test the Z-Wave JS sensor platform.""" from zwave_js_server.event import Event +from homeassistant.components.zwave_js.const import ( + ATTR_METER_TYPE, + ATTR_VALUE, + DOMAIN, + SERVICE_RESET_METER, +) from homeassistant.const import ( + ATTR_ENTITY_ID, DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_POWER, @@ -131,3 +138,55 @@ async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): ) node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + + +async def test_reset_meter( + hass, + client, + aeon_smart_switch_6, + integration, +): + """Test reset_meter service.""" + SENSOR = "sensor.smart_switch_6_electric_consumed_v" + client.async_send_command.return_value = {} + client.async_send_command_no_wait.return_value = {} + + # Test successful meter reset call + await hass.services.async_call( + DOMAIN, + SERVICE_RESET_METER, + { + ATTR_ENTITY_ID: SENSOR, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["nodeId"] == aeon_smart_switch_6.node_id + assert args["endpoint"] == 0 + assert args["args"] == [] + + client.async_send_command_no_wait.reset_mock() + + # Test successful meter reset call with options + await hass.services.async_call( + DOMAIN, + SERVICE_RESET_METER, + { + ATTR_ENTITY_ID: SENSOR, + ATTR_METER_TYPE: 1, + ATTR_VALUE: 2, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["nodeId"] == aeon_smart_switch_6.node_id + assert args["endpoint"] == 0 + assert args["args"] == [{"type": 1, "targetValue": 2}] + + client.async_send_command_no_wait.reset_mock() From d40012f110ced64afa0e7115b66eed879c078bcf Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Wed, 28 Jul 2021 23:36:13 +0200 Subject: [PATCH 004/903] Correct typing in Zerproc and activate mypy (#53642) --- homeassistant/components/zerproc/light.py | 3 +-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index ad14d9c506a..3f0136f9b0d 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -16,7 +16,6 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util @@ -30,7 +29,7 @@ SUPPORT_ZERPROC = SUPPORT_BRIGHTNESS | SUPPORT_COLOR DISCOVERY_INTERVAL = timedelta(seconds=60) -async def discover_entities(hass: HomeAssistant) -> list[Entity]: +async def discover_entities(hass: HomeAssistant) -> list[ZerprocLight]: """Attempt to discover new lights.""" lights = await pyzerproc.discover() diff --git a/mypy.ini b/mypy.ini index e38897bf303..cb10dee585b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1777,9 +1777,6 @@ ignore_errors = true [mypy-homeassistant.components.yeelight.*] ignore_errors = true -[mypy-homeassistant.components.zerproc.*] -ignore_errors = true - [mypy-homeassistant.components.zha.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 8ff72c332da..e507e906964 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -192,7 +192,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xiaomi_miio.*", "homeassistant.components.yamaha.*", "homeassistant.components.yeelight.*", - "homeassistant.components.zerproc.*", "homeassistant.components.zha.*", "homeassistant.components.zwave.*", ] From bbd1a85b0947d08be3dccaf413cb3fdca8efdca9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Jul 2021 15:48:27 -0700 Subject: [PATCH 005/903] Add last reset to Shelly (#53654) --- homeassistant/components/shelly/entity.py | 2 ++ homeassistant/components/shelly/sensor.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 743dd07414e..c8b23f71bd7 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass +from datetime import datetime import logging from typing import Any, Callable, Final, cast @@ -179,6 +180,7 @@ class BlockAttributeDescription: # Callable (settings, block), return true if entity should be removed removal_condition: Callable[[dict, aioshelly.Block], bool] | None = None extra_state_attributes: Callable[[aioshelly.Block], dict | None] | None = None + last_reset: datetime | None = None @dataclass diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 96ff6e55f8d..7c2ffdbc470 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1,6 +1,7 @@ """Sensor for Shelly.""" from __future__ import annotations +from datetime import datetime from typing import Final, cast from homeassistant.components import sensor @@ -20,6 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util import dt from .const import SHAIR_MAX_WORK_HOURS from .entity import ( @@ -119,6 +121,7 @@ SENSORS: Final = { value=lambda value: round(value / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), ), ("emeter", "energyReturned"): BlockAttributeDescription( name="Energy Returned", @@ -126,6 +129,7 @@ SENSORS: Final = { value=lambda value: round(value / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), ), ("light", "energy"): BlockAttributeDescription( name="Energy", @@ -257,6 +261,11 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): """State class of sensor.""" return self.description.state_class + @property + def last_reset(self) -> datetime | None: + """State class of sensor.""" + return self.description.last_reset + @property def unit_of_measurement(self) -> str | None: """Return unit of sensor.""" From 6eb330773456a4cbbfc5576cae684349c2514d22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jul 2021 18:09:49 -0500 Subject: [PATCH 006/903] Fix invalid homekit state when arming (#53646) - Maybe fixes #48538 --- .../homekit/type_security_systems.py | 141 +++++++++--------- .../homekit/test_type_security_systems.py | 63 +++++++- 2 files changed, 127 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index acbf636c1c3..6fe1a4e9e29 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -2,13 +2,13 @@ import logging from pyhap.const import CATEGORY_ALARM_SYSTEM -from pyhap.loader import get_loader from homeassistant.components.alarm_control_panel import DOMAIN from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, SUPPORT_ALARM_TRIGGER, ) from homeassistant.const import ( @@ -22,6 +22,8 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -36,28 +38,43 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -HASS_TO_HOMEKIT = { - STATE_ALARM_ARMED_HOME: 0, - STATE_ALARM_ARMED_AWAY: 1, - STATE_ALARM_ARMED_NIGHT: 2, - STATE_ALARM_DISARMED: 3, - STATE_ALARM_TRIGGERED: 4, +HK_ALARM_STAY_ARMED = 0 +HK_ALARM_AWAY_ARMED = 1 +HK_ALARM_NIGHT_ARMED = 2 +HK_ALARM_DISARMED = 3 +HK_ALARM_TRIGGERED = 4 + +HASS_TO_HOMEKIT_CURRENT = { + STATE_ALARM_ARMED_HOME: HK_ALARM_STAY_ARMED, + STATE_ALARM_ARMED_VACATION: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_AWAY: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_NIGHT: HK_ALARM_NIGHT_ARMED, + STATE_ALARM_ARMING: HK_ALARM_DISARMED, + STATE_ALARM_DISARMED: HK_ALARM_DISARMED, + STATE_ALARM_TRIGGERED: HK_ALARM_TRIGGERED, +} + +HASS_TO_HOMEKIT_TARGET = { + STATE_ALARM_ARMED_HOME: HK_ALARM_STAY_ARMED, + STATE_ALARM_ARMED_VACATION: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_AWAY: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_NIGHT: HK_ALARM_NIGHT_ARMED, + STATE_ALARM_ARMING: HK_ALARM_AWAY_ARMED, + STATE_ALARM_DISARMED: HK_ALARM_DISARMED, } HASS_TO_HOMEKIT_SERVICES = { - SERVICE_ALARM_ARM_HOME: 0, - SERVICE_ALARM_ARM_AWAY: 1, - SERVICE_ALARM_ARM_NIGHT: 2, - SERVICE_ALARM_DISARM: 3, + SERVICE_ALARM_ARM_HOME: HK_ALARM_STAY_ARMED, + SERVICE_ALARM_ARM_AWAY: HK_ALARM_AWAY_ARMED, + SERVICE_ALARM_ARM_NIGHT: HK_ALARM_NIGHT_ARMED, + SERVICE_ALARM_DISARM: HK_ALARM_DISARMED, } -HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} - -STATE_TO_SERVICE = { - STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY, - STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME, - STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT, - STATE_ALARM_DISARMED: SERVICE_ALARM_DISARM, +HK_TO_SERVICE = { + HK_ALARM_AWAY_ARMED: SERVICE_ALARM_ARM_AWAY, + HK_ALARM_STAY_ARMED: SERVICE_ALARM_ARM_HOME, + HK_ALARM_NIGHT_ARMED: SERVICE_ALARM_ARM_NIGHT, + HK_ALARM_DISARMED: SERVICE_ALARM_DISARM, } @@ -75,65 +92,51 @@ class SecuritySystem(HomeAccessory): ATTR_SUPPORTED_FEATURES, ( SUPPORT_ALARM_ARM_HOME + | SUPPORT_ALARM_ARM_VACATION | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_TRIGGER ), ) - loader = get_loader() - default_current_states = loader.get_char( - "SecuritySystemCurrentState" - ).properties.get("ValidValues") - default_target_services = loader.get_char( - "SecuritySystemTargetState" - ).properties.get("ValidValues") + serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM) + current_char = serv_alarm.get_characteristic(CHAR_CURRENT_SECURITY_STATE) + target_char = serv_alarm.get_characteristic(CHAR_TARGET_SECURITY_STATE) + default_current_states = current_char.properties.get("ValidValues") + default_target_services = target_char.properties.get("ValidValues") - current_supported_states = [ - HASS_TO_HOMEKIT[STATE_ALARM_DISARMED], - HASS_TO_HOMEKIT[STATE_ALARM_TRIGGERED], - ] - target_supported_services = [HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_DISARM]] + current_supported_states = [HK_ALARM_DISARMED, HK_ALARM_TRIGGERED] + target_supported_services = [HK_ALARM_DISARMED] if supported_states & SUPPORT_ALARM_ARM_HOME: - current_supported_states.append(HASS_TO_HOMEKIT[STATE_ALARM_ARMED_HOME]) - target_supported_services.append( - HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_ARM_HOME] - ) + current_supported_states.append(HK_ALARM_STAY_ARMED) + target_supported_services.append(HK_ALARM_STAY_ARMED) - if supported_states & SUPPORT_ALARM_ARM_AWAY: - current_supported_states.append(HASS_TO_HOMEKIT[STATE_ALARM_ARMED_AWAY]) - target_supported_services.append( - HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_ARM_AWAY] - ) + if supported_states & (SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_VACATION): + current_supported_states.append(HK_ALARM_AWAY_ARMED) + target_supported_services.append(HK_ALARM_AWAY_ARMED) if supported_states & SUPPORT_ALARM_ARM_NIGHT: - current_supported_states.append(HASS_TO_HOMEKIT[STATE_ALARM_ARMED_NIGHT]) - target_supported_services.append( - HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_ARM_NIGHT] - ) + current_supported_states.append(HK_ALARM_NIGHT_ARMED) + target_supported_services.append(HK_ALARM_NIGHT_ARMED) - new_current_states = { - key: val - for key, val in default_current_states.items() - if val in current_supported_states - } - new_target_services = { - key: val - for key, val in default_target_services.items() - if val in target_supported_services - } - - serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM) self.char_current_state = serv_alarm.configure_char( CHAR_CURRENT_SECURITY_STATE, - value=HASS_TO_HOMEKIT[STATE_ALARM_DISARMED], - valid_values=new_current_states, + value=HASS_TO_HOMEKIT_CURRENT[STATE_ALARM_DISARMED], + valid_values={ + key: val + for key, val in default_current_states.items() + if val in current_supported_states + }, ) self.char_target_state = serv_alarm.configure_char( CHAR_TARGET_SECURITY_STATE, value=HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_DISARM], - valid_values=new_target_services, + valid_values={ + key: val + for key, val in default_target_services.items() + if val in target_supported_services + }, setter_callback=self.set_security_state, ) @@ -144,9 +147,7 @@ class SecuritySystem(HomeAccessory): def set_security_state(self, value): """Move security state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set security state to %d", self.entity_id, value) - hass_value = HOMEKIT_TO_HASS[value] - service = STATE_TO_SERVICE[hass_value] - + service = HK_TO_SERVICE[value] params = {ATTR_ENTITY_ID: self.entity_id} if self._alarm_code: params[ATTR_CODE] = self._alarm_code @@ -156,20 +157,16 @@ class SecuritySystem(HomeAccessory): def async_update_state(self, new_state): """Update security state after state changed.""" hass_state = new_state.state - if hass_state in HASS_TO_HOMEKIT: - current_security_state = HASS_TO_HOMEKIT[hass_state] - if self.char_current_state.value != current_security_state: - self.char_current_state.set_value(current_security_state) + if (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None: + if self.char_current_state.value != current_state: + self.char_current_state.set_value(current_state) _LOGGER.debug( "%s: Updated current state to %s (%d)", self.entity_id, hass_state, - current_security_state, + current_state, ) - # SecuritySystemTargetState does not support triggered - if ( - hass_state != STATE_ALARM_TRIGGERED - and self.char_target_state.value != current_security_state - ): - self.char_target_state.set_value(current_security_state) + if (target_state := HASS_TO_HOMEKIT_TARGET.get(hass_state)) is not None: + if self.char_target_state.value != target_state: + self.char_target_state.set_value(target_state) diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 19b8b5720e2..d1ce830a0e2 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -17,6 +17,8 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, @@ -79,7 +81,7 @@ async def test_switch_set_state(hass, hk_driver, events): call_arm_night = async_mock_service(hass, DOMAIN, "alarm_arm_night") call_disarm = async_mock_service(hass, DOMAIN, "alarm_disarm") - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 0) + acc.char_target_state.client_update_value(0) await hass.async_block_till_done() assert call_arm_home assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id @@ -88,7 +90,7 @@ async def test_switch_set_state(hass, hk_driver, events): assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 1) + acc.char_target_state.client_update_value(1) await hass.async_block_till_done() assert call_arm_away assert call_arm_away[0].data[ATTR_ENTITY_ID] == entity_id @@ -97,7 +99,7 @@ async def test_switch_set_state(hass, hk_driver, events): assert len(events) == 2 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 2) + acc.char_target_state.client_update_value(2) await hass.async_block_till_done() assert call_arm_night assert call_arm_night[0].data[ATTR_ENTITY_ID] == entity_id @@ -106,7 +108,7 @@ async def test_switch_set_state(hass, hk_driver, events): assert len(events) == 3 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 3) + acc.char_target_state.client_update_value(3) await hass.async_block_till_done() assert call_disarm assert call_disarm[0].data[ATTR_ENTITY_ID] == entity_id @@ -128,7 +130,7 @@ async def test_no_alarm_code(hass, hk_driver, config, events): # Set from HomeKit call_arm_home = async_mock_service(hass, DOMAIN, "alarm_arm_home") - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 0) + acc.char_target_state.client_update_value(0) await hass.async_block_till_done() assert call_arm_home assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id @@ -138,6 +140,57 @@ async def test_no_alarm_code(hass, hk_driver, config, events): assert events[-1].data[ATTR_VALUE] is None +async def test_arming(hass, hk_driver, events): + """Test to make sure arming sets the right state.""" + entity_id = "alarm_control_panel.test" + + hass.states.async_set(entity_id, None) + + acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, {}) + await acc.run() + await hass.async_block_till_done() + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_AWAY) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 1 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_HOME) + await hass.async_block_till_done() + assert acc.char_target_state.value == 0 + assert acc.char_current_state.value == 0 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_VACATION) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 1 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_NIGHT) + await hass.async_block_till_done() + assert acc.char_target_state.value == 2 + assert acc.char_current_state.value == 2 + + hass.states.async_set(entity_id, STATE_ALARM_ARMING) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 3 + + hass.states.async_set(entity_id, STATE_ALARM_DISARMED) + await hass.async_block_till_done() + assert acc.char_target_state.value == 3 + assert acc.char_current_state.value == 3 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_AWAY) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 1 + + hass.states.async_set(entity_id, STATE_ALARM_TRIGGERED) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 4 + + async def test_supported_states(hass, hk_driver, events): """Test different supported states.""" code = "1234" From 057d979335fa63094236e0f33a35a0c60bd77915 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Jul 2021 17:31:10 -0700 Subject: [PATCH 007/903] Add last reset to enphase sensors (#53653) --- .../components/enphase_envoy/__init__.py | 8 +- .../components/enphase_envoy/const.py | 87 +++++++++++++++---- .../components/enphase_envoy/sensor.py | 43 ++++----- 3 files changed, 88 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index dfd6b782408..69c488169a6 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -47,9 +47,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except httpx.HTTPError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - for condition in SENSORS: - if condition != "inverters": - data[condition] = await getattr(envoy_reader, condition)() + for description in SENSORS: + if description.key != "inverters": + data[description.key] = await getattr( + envoy_reader, description.key + )() else: data[ "inverters_production" diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 7a1de25e242..9f87a821787 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -1,8 +1,12 @@ """The enphase_envoy component.""" -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT -from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) +from homeassistant.const import DEVICE_CLASS_ENERGY, ENERGY_WATT_HOUR, POWER_WATT +from homeassistant.util import dt DOMAIN = "enphase_envoy" @@ -12,22 +16,67 @@ PLATFORMS = ["sensor"] COORDINATOR = "coordinator" NAME = "name" -SENSORS = { - "production": ("Current Energy Production", POWER_WATT, STATE_CLASS_MEASUREMENT), - "daily_production": ("Today's Energy Production", ENERGY_WATT_HOUR, None), - "seven_days_production": ( - "Last Seven Days Energy Production", - ENERGY_WATT_HOUR, - None, +SENSORS = ( + SensorEntityDescription( + key="production", + name="Current Power Production", + unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, ), - "lifetime_production": ("Lifetime Energy Production", ENERGY_WATT_HOUR, None), - "consumption": ("Current Energy Consumption", POWER_WATT, STATE_CLASS_MEASUREMENT), - "daily_consumption": ("Today's Energy Consumption", ENERGY_WATT_HOUR, None), - "seven_days_consumption": ( - "Last Seven Days Energy Consumption", - ENERGY_WATT_HOUR, - None, + SensorEntityDescription( + key="daily_production", + name="Today's Energy Production", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, ), - "lifetime_consumption": ("Lifetime Energy Consumption", ENERGY_WATT_HOUR, None), - "inverters": ("Inverter", POWER_WATT, STATE_CLASS_MEASUREMENT), -} + SensorEntityDescription( + key="seven_days_production", + name="Last Seven Days Energy Production", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + ), + SensorEntityDescription( + key="lifetime_production", + name="Lifetime Energy Production", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + last_reset=dt.utc_from_timestamp(0), + ), + SensorEntityDescription( + key="consumption", + name="Current Power Consumption", + unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="daily_consumption", + name="Today's Energy Consumption", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + ), + SensorEntityDescription( + key="seven_days_consumption", + name="Last Seven Days Energy Consumption", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + ), + SensorEntityDescription( + key="lifetime_consumption", + name="Lifetime Energy Consumption", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + last_reset=dt.utc_from_timestamp(0), + ), + SensorEntityDescription( + key="inverters", + name="Inverter", + unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), +) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 3fab9e320dc..29d273401f4 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -56,42 +56,38 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name = data[NAME] entities = [] - for condition, sensor in SENSORS.items(): + for sensor_description in SENSORS: if ( - condition == "inverters" + sensor_description.key == "inverters" and coordinator.data.get("inverters_production") is not None ): for inverter in coordinator.data["inverters_production"]: - entity_name = f"{name} {sensor[0]} {inverter}" + entity_name = f"{name} {sensor_description.name} {inverter}" split_name = entity_name.split(" ") serial_number = split_name[-1] entities.append( Envoy( - condition, + sensor_description, entity_name, name, config_entry.unique_id, serial_number, - sensor[1], - sensor[2], coordinator, ) ) - elif condition != "inverters": - data = coordinator.data.get(condition) + elif sensor_description.key != "inverters": + data = coordinator.data.get(sensor_description.key) if isinstance(data, str) and "not available" in data: continue - entity_name = f"{name} {sensor[0]}" + entity_name = f"{name} {sensor_description.name}" entities.append( Envoy( - condition, + sensor_description, entity_name, name, config_entry.unique_id, None, - sensor[1], - sensor[2], coordinator, ) ) @@ -104,23 +100,19 @@ class Envoy(CoordinatorEntity, SensorEntity): def __init__( self, - sensor_type, + description, name, device_name, device_serial_number, serial_number, - unit, - state_class, coordinator, ): """Initialize Envoy entity.""" - self._type = sensor_type + self.entity_description = description self._name = name self._serial_number = serial_number self._device_name = device_name self._device_serial_number = device_serial_number - self._unit_of_measurement = unit - self._attr_state_class = state_class super().__init__(coordinator) @@ -135,16 +127,16 @@ class Envoy(CoordinatorEntity, SensorEntity): if self._serial_number: return self._serial_number if self._device_serial_number: - return f"{self._device_serial_number}_{self._type}" + return f"{self._device_serial_number}_{self.entity_description.key}" @property def state(self): """Return the state of the sensor.""" - if self._type != "inverters": - value = self.coordinator.data.get(self._type) + if self.entity_description.key != "inverters": + value = self.coordinator.data.get(self.entity_description.key) elif ( - self._type == "inverters" + self.entity_description.key == "inverters" and self.coordinator.data.get("inverters_production") is not None ): value = self.coordinator.data.get("inverters_production").get( @@ -155,11 +147,6 @@ class Envoy(CoordinatorEntity, SensorEntity): return value - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - @property def icon(self): """Icon to use in the frontend, if any.""" @@ -169,7 +156,7 @@ class Envoy(CoordinatorEntity, SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" if ( - self._type == "inverters" + self.entity_description.key == "inverters" and self.coordinator.data.get("inverters_production") is not None ): value = self.coordinator.data.get("inverters_production").get( From ef2ca1cee92d9f4899ea8e5454dcbafce32207f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jul 2021 19:39:45 -0500 Subject: [PATCH 008/903] Bump aiolip to 1.1.6 to fix timeout with ident (#53660) --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index e0acf31e99c..b6f0785ffe7 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -2,7 +2,7 @@ "domain": "lutron_caseta", "name": "Lutron Cas\u00e9ta", "documentation": "https://www.home-assistant.io/integrations/lutron_caseta", - "requirements": ["pylutron-caseta==0.11.0", "aiolip==1.1.4"], + "requirements": ["pylutron-caseta==0.11.0", "aiolip==1.1.6"], "config_flow": true, "zeroconf": ["_leap._tcp.local."], "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index bfa61df2f9e..6f9381d19ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -203,7 +203,7 @@ aiolifx==0.6.9 aiolifx_effects==0.2.2 # homeassistant.components.lutron_caseta -aiolip==1.1.4 +aiolip==1.1.6 # homeassistant.components.lyric aiolyric==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d2e39327df..b1f1ba01960 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ aiohue==2.5.1 aiokafka==0.6.0 # homeassistant.components.lutron_caseta -aiolip==1.1.4 +aiolip==1.1.6 # homeassistant.components.lyric aiolyric==1.0.7 From e14a04df2e90537e4727b91159d5f8468fca33cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jul 2021 23:58:28 -0500 Subject: [PATCH 009/903] Add device class energy and last reset to sense (#53667) --- homeassistant/components/sense/sensor.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index dd522d012a5..5a352969c3b 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -1,7 +1,10 @@ """Support for monitoring a Sense energy sensor.""" +import datetime + from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( ATTR_ATTRIBUTION, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, @@ -9,6 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.util.dt as dt_util from .const import ( ACTIVE_NAME, @@ -218,6 +222,8 @@ class SenseVoltageSensor(SensorEntity): class SenseTrendsSensor(SensorEntity): """Implementation of a Sense energy sensor.""" + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_MEASUREMENT _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON @@ -252,6 +258,13 @@ class SenseTrendsSensor(SensorEntity): """Return if entity is available.""" return self._had_any_update and self._coordinator.last_update_success + @property + def last_reset(self) -> datetime.datetime: + """Return the time when the sensor was last reset, if any.""" + if self._sensor_type == "DAY": + return dt_util.start_of_local_day() + return None + @callback def _async_update(self): """Track if we had an update so we do not report zero data.""" From cdce14d63db209acedb9888e726b813a069bf720 Mon Sep 17 00:00:00 2001 From: Stephen Beechen Date: Wed, 28 Jul 2021 23:12:59 -0600 Subject: [PATCH 010/903] Allow uploading large snapshots (#53528) Co-authored-by: Pascal Vizeli --- homeassistant/components/hassio/http.py | 54 +++++++------------------ tests/components/hassio/test_http.py | 49 ++++++++++++++++------ 2 files changed, 50 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 302cc00bb9f..73e5549be9a 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -1,16 +1,15 @@ """HTTP Support for Hass.io.""" from __future__ import annotations -import asyncio import logging import os import re import aiohttp from aiohttp import web -from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE +from aiohttp.client import ClientError, ClientTimeout +from aiohttp.hdrs import CONTENT_TYPE from aiohttp.web_exceptions import HTTPBadGateway -import async_timeout from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.onboarding import async_is_onboarded @@ -20,8 +19,6 @@ from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID, X_HASSIO _LOGGER = logging.getLogger(__name__) -MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 - NO_TIMEOUT = re.compile( r"^(?:" r"|homeassistant/update" @@ -75,48 +72,28 @@ class HassIOView(HomeAssistantView): async def _command_proxy( self, path: str, request: web.Request - ) -> web.Response | web.StreamResponse: + ) -> web.StreamResponse: """Return a client request with proxy origin for Hass.io supervisor. This method is a coroutine. """ - read_timeout = _get_timeout(path) - client_timeout = 10 - data = None headers = _init_header(request) if path in ("snapshots/new/upload", "backups/new/upload"): # We need to reuse the full content type that includes the boundary headers[ "Content-Type" ] = request._stored_content_type # pylint: disable=protected-access - - # Backups are big, so we need to adjust the allowed size - request._client_max_size = ( # pylint: disable=protected-access - MAX_UPLOAD_SIZE - ) - client_timeout = 300 - try: - with async_timeout.timeout(client_timeout): - data = await request.read() - - method = getattr(self._websession, request.method.lower()) - client = await method( - f"http://{self._host}/{path}", - data=data, + # Stream the request to the supervisor + client = await self._websession.request( + method=request.method, + url=f"http://{self._host}/{path}", headers=headers, - timeout=read_timeout, + data=request.content, + timeout=_get_timeout(path), ) - # Simple request - if int(client.headers.get(CONTENT_LENGTH, 0)) < 4194000: - # Return Response - body = await client.read() - return web.Response( - content_type=client.content_type, status=client.status, body=body - ) - - # Stream response + # Stream the supervisor response back response = web.StreamResponse(status=client.status, headers=client.headers) response.content_type = client.content_type @@ -126,12 +103,9 @@ class HassIOView(HomeAssistantView): return response - except aiohttp.ClientError as err: + except ClientError as err: _LOGGER.error("Client error on api %s request %s", path, err) - except asyncio.TimeoutError: - _LOGGER.error("Client timeout error on API request %s", path) - raise HTTPBadGateway() @@ -151,11 +125,11 @@ def _init_header(request: web.Request) -> dict[str, str]: return headers -def _get_timeout(path: str) -> int: +def _get_timeout(path: str) -> ClientTimeout: """Return timeout for a URL path.""" if NO_TIMEOUT.match(path): - return 0 - return 300 + return ClientTimeout(connect=10) + return ClientTimeout(connect=10, total=300) def _need_auth(hass, path: str) -> bool: diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index fc4bb3e6a0d..881d3cc26ed 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -1,11 +1,13 @@ """The tests for the hassio component.""" -import asyncio -from unittest.mock import patch - +from aiohttp.client import ClientError +from aiohttp.streams import StreamReader +from aiohttp.test_utils import TestClient import pytest from homeassistant.components.hassio.http import _need_auth +from tests.test_util.aiohttp import AiohttpClientMocker + async def test_forward_request(hassio_client, aioclient_mock): """Test fetching normal path.""" @@ -106,16 +108,6 @@ async def test_forward_log_request(hassio_client, aioclient_mock): assert len(aioclient_mock.mock_calls) == 1 -async def test_bad_gateway_when_cannot_find_supervisor(hassio_client): - """Test we get a bad gateway error if we can't find supervisor.""" - with patch( - "homeassistant.components.hassio.http.async_timeout.timeout", - side_effect=asyncio.TimeoutError, - ): - resp = await hassio_client.get("/api/hassio/addons/test/info") - assert resp.status == 502 - - async def test_forwarding_user_info(hassio_client, hass_admin_user, aioclient_mock): """Test that we forward user info correctly.""" aioclient_mock.get("http://127.0.0.1/hello") @@ -171,6 +163,37 @@ async def test_backup_download_headers(hassio_client, aioclient_mock): assert resp.headers["Content-Disposition"] == content_disposition +async def test_supervisor_client_error( + hassio_client: TestClient, aioclient_mock: AiohttpClientMocker +): + """Test any client error from the supervisor returns a 502.""" + # Create a request that throws a ClientError + async def raise_client_error(*args): + raise ClientError() + + aioclient_mock.get( + "http://127.0.0.1/test/raise/error", + side_effect=raise_client_error, + ) + + # Verify it returns bad gateway + resp = await hassio_client.get("/api/hassio/test/raise/error") + assert resp.status == 502 + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_streamed_requests( + hassio_client: TestClient, aioclient_mock: AiohttpClientMocker +): + """Test requests get proxied to the supervisor as a stream.""" + aioclient_mock.get("http://127.0.0.1/test/stream") + await hassio_client.get("/api/hassio/test/stream", data="Test data") + assert len(aioclient_mock.mock_calls) == 1 + + # Verify the request body is passed as a StreamReader + assert isinstance(aioclient_mock.mock_calls[0][2], StreamReader) + + def test_need_auth(hass): """Test if the requested path needs authentication.""" assert not _need_auth(hass, "addons/test/logo") From a2d66bd1c03479988b2aa8e8523d7f9189e0a0d7 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 29 Jul 2021 06:15:28 +0100 Subject: [PATCH 011/903] Prosegur code quality improvements (#53647) --- homeassistant/components/prosegur/__init__.py | 11 +++----- .../components/prosegur/config_flow.py | 13 ++++------ tests/components/prosegur/common.py | 2 +- .../prosegur/test_alarm_control_panel.py | 6 ++--- tests/components/prosegur/test_config_flow.py | 26 +++---------------- tests/components/prosegur/test_init.py | 2 -- 6 files changed, 15 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/prosegur/__init__.py b/homeassistant/components/prosegur/__init__.py index 2e4a0bd0ed5..3e31a1142ce 100644 --- a/homeassistant/components/prosegur/__init__.py +++ b/homeassistant/components/prosegur/__init__.py @@ -3,10 +3,10 @@ import logging from pyprosegur.auth import Auth -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from .const import CONF_COUNTRY, DOMAIN @@ -32,12 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ConnectionRefusedError as error: _LOGGER.error("Configured credential are invalid, %s", error) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH, "entry_id": entry.data["entry_id"]}, - ) - ) + raise ConfigEntryAuthFailed from error except ConnectionError as error: _LOGGER.error("Could not connect with Prosegur backend: %s", error) diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index af1ae456f12..1807561663b 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -25,11 +25,9 @@ STEP_USER_DATA_SCHEMA = vol.Schema( async def validate_input(hass: core.HomeAssistant, data): """Validate the user input allows us to connect.""" + session = aiohttp_client.async_get_clientsession(hass) + auth = Auth(session, data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_COUNTRY]) try: - session = aiohttp_client.async_get_clientsession(hass) - auth = Auth( - session, data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_COUNTRY] - ) install = await Installation.retrieve(auth) except ConnectionRefusedError: raise InvalidAuth from ConnectionRefusedError @@ -95,15 +93,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception as exception: # pylint: disable=broad-except - _LOGGER.exception(exception) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - data = self.entry.data.copy() self.hass.config_entries.async_update_entry( self.entry, data={ - **data, + **self.entry.data, CONF_USERNAME: user_input[CONF_USERNAME], CONF_PASSWORD: user_input[CONF_PASSWORD], }, diff --git a/tests/components/prosegur/common.py b/tests/components/prosegur/common.py index 504da3ea92a..bed9d987ceb 100644 --- a/tests/components/prosegur/common.py +++ b/tests/components/prosegur/common.py @@ -8,7 +8,7 @@ from tests.common import MockConfigEntry CONTRACT = "1234abcd" -async def setup_platform(hass, platform): +async def setup_platform(hass): """Set up the Prosegur platform.""" mock_entry = MockConfigEntry( domain=PROSEGUR_DOMAIN, diff --git a/tests/components/prosegur/test_alarm_control_panel.py b/tests/components/prosegur/test_alarm_control_panel.py index 26e0c5f94b3..9ab0c0d37de 100644 --- a/tests/components/prosegur/test_alarm_control_panel.py +++ b/tests/components/prosegur/test_alarm_control_panel.py @@ -48,7 +48,7 @@ def mock_status(request): async def test_entity_registry(hass, mock_auth, mock_status): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, ALARM_DOMAIN) + await setup_platform(hass) entity_registry = await hass.helpers.entity_registry.async_get_registry() entry = entity_registry.async_get(PROSEGUR_ALARM_ENTITY) @@ -74,7 +74,7 @@ async def test_connection_error(hass, mock_auth): with patch("pyprosegur.installation.Installation.retrieve", return_value=install): - await setup_platform(hass, ALARM_DOMAIN) + await setup_platform(hass) await hass.async_block_till_done() @@ -106,7 +106,7 @@ async def test_arm(hass, mock_auth, code, alarm_service, alarm_state): install.status = code with patch("pyprosegur.installation.Installation.retrieve", return_value=install): - await setup_platform(hass, ALARM_DOMAIN) + await setup_platform(hass) await hass.services.async_call( ALARM_DOMAIN, diff --git a/tests/components/prosegur/test_config_flow.py b/tests/components/prosegur/test_config_flow.py index 447baefed23..bece0bae621 100644 --- a/tests/components/prosegur/test_config_flow.py +++ b/tests/components/prosegur/test_config_flow.py @@ -26,7 +26,7 @@ async def test_form(hass): with patch( "homeassistant.components.prosegur.config_flow.Installation.retrieve", return_value=install, - ), patch( + ) as mock_retrieve, patch( "homeassistant.components.prosegur.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -50,6 +50,8 @@ async def test_form(hass): } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_retrieve.mock_calls) == 1 + async def test_form_invalid_auth(hass): """Test we handle invalid auth.""" @@ -120,28 +122,6 @@ async def test_form_unknown_exception(hass): assert result2["errors"] == {"base": "unknown"} -async def test_form_validate_input(hass): - """Test we retrieve data from Installation.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "pyprosegur.installation.Installation.retrieve", - return_value=MagicMock, - ) as mock_retrieve: - await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "country": "PT", - }, - ) - - assert len(mock_retrieve.mock_calls) == 1 - - async def test_reauth_flow(hass): """Test a reauthentication flow.""" entry = MockConfigEntry( diff --git a/tests/components/prosegur/test_init.py b/tests/components/prosegur/test_init.py index e0fe596ee13..2079d7a2b3c 100644 --- a/tests/components/prosegur/test_init.py +++ b/tests/components/prosegur/test_init.py @@ -18,7 +18,6 @@ from tests.common import MockConfigEntry async def test_setup_entry_fail_retrieve(hass, error): """Test loading the Prosegur entry.""" - hass.config.components.add(DOMAIN) config_entry = MockConfigEntry( domain=DOMAIN, data={ @@ -47,7 +46,6 @@ async def test_unload_entry(hass, aioclient_mock): json={"data": {"token": "123456789"}}, ) - hass.config.components.add(DOMAIN) config_entry = MockConfigEntry( domain=DOMAIN, data={ From c04671ac648a53c1f76076f3c5a18eb98447cbcc Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 28 Jul 2021 23:16:14 -0600 Subject: [PATCH 012/903] Fix unhandled exception with Guardian paired sensor coordinators (#53663) --- homeassistant/components/guardian/__init__.py | 17 +++++++++-------- .../components/guardian/binary_sensor.py | 12 ++++++------ homeassistant/components/guardian/const.py | 1 + homeassistant/components/guardian/sensor.py | 12 ++++++------ 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 9338f9a47a9..915746c5ed5 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -26,6 +26,7 @@ from .const import ( CONF_UID, DATA_CLIENT, DATA_COORDINATOR, + DATA_COORDINATOR_PAIRED_SENSOR, DATA_PAIRED_SENSOR_MANAGER, DATA_UNSUB_DISPATCHER_CONNECT, DOMAIN, @@ -44,6 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: { DATA_CLIENT: {}, DATA_COORDINATOR: {}, + DATA_COORDINATOR_PAIRED_SENSOR: {}, DATA_PAIRED_SENSOR_MANAGER: {}, DATA_UNSUB_DISPATCHER_CONNECT: {}, }, @@ -51,9 +53,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] = Client( entry.data[CONF_IP_ADDRESS], port=entry.data[CONF_PORT] ) - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = { - API_SENSOR_PAIRED_SENSOR_STATUS: {} - } + hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = {} + hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][entry.entry_id] = {} hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id] = [] # The valve controller's UDP-based API can't handle concurrent requests very well, @@ -113,6 +114,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id) hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id) + hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR].pop(entry.entry_id) for unsub in hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id]: unsub() hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT].pop(entry.entry_id) @@ -143,8 +145,8 @@ class PairedSensorManager: self._paired_uids.add(uid) - coordinator = self._hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS + coordinator = self._hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + self._entry.entry_id ][uid] = GuardianDataUpdateCoordinator( self._hass, client=self._client, @@ -194,8 +196,8 @@ class PairedSensorManager: # Clear out objects related to this paired sensor: self._paired_uids.remove(uid) - self._hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS + self._hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + self._entry.entry_id ].pop(uid) # Remove the paired sensor device from the device registry (which will @@ -297,7 +299,6 @@ class ValveControllerEntity(GuardianEntity): return any( coordinator.last_update_success for coordinator in self.coordinators.values() - if coordinator ) async def _async_continue_entity_setup(self) -> None: diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 8ce381e0456..1cbc9f5cede 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -15,11 +15,11 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PairedSensorEntity, ValveControllerEntity from .const import ( - API_SENSOR_PAIRED_SENSOR_STATUS, API_SYSTEM_ONBOARD_SENSOR_STATUS, API_WIFI_STATUS, CONF_UID, DATA_COORDINATOR, + DATA_COORDINATOR_PAIRED_SENSOR, DATA_UNSUB_DISPATCHER_CONNECT, DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, @@ -49,9 +49,9 @@ async def async_setup_entry( @callback def add_new_paired_sensor(uid: str) -> None: """Add a new paired sensor.""" - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS - ][uid] + coordinator = hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][entry.entry_id][ + uid + ] entities = [] for kind in PAIRED_SENSOR_SENSORS: @@ -95,8 +95,8 @@ async def async_setup_entry( ) # Add all paired sensor-specific binary sensors: - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS + for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + entry.entry_id ].values(): for kind in PAIRED_SENSOR_SENSORS: name, device_class = SENSOR_ATTRS_MAP[kind] diff --git a/homeassistant/components/guardian/const.py b/homeassistant/components/guardian/const.py index 750a8c407ca..e27e8a37047 100644 --- a/homeassistant/components/guardian/const.py +++ b/homeassistant/components/guardian/const.py @@ -16,6 +16,7 @@ CONF_UID = "uid" DATA_CLIENT = "client" DATA_COORDINATOR = "coordinator" +DATA_COORDINATOR_PAIRED_SENSOR = "coordinator_paired_sensor" DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager" DATA_UNSUB_DISPATCHER_CONNECT = "unsub_dispatcher_connect" diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index ce09bb99c60..2d7cde86cca 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -17,11 +17,11 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PairedSensorEntity, ValveControllerEntity from .const import ( - API_SENSOR_PAIRED_SENSOR_STATUS, API_SYSTEM_DIAGNOSTICS, API_SYSTEM_ONBOARD_SENSOR_STATUS, CONF_UID, DATA_COORDINATOR, + DATA_COORDINATOR_PAIRED_SENSOR, DATA_UNSUB_DISPATCHER_CONNECT, DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, @@ -54,9 +54,9 @@ async def async_setup_entry( @callback def add_new_paired_sensor(uid: str) -> None: """Add a new paired sensor.""" - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS - ][uid] + coordinator = hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][entry.entry_id][ + uid + ] entities = [] for kind in PAIRED_SENSOR_SENSORS: @@ -96,8 +96,8 @@ async def async_setup_entry( ) # Add all paired sensor-specific binary sensors: - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS + for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + entry.entry_id ].values(): for kind in PAIRED_SENSOR_SENSORS: name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] From 806ab47ca57baaeca6b6fa25245d8c9048812ac5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 28 Jul 2021 22:49:13 -0700 Subject: [PATCH 013/903] Bump nest to version 0.3.5 (#53672) Fix runtime type assertions, Fixes Issue #53652 --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 05cfa261ef4..6c9462e43db 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["ffmpeg", "http"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.3.4"], + "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.3.5"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 6f9381d19ca..9a58cf0f2d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -707,7 +707,7 @@ google-cloud-pubsub==2.1.0 google-cloud-texttospeech==0.4.0 # homeassistant.components.nest -google-nest-sdm==0.3.4 +google-nest-sdm==0.3.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1f1ba01960..c4341baa88d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ google-api-python-client==1.6.4 google-cloud-pubsub==2.1.0 # homeassistant.components.nest -google-nest-sdm==0.3.4 +google-nest-sdm==0.3.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 From dc1b2b7687c0f9422571a58fe3ba867be68caf07 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Thu, 29 Jul 2021 00:13:55 -0700 Subject: [PATCH 014/903] pyWeMo version bump (0.6.6) (#53671) --- homeassistant/components/wemo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 3d051fcc6dc..21a7760741a 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -3,7 +3,7 @@ "name": "Belkin WeMo", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wemo", - "requirements": ["pywemo==0.6.5"], + "requirements": ["pywemo==0.6.6"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/requirements_all.txt b/requirements_all.txt index 9a58cf0f2d2..063d8d0b4fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1977,7 +1977,7 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.5 +pywemo==0.6.6 # homeassistant.components.wilight pywilight==0.0.70 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4341baa88d..062e5068c91 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1095,7 +1095,7 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.5 +pywemo==0.6.6 # homeassistant.components.wilight pywilight==0.0.70 From 1019ee22ff13e5f542e868179d791e6a0d87369a Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 29 Jul 2021 03:30:50 -0400 Subject: [PATCH 015/903] Add enabled attribute to zwave_js discovery model (#53645) * Add attribute to zwave_js discovery model * Fix binary sensor entity enabled logic * Add tests --- .../components/zwave_js/binary_sensor.py | 6 -- .../components/zwave_js/discovery.py | 55 +++++++++++++++++-- homeassistant/components/zwave_js/entity.py | 3 + homeassistant/components/zwave_js/sensor.py | 9 --- tests/components/zwave_js/common.py | 2 + tests/components/zwave_js/test_sensor.py | 24 ++++++++ 6 files changed, 78 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 71de7270d9a..9d72a804ca0 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -277,12 +277,6 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): if self.info.primary_value.command_class == CommandClass.BATTERY else None ) - # Legacy binary sensors are phased out (replaced by notification sensors) - # Disable by default to not confuse users - self._attr_entity_registry_enabled_default = bool( - self.info.primary_value.command_class != CommandClass.SENSOR_BINARY - or self.info.node.device_class.generic.key == 0x20 - ) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 403ea5c9746..7c29c89dfab 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -67,6 +67,8 @@ class ZwaveDiscoveryInfo: platform_data: dict[str, Any] | None = None # additional values that need to be watched by entity additional_value_ids_to_watch: set[str] | None = None + # bool to specify whether entity should be enabled by default + entity_registry_enabled_default: bool = True @dataclass @@ -135,6 +137,8 @@ class ZWaveDiscoverySchema: allow_multi: bool = False # [optional] bool to specify whether state is assumed and events should be fired on value update assumed_state: bool = False + # [optional] bool to specify whether entity should be enabled by default + entity_registry_enabled_default: bool = True def get_config_parameter_discovery_schema( @@ -161,6 +165,7 @@ def get_config_parameter_discovery_schema( property_key_name=property_key_name, type={"number"}, ), + entity_registry_enabled_default=False, **kwargs, ) @@ -428,12 +433,33 @@ DISCOVERY_SCHEMAS = [ ], ), # binary sensors + # When CC is Sensor Binary and device class generic is Binary Sensor, entity should + # be enabled by default + ZWaveDiscoverySchema( + platform="binary_sensor", + hint="boolean", + device_class_generic={"Binary Sensor"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SENSOR_BINARY}, + type={"boolean"}, + ), + ), + # Legacy binary sensors are phased out (replaced by notification sensors) + # Disable by default to not confuse users + ZWaveDiscoverySchema( + platform="binary_sensor", + hint="boolean", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SENSOR_BINARY}, + type={"boolean"}, + ), + entity_registry_enabled_default=False, + ), ZWaveDiscoverySchema( platform="binary_sensor", hint="boolean", primary_value=ZWaveValueDiscoverySchema( command_class={ - CommandClass.SENSOR_BINARY, CommandClass.BATTERY, CommandClass.SENSOR_ALARM, }, @@ -456,13 +482,19 @@ DISCOVERY_SCHEMAS = [ platform="sensor", hint="string_sensor", primary_value=ZWaveValueDiscoverySchema( - command_class={ - CommandClass.SENSOR_ALARM, - CommandClass.INDICATOR, - }, + command_class={CommandClass.SENSOR_ALARM}, type={"string"}, ), ), + ZWaveDiscoverySchema( + platform="sensor", + hint="string_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={"string"}, + ), + entity_registry_enabled_default=False, + ), # generic numeric sensors ZWaveDiscoverySchema( platform="sensor", @@ -471,12 +503,20 @@ DISCOVERY_SCHEMAS = [ command_class={ CommandClass.SENSOR_MULTILEVEL, CommandClass.SENSOR_ALARM, - CommandClass.INDICATOR, CommandClass.BATTERY, }, type={"number"}, ), ), + ZWaveDiscoverySchema( + platform="sensor", + hint="numeric_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={"number"}, + ), + entity_registry_enabled_default=False, + ), # numeric sensors for Meter CC ZWaveDiscoverySchema( platform="sensor", @@ -500,6 +540,7 @@ DISCOVERY_SCHEMAS = [ type={"number"}, ), allow_multi=True, + entity_registry_enabled_default=False, ), # sensor for basic CC ZWaveDiscoverySchema( @@ -512,6 +553,7 @@ DISCOVERY_SCHEMAS = [ type={"number"}, property={"currentValue"}, ), + entity_registry_enabled_default=False, ), # binary switches ZWaveDiscoverySchema( @@ -697,6 +739,7 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None platform_data_template=schema.data_template, platform_data=resolved_data, additional_value_ids_to_watch=additional_value_ids_to_watch, + entity_registry_enabled_default=schema.entity_registry_enabled_default, ) if not schema.allow_multi: diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 6df7c8d546b..793eaa435d5 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -46,6 +46,9 @@ class ZWaveBaseEntity(Entity): self._attr_unique_id = get_unique_id( self.client.driver.controller.home_id, self.info.primary_value.value_id ) + self._attr_entity_registry_enabled_default = ( + self.info.entity_registry_enabled_default + ) self._attr_assumed_state = self.info.assumed_state # device is precreated in main handler self._attr_device_info = { diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 7c41ad035be..209a5b6d4aa 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -118,15 +118,6 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): self._attr_name = self.generate_name(include_value_name=True) self._attr_device_class = self._get_device_class() self._attr_state_class = self._get_state_class() - self._attr_entity_registry_enabled_default = True - # We hide some of the more advanced sensors by default to not overwhelm users - if self.info.primary_value.command_class in [ - CommandClass.BASIC, - CommandClass.CONFIGURATION, - CommandClass.INDICATOR, - CommandClass.NOTIFICATION, - ]: - self._attr_entity_registry_enabled_default = False def _get_device_class(self) -> str | None: """ diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index f14842609c5..7177134aa33 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -11,6 +11,8 @@ NOTIFICATION_MOTION_BINARY_SENSOR = ( "binary_sensor.multisensor_6_home_security_motion_detection" ) NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_home_security_motion_sensor_status" +INDICATOR_SENSOR = "sensor.z_wave_thermostat_indicator_value" +BASIC_SENSOR = "sensor.livingroomlight_basic" PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( "binary_sensor.august_smart_lock_pro_3rd_gen_the_current_status_of_the_door" ) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index e368ec1b026..aae7a1c0602 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -21,9 +21,11 @@ from homeassistant.helpers import entity_registry as er from .common import ( AIR_TEMPERATURE_SENSOR, + BASIC_SENSOR, ENERGY_SENSOR, HUMIDITY_SENSOR, ID_LOCK_CONFIG_PARAMETER_SENSOR, + INDICATOR_SENSOR, NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, ) @@ -88,6 +90,28 @@ async def test_disabled_notification_sensor(hass, multisensor_6, integration): assert state.attributes["value"] == 8 +async def test_disabled_indcator_sensor( + hass, climate_radio_thermostat_ct100_plus, integration +): + """Test sensor is created from Indicator CC and is disabled.""" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(INDICATOR_SENSOR) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + + +async def test_disabled_basic_sensor(hass, ge_in_wall_dimmer_switch, integration): + """Test sensor is created from Basic CC and is disabled.""" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(BASIC_SENSOR) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + + async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration): """Test config parameter sensor is created.""" ent_reg = er.async_get(hass) From 30cbf03b48fc5e5a70e9d2440f5b062a10ee208a Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 29 Jul 2021 08:18:38 -0400 Subject: [PATCH 016/903] Add energy support for zwave_js meter CC entities (#53665) * Add energy support for zwave_js meter CC entities * shrink * comments * comments * comments * Move attributes * Add tests * Apply suggestions from code review Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 4 +- homeassistant/components/zwave_js/sensor.py | 85 +++++++++++++++++-- tests/components/zwave_js/common.py | 6 ++ tests/components/zwave_js/conftest.py | 18 ++++ tests/components/zwave_js/test_sensor.py | 52 +++++++++--- 5 files changed, 146 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 7c29c89dfab..588b4c76472 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -517,10 +517,10 @@ DISCOVERY_SCHEMAS = [ ), entity_registry_enabled_default=False, ), - # numeric sensors for Meter CC + # Meter sensors for Meter CC ZWaveDiscoverySchema( platform="sensor", - hint="numeric_sensor", + hint="meter", primary_value=ZWaveValueDiscoverySchema( command_class={ CommandClass.METER, diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 209a5b6d4aa..39aa2f30604 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -11,6 +11,7 @@ from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue from homeassistant.components.sensor import ( + ATTR_LAST_RESET, DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, DEVICE_CLASS_ILLUMINANCE, @@ -28,8 +29,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt from .const import ATTR_METER_TYPE, ATTR_VALUE, DATA_CLIENT, DOMAIN, SERVICE_RESET_METER from .discovery import ZwaveDiscoveryInfo @@ -60,6 +66,8 @@ async def async_setup_entry( entities.append(ZWaveListSensor(config_entry, client, info)) elif info.platform_hint == "config_parameter": entities.append(ZWaveConfigParameterSensor(config_entry, client, info)) + elif info.platform_hint == "meter": + entities.append(ZWaveMeterSensor(config_entry, client, info)) else: LOGGER.warning( "Sensor not implemented for %s/%s", @@ -128,10 +136,6 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): """ if self.info.primary_value.command_class == CommandClass.BATTERY: return DEVICE_CLASS_BATTERY - if self.info.primary_value.command_class == CommandClass.METER: - if self.info.primary_value.metadata.unit == "kWh": - return DEVICE_CLASS_ENERGY - return DEVICE_CLASS_POWER if isinstance(self.info.primary_value.property_, str): property_lower = self.info.primary_value.property_.lower() if "humidity" in property_lower: @@ -221,14 +225,72 @@ class ZWaveNumericSensor(ZwaveSensorBase): return str(self.info.primary_value.metadata.unit) + +class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): + """Representation of a Z-Wave Meter CC sensor.""" + + _attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__( + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + ) -> None: + """Initialize a ZWaveNumericSensor entity.""" + super().__init__(config_entry, client, info) + + # Entity class attributes + self._attr_last_reset = dt.utc_from_timestamp(0) + self._attr_device_class = DEVICE_CLASS_POWER + if self.info.primary_value.metadata.unit == "kWh": + self._attr_device_class = DEVICE_CLASS_ENERGY + + @callback + def async_update_last_reset( + self, node: ZwaveNode, endpoint: int, meter_type: int | None + ) -> None: + """Update last reset.""" + # If the signal is not for this node or is for a different endpoint, ignore it + if self.info.node != node or self.info.primary_value.endpoint != endpoint: + return + # If a meter type was specified and doesn't match this entity's meter type, + # ignore it + if ( + meter_type is not None + and self.info.primary_value.metadata.cc_specific.get("meterType") + != meter_type + ): + return + + self._attr_last_reset = dt.utcnow() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + + # Restore the last reset time from stored state + restored_state = await self.async_get_last_state() + if restored_state and ATTR_LAST_RESET in restored_state.attributes: + self._attr_last_reset = dt.parse_datetime( + restored_state.attributes[ATTR_LAST_RESET] + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{SERVICE_RESET_METER}", + self.async_update_last_reset, + ) + ) + async def async_reset_meter( self, meter_type: int | None = None, value: int | None = None ) -> None: """Reset meter(s) on device.""" node = self.info.node primary_value = self.info.primary_value - if primary_value.command_class != CommandClass.METER: - raise TypeError("Reset only available for Meter sensors") options = {} if meter_type is not None: options["type"] = meter_type @@ -244,6 +306,15 @@ class ZWaveNumericSensor(ZwaveSensorBase): primary_value.endpoint, options, ) + self._attr_last_reset = dt.utcnow() + # Notify meters that may have been reset + async_dispatcher_send( + self.hass, + f"{DOMAIN}_{SERVICE_RESET_METER}", + node, + primary_value.endpoint, + options.get("type"), + ) class ZWaveListSensor(ZwaveSensorBase): diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 7177134aa33..c7100b22bd5 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -1,4 +1,6 @@ """Provide common test tools for Z-Wave JS.""" +from datetime import datetime, timezone + AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" HUMIDITY_SENSOR = "sensor.multisensor_6_humidity" ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2" @@ -29,3 +31,7 @@ ID_LOCK_CONFIG_PARAMETER_SENSOR = ( "sensor.z_wave_module_for_id_lock_150_and_101_config_parameter_door_lock_mode" ) ZEN_31_ENTITY = "light.kitchen_under_cabinet_lights" +METER_SENSOR = "sensor.smart_switch_6_electric_consumed_v" + +DATETIME_ZERO = datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc) +DATETIME_LAST_RESET = datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 9dc8490b314..75b5ab65d38 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -11,6 +11,11 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo +from homeassistant.components.sensor import ATTR_LAST_RESET +from homeassistant.core import State + +from .common import DATETIME_LAST_RESET + from tests.common import MockConfigEntry, load_fixture # Add-on fixtures @@ -835,3 +840,16 @@ def aeotec_zw164_siren_fixture(client, aeotec_zw164_siren_state): def firmware_file_fixture(): """Return mock firmware file stream.""" return io.BytesIO(bytes(10)) + + +@pytest.fixture(name="restore_last_reset") +def restore_last_reset_fixture(): + """Return mock restore last reset.""" + state = State( + "sensor.test", "test", {ATTR_LAST_RESET: DATETIME_LAST_RESET.isoformat()} + ) + with patch( + "homeassistant.components.zwave_js.sensor.ZWaveMeterSensor.async_get_last_state", + return_value=state, + ): + yield state diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index aae7a1c0602..29fed2b5c55 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,6 +1,9 @@ """Test the Z-Wave JS sensor platform.""" +from unittest.mock import patch + from zwave_js_server.event import Event +from homeassistant.components.sensor import ATTR_LAST_RESET from homeassistant.components.zwave_js.const import ( ATTR_METER_TYPE, ATTR_VALUE, @@ -22,10 +25,13 @@ from homeassistant.helpers import entity_registry as er from .common import ( AIR_TEMPERATURE_SENSOR, BASIC_SENSOR, + DATETIME_LAST_RESET, + DATETIME_ZERO, ENERGY_SENSOR, HUMIDITY_SENSOR, ID_LOCK_CONFIG_PARAMETER_SENSOR, INDICATOR_SENSOR, + METER_SENSOR, NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, ) @@ -171,18 +177,30 @@ async def test_reset_meter( integration, ): """Test reset_meter service.""" - SENSOR = "sensor.smart_switch_6_electric_consumed_v" client.async_send_command.return_value = {} client.async_send_command_no_wait.return_value = {} - # Test successful meter reset call - await hass.services.async_call( - DOMAIN, - SERVICE_RESET_METER, - { - ATTR_ENTITY_ID: SENSOR, - }, - blocking=True, + # Validate that the sensor last reset is starting from nothing + assert ( + hass.states.get(METER_SENSOR).attributes[ATTR_LAST_RESET] + == DATETIME_ZERO.isoformat() + ) + + # Test successful meter reset call, patching utcnow so we can make sure the last + # reset gets updated + with patch("homeassistant.util.dt.utcnow", return_value=DATETIME_LAST_RESET): + await hass.services.async_call( + DOMAIN, + SERVICE_RESET_METER, + { + ATTR_ENTITY_ID: METER_SENSOR, + }, + blocking=True, + ) + + assert ( + hass.states.get(METER_SENSOR).attributes[ATTR_LAST_RESET] + == DATETIME_LAST_RESET.isoformat() ) assert len(client.async_send_command_no_wait.call_args_list) == 1 @@ -199,7 +217,7 @@ async def test_reset_meter( DOMAIN, SERVICE_RESET_METER, { - ATTR_ENTITY_ID: SENSOR, + ATTR_ENTITY_ID: METER_SENSOR, ATTR_METER_TYPE: 1, ATTR_VALUE: 2, }, @@ -214,3 +232,17 @@ async def test_reset_meter( assert args["args"] == [{"type": 1, "targetValue": 2}] client.async_send_command_no_wait.reset_mock() + + +async def test_restore_last_reset( + hass, + client, + aeon_smart_switch_6, + restore_last_reset, + integration, +): + """Test restoring last_reset on setup.""" + assert ( + hass.states.get(METER_SENSOR).attributes[ATTR_LAST_RESET] + == DATETIME_LAST_RESET.isoformat() + ) From 705e2446e566149505d5705f1eb13e358746e7b3 Mon Sep 17 00:00:00 2001 From: Nicko van Someren Date: Thu, 29 Jul 2021 08:29:52 -0600 Subject: [PATCH 017/903] Fix Lutron button events to have unambiguous names (#53666) --- homeassistant/components/lutron/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index b38968c36b8..8382194ab46 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -140,6 +140,8 @@ class LutronButton: def __init__(self, hass, area_name, keypad, button): """Register callback for activity on the button.""" name = f"{keypad.name}: {button.name}" + if button.name == "Unknown Button": + name += f" {button.number}" self._hass = hass self._has_release_event = ( button.button_type is not None and "RaiseLower" in button.button_type @@ -150,7 +152,7 @@ class LutronButton: self._button_name = button.name self._button = button self._event = "lutron_event" - self._full_id = slugify(f"{area_name} {keypad.name}: {button.name}") + self._full_id = slugify(f"{area_name} {name}") button.subscribe(self.button_callback, None) From f8750daa097707e62c101520efd4a76d0c2e700a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 29 Jul 2021 16:59:02 +0200 Subject: [PATCH 018/903] Surepetcare, bug fix (#53695) --- homeassistant/components/surepetcare/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index ca7b7378127..5a9ae733db0 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -175,7 +175,7 @@ class DeviceConnectivity(SurePetcareBinarySensor): """Get the latest data and update the state.""" surepy_entity = self._spc.states[self._id] state = surepy_entity.raw_data()["status"] - self._attr_is_on = self._attr_available = bool(self.state) + self._attr_is_on = self._attr_available = bool(state) if state: self._attr_extra_state_attributes = { "device_rssi": f'{state["signal"]["device_rssi"]:.2f}', From 219348d573ef6310944ff0c26ea6d33d9a46ba7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jul 2021 11:07:52 -0500 Subject: [PATCH 019/903] Skip each ssdp listener that fails to bind (#53670) --- homeassistant/components/ssdp/__init__.py | 16 +++++- tests/components/ssdp/test_init.py | 62 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index f0ba8b7dcea..31ebb0d1a92 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -256,9 +256,21 @@ class Scanner: self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start ) - await asyncio.gather( - *(listener.async_start() for listener in self._ssdp_listeners) + results = await asyncio.gather( + *(listener.async_start() for listener in self._ssdp_listeners), + return_exceptions=True, ) + failed_listeners = [] + for idx, result in enumerate(results): + if isinstance(result, Exception): + _LOGGER.warning( + "Failed to setup listener for %s: %s", + self._ssdp_listeners[idx].source_ip, + result, + ) + failed_listeners.append(self._ssdp_listeners[idx]) + for listener in failed_listeners: + self._ssdp_listeners.remove(listener) self._cancel_scan = async_track_time_interval( self.hass, self.async_scan, SCAN_INTERVAL ) diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 568a2261fee..34ca1b7228e 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -790,3 +790,65 @@ async def test_async_detect_interfaces_setting_empty_route(hass): (IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")), (IPv4Address("192.168.1.5"), None), } + + +async def test_bind_failure_skips_adapter(hass, caplog): + """Test that an adapter with a bind failure is skipped.""" + mock_get_ssdp = { + "mock-domain": [ + { + ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + } + ] + } + create_args = [] + did_search = 0 + + @callback + def _callback(*_): + nonlocal did_search + did_search += 1 + pass + + def _generate_failing_ssdp_listener(*args, **kwargs): + create_args.append([args, kwargs]) + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + if kwargs["source_ip"] == IPv6Address("2001:db8::"): + raise OSError + pass + + listener.async_start = _async_callback + listener.async_search = _callback + return listener + + with patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value=mock_get_ssdp, + ), patch( + "homeassistant.components.ssdp.SSDPListener", + new=_generate_failing_ssdp_listener, + ), patch( + "homeassistant.components.ssdp.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ): + assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + argset = set() + for argmap in create_args: + argset.add((argmap[1].get("source_ip"), argmap[1].get("target_ip"))) + + assert argset == { + (IPv6Address("2001:db8::"), None), + (IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")), + (IPv4Address("192.168.1.5"), None), + } + assert "Failed to setup listener for" in caplog.text + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() + assert did_search == 2 From 09947d13a8deb49e92893873858d6c1f1ecb9e6a Mon Sep 17 00:00:00 2001 From: Andrew55529 Date: Thu, 29 Jul 2021 19:31:32 +0300 Subject: [PATCH 020/903] Fix problem with telegram_bot (#53690) --- homeassistant/components/telegram_bot/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index c3f07e5269d..02629e695fc 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -848,7 +848,7 @@ class BaseTelegramBotEntity: if ( msg_data["from"].get("id") not in self.allowed_chat_ids - and msg_data["chat"].get("id") not in self.allowed_chat_ids + and msg_data["message"]["chat"].get("id") not in self.allowed_chat_ids ): # Neither from id nor chat id was in allowed_chat_ids, # origin is not allowed. From 24a589961a1f8c4a2d549b3a206d1c7931b1b895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 29 Jul 2021 18:44:38 +0200 Subject: [PATCH 021/903] Energy round (#53696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Energy. Round cost Signed-off-by: Daniel Hjelseth Høyer * Energy. Round cost Signed-off-by: Daniel Hjelseth Høyer * Update homeassistant/components/energy/sensor.py Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/energy/sensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 773abbbe6b9..1c42ea5a050 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -152,10 +152,12 @@ class EnergyCostSensor(SensorEntity): self._attr_state_class = STATE_CLASS_MEASUREMENT self._flow = flow self._last_energy_sensor_state: State | None = None + self._cur_value = 0.0 def _reset(self, energy_state: State) -> None: """Reset the cost sensor.""" self._attr_state = 0.0 + self._cur_value = 0.0 self._attr_last_reset = dt_util.utcnow() self._last_energy_sensor_state = energy_state self.async_write_ha_state() @@ -195,7 +197,6 @@ class EnergyCostSensor(SensorEntity): self._reset(energy_state) return - cur_value = cast(float, self._attr_state) if ( energy_state.attributes[ATTR_LAST_RESET] != self._last_energy_sensor_state.attributes[ATTR_LAST_RESET] @@ -205,7 +206,8 @@ class EnergyCostSensor(SensorEntity): else: # Update with newly incurred cost old_energy_value = float(self._last_energy_sensor_state.state) - self._attr_state = cur_value + (energy - old_energy_value) * energy_price + self._cur_value += (energy - old_energy_value) * energy_price + self._attr_state = round(self._cur_value, 2) self._last_energy_sensor_state = energy_state From bedb9550f578e9c28b19908da866aafc4637d4ee Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 29 Jul 2021 20:02:47 +0200 Subject: [PATCH 022/903] Move TP-Link power and energy switch attributes to sensors (#53596) Co-authored-by: Paulus Schoutsen --- homeassistant/components/tplink/__init__.py | 127 ++++++++++-- homeassistant/components/tplink/common.py | 33 +-- homeassistant/components/tplink/const.py | 76 +++++++ homeassistant/components/tplink/sensor.py | 100 +++++++++ homeassistant/components/tplink/switch.py | 201 ++++-------------- tests/components/tplink/consts.py | 72 +++++++ tests/components/tplink/test_init.py | 219 +++++++++++++++++++- tests/components/tplink/test_light.py | 2 +- 8 files changed, 634 insertions(+), 196 deletions(-) create mode 100644 homeassistant/components/tplink/sensor.py create mode 100644 tests/components/tplink/consts.py diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 1f843d364d8..9d5263687f8 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -1,37 +1,57 @@ """Component to embed TP-Link smart home devices.""" -import logging +from __future__ import annotations +from datetime import datetime, timedelta +import logging +import time + +from pyHS100.smartdevice import SmartDevice, SmartDeviceException +from pyHS100.smartplug import SmartPlug import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.sensor import ATTR_LAST_RESET +from homeassistant.components.switch import ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import ( + ATTR_VOLTAGE, + CONF_ALIAS, + CONF_DEVICE_ID, + CONF_HOST, + CONF_MAC, + CONF_STATE, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.dt import utc_from_timestamp -from .common import ( +from .common import SmartDevices, async_discover_devices, get_static_devices +from .const import ( ATTR_CONFIG, + ATTR_CURRENT_A, + ATTR_TOTAL_ENERGY_KWH, CONF_DIMMER, CONF_DISCOVERY, + CONF_EMETER_PARAMS, CONF_LIGHT, + CONF_MODEL, CONF_STRIP, + CONF_SW_VERSION, CONF_SWITCH, - SmartDevices, - async_discover_devices, - get_static_devices, + COORDINATORS, + PLATFORMS, ) _LOGGER = logging.getLogger(__name__) DOMAIN = "tplink" -PLATFORMS = [CONF_LIGHT, CONF_SWITCH] - TPLINK_HOST_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string}) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -82,8 +102,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_count = len(tplink_devices) # These will contain the initialized devices - lights = hass.data[DOMAIN][CONF_LIGHT] = [] - switches = hass.data[DOMAIN][CONF_SWITCH] = [] + hass.data[DOMAIN][CONF_LIGHT] = [] + hass.data[DOMAIN][CONF_SWITCH] = [] + lights: list[SmartDevice] = hass.data[DOMAIN][CONF_LIGHT] + switches: list[SmartPlug] = hass.data[DOMAIN][CONF_SWITCH] # Add static devices static_devices = SmartDevices() @@ -102,14 +124,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: lights.extend(discovered_devices.lights) switches.extend(discovered_devices.switches) - forward_setup = hass.config_entries.async_forward_entry_setup if lights: _LOGGER.debug( "Got %s lights: %s", len(lights), ", ".join(d.host for d in lights) ) - hass.async_create_task(forward_setup(entry, "light")) - if switches: _LOGGER.debug( "Got %s switches: %s", @@ -117,7 +136,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ", ".join(d.host for d in switches), ) - hass.async_create_task(forward_setup(entry, "switch")) + # prepare DataUpdateCoordinators + hass.data[DOMAIN][COORDINATORS] = {} + for switch in switches: + + try: + await hass.async_add_executor_job(switch.get_sysinfo) + except SmartDeviceException as ex: + _LOGGER.debug(ex) + raise ConfigEntryNotReady from ex + + hass.data[DOMAIN][COORDINATORS][ + switch.mac + ] = coordinator = SmartPlugDataUpdateCoordinator(hass, switch) + + await coordinator.async_config_entry_first_refresh() + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -130,3 +165,65 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].clear() return unload_ok + + +class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): + """DataUpdateCoordinator to gather data for specific SmartPlug.""" + + def __init__( + self, + hass: HomeAssistant, + smartplug: SmartPlug, + ) -> None: + """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" + self.smartplug = smartplug + + update_interval = timedelta(seconds=30) + super().__init__( + hass, _LOGGER, name=smartplug.alias, update_interval=update_interval + ) + + async def _async_update_data(self) -> dict: + """Fetch all device and sensor data from api.""" + info = self.smartplug.sys_info + data = { + CONF_HOST: self.smartplug.host, + CONF_MAC: info["mac"], + CONF_MODEL: info["model"], + CONF_SW_VERSION: info["sw_ver"], + } + if self.smartplug.context is None: + data[CONF_ALIAS] = info["alias"] + data[CONF_DEVICE_ID] = info["mac"] + data[CONF_STATE] = self.smartplug.state == self.smartplug.SWITCH_STATE_ON + else: + plug_from_context = next( + c + for c in self.smartplug.sys_info["children"] + if c["id"] == self.smartplug.context + ) + data[CONF_ALIAS] = plug_from_context["alias"] + data[CONF_DEVICE_ID] = self.smartplug.context + data[CONF_STATE] = plug_from_context["state"] == 1 + if self.smartplug.has_emeter: + emeter_readings = self.smartplug.get_emeter_realtime() + data[CONF_EMETER_PARAMS] = { + ATTR_CURRENT_POWER_W: round(float(emeter_readings["power"]), 2), + ATTR_TOTAL_ENERGY_KWH: round(float(emeter_readings["total"]), 3), + ATTR_VOLTAGE: round(float(emeter_readings["voltage"]), 1), + ATTR_CURRENT_A: round(float(emeter_readings["current"]), 2), + ATTR_LAST_RESET: {ATTR_TOTAL_ENERGY_KWH: utc_from_timestamp(0)}, + } + emeter_statics = self.smartplug.get_emeter_daily() + data[CONF_EMETER_PARAMS][ATTR_LAST_RESET][ + ATTR_TODAY_ENERGY_KWH + ] = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + if emeter_statics.get(int(time.strftime("%e"))): + data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = round( + float(emeter_statics[int(time.strftime("%e"))]), 3 + ) + else: + # today's consumption not available, when device was off all the day + data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = 0.0 + + return data diff --git a/homeassistant/components/tplink/common.py b/homeassistant/components/tplink/common.py index 84ce7edadb6..6f6fb0a14c2 100644 --- a/homeassistant/components/tplink/common.py +++ b/homeassistant/components/tplink/common.py @@ -14,21 +14,20 @@ from pyHS100 import ( ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity -from .const import DOMAIN as TPLINK_DOMAIN +from .const import ( + CONF_DIMMER, + CONF_LIGHT, + CONF_STRIP, + CONF_SWITCH, + DOMAIN as TPLINK_DOMAIN, + MAX_DISCOVERY_RETRIES, +) _LOGGER = logging.getLogger(__name__) -ATTR_CONFIG = "config" -CONF_DIMMER = "dimmer" -CONF_DISCOVERY = "discovery" -CONF_LIGHT = "light" -CONF_STRIP = "strip" -CONF_SWITCH = "switch" -MAX_DISCOVERY_RETRIES = 4 - - class SmartDevices: """Hold different kinds of devices.""" @@ -98,7 +97,7 @@ async def async_discover_devices( else: _LOGGER.error("Unknown smart device type: %s", type(dev)) - devices = {} + devices: dict[str, SmartDevice] = {} for attempt in range(1, MAX_DISCOVERY_RETRIES + 1): _LOGGER.debug( "Discovering tplink devices, attempt %s of %s", @@ -159,16 +158,18 @@ def get_static_devices(config_data) -> SmartDevices: def add_available_devices( hass: HomeAssistant, device_type: str, device_class: Callable -) -> list: +) -> list[Entity]: """Get sysinfo for all devices.""" - devices = hass.data[TPLINK_DOMAIN][device_type] + devices: list[SmartDevice] = hass.data[TPLINK_DOMAIN][device_type] if f"{device_type}_remaining" in hass.data[TPLINK_DOMAIN]: - devices = hass.data[TPLINK_DOMAIN][f"{device_type}_remaining"] + devices: list[SmartDevice] = hass.data[TPLINK_DOMAIN][ + f"{device_type}_remaining" + ] - entities_ready = [] - devices_unavailable = [] + entities_ready: list[Entity] = [] + devices_unavailable: list[SmartDevice] = [] for device in devices: try: device.get_sysinfo() diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 8b85b8afd74..93cad889a2f 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -1,5 +1,81 @@ """Const for TP-Link.""" +from __future__ import annotations + import datetime +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) +from homeassistant.components.switch import ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH +from homeassistant.const import ( + ATTR_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) + DOMAIN = "tplink" +COORDINATORS = "coordinators" + MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=8) +MAX_DISCOVERY_RETRIES = 4 + +ATTR_CONFIG = "config" +ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" +ATTR_CURRENT_A = "current_a" + +CONF_MODEL = "model" +CONF_SW_VERSION = "sw_ver" +CONF_EMETER_PARAMS = "emeter_params" +CONF_DIMMER = "dimmer" +CONF_DISCOVERY = "discovery" +CONF_LIGHT = "light" +CONF_STRIP = "strip" +CONF_SWITCH = "switch" +CONF_SENSOR = "sensor" + +PLATFORMS = [CONF_LIGHT, CONF_SENSOR, CONF_SWITCH] + +ENERGY_SENSORS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=ATTR_CURRENT_POWER_W, + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + name="Current Consumption", + ), + SensorEntityDescription( + key=ATTR_TOTAL_ENERGY_KWH, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + name="Total Consumption", + ), + SensorEntityDescription( + key=ATTR_TODAY_ENERGY_KWH, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + name="Today's Consumption", + ), + SensorEntityDescription( + key=ATTR_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + name="Voltage", + ), + SensorEntityDescription( + key=ATTR_CURRENT_A, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + name="Current", + ), +] diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py new file mode 100644 index 00000000000..24b93e90963 --- /dev/null +++ b/homeassistant/components/tplink/sensor.py @@ -0,0 +1,100 @@ +"""Support for TPLink HS100/HS110/HS200 smart switch energy sensors.""" +from __future__ import annotations + +from typing import Any + +from pyHS100 import SmartPlug + +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.components.tplink import SmartPlugDataUpdateCoordinator +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_MAC +from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ( + CONF_EMETER_PARAMS, + CONF_MODEL, + CONF_SW_VERSION, + CONF_SWITCH, + COORDINATORS, + DOMAIN as TPLINK_DOMAIN, + ENERGY_SENSORS, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches.""" + entities: list[SmartPlugSensor] = [] + coordinators: list[SmartPlugDataUpdateCoordinator] = hass.data[TPLINK_DOMAIN][ + COORDINATORS + ] + switches: list[SmartPlug] = hass.data[TPLINK_DOMAIN][CONF_SWITCH] + for switch in switches: + coordinator: SmartPlugDataUpdateCoordinator = coordinators[switch.mac] + if not switch.has_emeter and coordinator.data.get(CONF_EMETER_PARAMS) is None: + continue + for description in ENERGY_SENSORS: + if coordinator.data[CONF_EMETER_PARAMS].get(description.key) is not None: + entities.append(SmartPlugSensor(switch, coordinator, description)) + + async_add_entities(entities) + + +class SmartPlugSensor(CoordinatorEntity, SensorEntity): + """Representation of a TPLink Smart Plug energy sensor.""" + + def __init__( + self, + smartplug: SmartPlug, + coordinator: DataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.smartplug = smartplug + self.entity_description = description + self._attr_name = f"{coordinator.data[CONF_ALIAS]} {description.name}" + self._attr_last_reset = coordinator.data[CONF_EMETER_PARAMS][ + ATTR_LAST_RESET + ].get(description.key) + + @property + def data(self) -> dict[str, Any]: + """Return data from DataUpdateCoordinator.""" + return self.coordinator.data + + @property + def state(self) -> float | None: + """Return the sensors state.""" + return self.data[CONF_EMETER_PARAMS][self.entity_description.key] + + @property + def unique_id(self) -> str | None: + """Return a unique ID.""" + return f"{self.data[CONF_DEVICE_ID]}_{self.entity_description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return { + "name": self.data[CONF_ALIAS], + "model": self.data[CONF_MODEL], + "manufacturer": "TP-Link", + "connections": {(dr.CONNECTION_NETWORK_MAC, self.data[CONF_MAC])}, + "sw_version": self.data[CONF_SW_VERSION], + } diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index d088584c4ad..688091991c3 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -1,40 +1,30 @@ """Support for TPLink HS100/HS110/HS200 smart switch.""" from __future__ import annotations -import asyncio -from collections.abc import Mapping -from contextlib import suppress -import logging -import time from typing import Any -from pyHS100 import SmartDeviceException, SmartPlug +from pyHS100 import SmartPlug -from homeassistant.components.switch import ( - ATTR_CURRENT_POWER_W, - ATTR_TODAY_ENERGY_KWH, - SwitchEntity, -) +from homeassistant.components.switch import SwitchEntity +from homeassistant.components.tplink import SmartPlugDataUpdateCoordinator from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_VOLTAGE +from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_MAC, CONF_STATE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) -from . import CONF_SWITCH, DOMAIN as TPLINK_DOMAIN -from .common import add_available_devices - -PARALLEL_UPDATES = 0 - -_LOGGER = logging.getLogger(__name__) - -ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" -ATTR_CURRENT_A = "current_a" - -MAX_ATTEMPTS = 300 -SLEEP_TIME = 2 +from .const import ( + CONF_MODEL, + CONF_SW_VERSION, + CONF_SWITCH, + COORDINATORS, + DOMAIN as TPLINK_DOMAIN, +) async def async_setup_entry( @@ -43,164 +33,65 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - entities = await hass.async_add_executor_job( - add_available_devices, hass, CONF_SWITCH, SmartPlugSwitch - ) + entities: list[SmartPlugSwitch] = [] + coordinators: list[SmartPlugDataUpdateCoordinator] = hass.data[TPLINK_DOMAIN][ + COORDINATORS + ] + switches: list[SmartPlug] = hass.data[TPLINK_DOMAIN][CONF_SWITCH] + for switch in switches: + coordinator = coordinators[switch.mac] + entities.append(SmartPlugSwitch(switch, coordinator)) - if entities: - async_add_entities(entities, update_before_add=True) - - if hass.data[TPLINK_DOMAIN][f"{CONF_SWITCH}_remaining"]: - raise PlatformNotReady + async_add_entities(entities) -class SmartPlugSwitch(SwitchEntity): +class SmartPlugSwitch(CoordinatorEntity, SwitchEntity): """Representation of a TPLink Smart Plug switch.""" - def __init__(self, smartplug: SmartPlug) -> None: + def __init__( + self, smartplug: SmartPlug, coordinator: DataUpdateCoordinator + ) -> None: """Initialize the switch.""" + super().__init__(coordinator) self.smartplug = smartplug - self._sysinfo = None - self._state = None - self._is_available = False - # Set up emeter cache - self._emeter_params = {} - self._mac = None - self._alias = None - self._model = None - self._device_id = None - self._host = None + @property + def data(self) -> dict[str, Any]: + """Return data from DataUpdateCoordinator.""" + return self.coordinator.data @property def unique_id(self) -> str | None: """Return a unique ID.""" - return self._device_id + return self.data[CONF_DEVICE_ID] @property def name(self) -> str | None: """Return the name of the Smart Plug.""" - return self._alias + return self.data[CONF_ALIAS] @property def device_info(self) -> DeviceInfo: """Return information about the device.""" return { - "name": self._alias, - "model": self._model, + "name": self.data[CONF_ALIAS], + "model": self.data[CONF_MODEL], "manufacturer": "TP-Link", - "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, - "sw_version": self._sysinfo["sw_ver"], + "connections": {(dr.CONNECTION_NETWORK_MAC, self.data[CONF_MAC])}, + "sw_version": self.data[CONF_SW_VERSION], } - @property - def available(self) -> bool: - """Return if switch is available.""" - return self._is_available - @property def is_on(self) -> bool | None: """Return true if switch is on.""" - return self._state + return self.data[CONF_STATE] - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - self.smartplug.turn_on() + await self.hass.async_add_job(self.smartplug.turn_on) + await self.coordinator.async_refresh() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - self.smartplug.turn_off() - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return the state attributes of the device.""" - return self._emeter_params - - @property - def _plug_from_context(self) -> Any: - """Return the plug from the context.""" - children = self.smartplug.sys_info["children"] - return next(c for c in children if c["id"] == self.smartplug.context) - - def update_state(self) -> None: - """Update the TP-Link switch's state.""" - if self.smartplug.context is None: - self._state = self.smartplug.state == self.smartplug.SWITCH_STATE_ON - else: - self._state = self._plug_from_context["state"] == 1 - - def attempt_update(self, update_attempt: int) -> bool: - """Attempt to get details from the TP-Link switch.""" - try: - if not self._sysinfo: - self._sysinfo = self.smartplug.sys_info - self._mac = self._sysinfo["mac"] - self._model = self._sysinfo["model"] - self._host = self.smartplug.host - if self.smartplug.context is None: - self._alias = self._sysinfo["alias"] - self._device_id = self._mac - else: - self._alias = self._plug_from_context["alias"] - self._device_id = self.smartplug.context - - self.update_state() - - if self.smartplug.has_emeter: - emeter_readings = self.smartplug.get_emeter_realtime() - - self._emeter_params[ATTR_CURRENT_POWER_W] = round( - float(emeter_readings["power"]), 2 - ) - self._emeter_params[ATTR_TOTAL_ENERGY_KWH] = round( - float(emeter_readings["total"]), 3 - ) - self._emeter_params[ATTR_VOLTAGE] = round( - float(emeter_readings["voltage"]), 1 - ) - self._emeter_params[ATTR_CURRENT_A] = round( - float(emeter_readings["current"]), 2 - ) - - emeter_statics = self.smartplug.get_emeter_daily() - with suppress(KeyError): # Device returned no daily history - self._emeter_params[ATTR_TODAY_ENERGY_KWH] = round( - float(emeter_statics[int(time.strftime("%e"))]), 3 - ) - return True - except (SmartDeviceException, OSError) as ex: - if update_attempt == 0: - _LOGGER.debug( - "Retrying in %s seconds for %s|%s due to: %s", - SLEEP_TIME, - self._host, - self._alias, - ex, - ) - return False - - async def async_update(self) -> None: - """Update the TP-Link switch's state.""" - for update_attempt in range(MAX_ATTEMPTS): - is_ready = await self.hass.async_add_executor_job( - self.attempt_update, update_attempt - ) - - if is_ready: - self._is_available = True - if update_attempt > 0: - _LOGGER.debug( - "Device %s|%s responded after %s attempts", - self._host, - self._alias, - update_attempt, - ) - break - await asyncio.sleep(SLEEP_TIME) - - else: - if self._is_available: - _LOGGER.warning( - "Could not read state for %s|%s", self.smartplug.host, self._alias - ) - self._is_available = False + await self.hass.async_add_job(self.smartplug.turn_off) + await self.coordinator.async_refresh() diff --git a/tests/components/tplink/consts.py b/tests/components/tplink/consts.py new file mode 100644 index 00000000000..de134ddbe07 --- /dev/null +++ b/tests/components/tplink/consts.py @@ -0,0 +1,72 @@ +"""Constants for the TP-Link component tests.""" + +SMARTPLUGSWITCH_DATA = { + "sysinfo": { + "sw_ver": "1.0.4 Build 191111 Rel.143500", + "hw_ver": "4.0", + "model": "HS110(EU)", + "deviceId": "4C56447B395BB7A2FAC68C9DFEE2E84163222581", + "oemId": "40F54B43071E9436B6395611E9D91CEA", + "hwId": "A6C77E4FDD238B53D824AC8DA361F043", + "rssi": -24, + "longitude_i": 130793, + "latitude_i": 480582, + "alias": "SmartPlug", + "status": "new", + "mic_type": "IOT.SMARTPLUGSWITCH", + "feature": "TIM:ENE", + "mac": "69:F2:3C:8E:E3:47", + "updating": 0, + "led_off": 0, + "relay_state": 0, + "on_time": 0, + "active_mode": "none", + "icon_hash": "", + "dev_name": "Smart Wi-Fi Plug With Energy Monitoring", + "next_action": {"type": -1}, + "err_code": 0, + }, + "realtime": { + "voltage_mv": 233957, + "current_ma": 21, + "power_mw": 0, + "total_wh": 1793, + "err_code": 0, + }, +} +SMARTSTRIPWITCH_DATA = { + "sysinfo": { + "sw_ver": "1.0.4 Build 191111 Rel.143500", + "hw_ver": "4.0", + "model": "HS110(EU)", + "deviceId": "4C56447B395BB7A2FAC68C9DFEE2E84163222581", + "oemId": "40F54B43071E9436B6395611E9D91CEA", + "hwId": "A6C77E4FDD238B53D824AC8DA361F043", + "rssi": -24, + "longitude_i": 130793, + "latitude_i": 480582, + "alias": "SmartPlug", + "status": "new", + "mic_type": "IOT.SMARTPLUGSWITCH", + "feature": "TIM", + "mac": "69:F2:3C:8E:E3:47", + "updating": 0, + "led_off": 0, + "relay_state": 0, + "on_time": 0, + "active_mode": "none", + "icon_hash": "", + "dev_name": "Smart Wi-Fi Plug With Energy Monitoring", + "next_action": {"type": -1}, + "children": [{"id": "1", "state": 1, "alias": "SmartPlug#1"}], + "err_code": 0, + }, + "realtime": { + "voltage_mv": 233957, + "current_ma": 21, + "power_mw": 0, + "total_wh": 1793, + "err_code": 0, + }, + "context": "1", +} diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 49309a6ecef..0cfb4d3d233 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1,24 +1,45 @@ """Tests for the TP-Link component.""" from __future__ import annotations +from datetime import datetime +import time from typing import Any from unittest.mock import MagicMock, patch -from pyHS100 import SmartBulb, SmartDevice, SmartDeviceException, SmartPlug +from pyHS100 import SmartBulb, SmartDevice, SmartDeviceException, SmartPlug, smartstrip +from pyHS100.smartdevice import EmeterStatus import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import tplink -from homeassistant.components.tplink.common import ( +from homeassistant.components.sensor import ATTR_LAST_RESET +from homeassistant.components.switch import ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH +from homeassistant.components.tplink.common import SmartDevices +from homeassistant.components.tplink.const import ( + ATTR_CURRENT_A, + ATTR_TOTAL_ENERGY_KWH, CONF_DIMMER, CONF_DISCOVERY, + CONF_EMETER_PARAMS, CONF_LIGHT, + CONF_MODEL, + CONF_SW_VERSION, CONF_SWITCH, + COORDINATORS, ) -from homeassistant.const import CONF_HOST +from homeassistant.const import ( + ATTR_VOLTAGE, + CONF_ALIAS, + CONF_DEVICE_ID, + CONF_HOST, + CONF_MAC, +) +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utc_from_timestamp from tests.common import MockConfigEntry, mock_coro +from tests.components.tplink.consts import SMARTPLUGSWITCH_DATA, SMARTSTRIPWITCH_DATA async def test_creating_entry_tries_discover(hass): @@ -186,7 +207,7 @@ async def test_configuring_discovery_disabled(hass): assert mock_setup.call_count == 1 -async def test_platforms_are_initialized(hass): +async def test_platforms_are_initialized(hass: HomeAssistant): """Test that platforms are initialized per configuration array.""" config = { tplink.DOMAIN: { @@ -199,6 +220,8 @@ async def test_platforms_are_initialized(hass): with patch( "homeassistant.components.tplink.common.Discover.discover" ) as discover, patch( + "homeassistant.components.tplink.get_static_devices" + ) as get_static_devices, patch( "homeassistant.components.tplink.common.SmartDevice._query_helper" ), patch( "homeassistant.components.tplink.light.async_setup_entry", @@ -209,13 +232,141 @@ async def test_platforms_are_initialized(hass): ) as switch_setup, patch( "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False ): + + light = SmartBulb("123.123.123.123") + switch = SmartPlug("321.321.321.321") + switch.get_sysinfo = MagicMock(return_value=SMARTPLUGSWITCH_DATA["sysinfo"]) + switch.get_emeter_realtime = MagicMock( + return_value=EmeterStatus(SMARTPLUGSWITCH_DATA["realtime"]) + ) + switch.get_emeter_daily = MagicMock( + return_value={int(time.strftime("%e")): 1.123} + ) + get_static_devices.return_value = SmartDevices([light], [switch]) + # patching is_dimmable is necessray to avoid misdetection as light. await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() - assert discover.call_count == 0 - assert light_setup.call_count == 1 - assert switch_setup.call_count == 1 + assert hass.data.get(tplink.DOMAIN) + assert hass.data[tplink.DOMAIN].get(COORDINATORS) + assert hass.data[tplink.DOMAIN][COORDINATORS].get(switch.mac) + assert isinstance( + hass.data[tplink.DOMAIN][COORDINATORS][switch.mac], + tplink.SmartPlugDataUpdateCoordinator, + ) + data = hass.data[tplink.DOMAIN][COORDINATORS][switch.mac].data + assert data[CONF_HOST] == switch.host + assert data[CONF_MAC] == switch.sys_info["mac"] + assert data[CONF_MODEL] == switch.sys_info["model"] + assert data[CONF_SW_VERSION] == switch.sys_info["sw_ver"] + assert data[CONF_ALIAS] == switch.sys_info["alias"] + assert data[CONF_DEVICE_ID] == switch.sys_info["mac"] + + emeter_readings = switch.get_emeter_realtime() + assert data[CONF_EMETER_PARAMS][ATTR_VOLTAGE] == round( + float(emeter_readings["voltage"]), 1 + ) + assert data[CONF_EMETER_PARAMS][ATTR_CURRENT_A] == round( + float(emeter_readings["current"]), 2 + ) + assert data[CONF_EMETER_PARAMS][ATTR_CURRENT_POWER_W] == round( + float(emeter_readings["power"]), 2 + ) + assert data[CONF_EMETER_PARAMS][ATTR_TOTAL_ENERGY_KWH] == round( + float(emeter_readings["total"]), 3 + ) + assert data[CONF_EMETER_PARAMS][ATTR_LAST_RESET][ + ATTR_TOTAL_ENERGY_KWH + ] == utc_from_timestamp(0) + + assert data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] == 1.123 + assert data[CONF_EMETER_PARAMS][ATTR_LAST_RESET][ + ATTR_TODAY_ENERGY_KWH + ] == datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + + assert discover.call_count == 0 + assert get_static_devices.call_count == 1 + assert light_setup.call_count == 1 + assert switch_setup.call_count == 1 + + +async def test_smartplug_without_consumption_sensors(hass: HomeAssistant): + """Test that platforms are initialized per configuration array.""" + config = { + tplink.DOMAIN: { + CONF_DISCOVERY: False, + CONF_SWITCH: [{CONF_HOST: "321.321.321.321"}], + } + } + + with patch("homeassistant.components.tplink.common.Discover.discover"), patch( + "homeassistant.components.tplink.get_static_devices" + ) as get_static_devices, patch( + "homeassistant.components.tplink.common.SmartDevice._query_helper" + ), patch( + "homeassistant.components.tplink.light.async_setup_entry", + return_value=mock_coro(True), + ), patch( + "homeassistant.components.tplink.switch.async_setup_entry", + return_value=mock_coro(True), + ), patch( + "homeassistant.components.tplink.sensor.SmartPlugSensor.__init__" + ) as SmartPlugSensor, patch( + "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False + ): + + switch = SmartPlug("321.321.321.321") + switch.get_sysinfo = MagicMock(return_value=SMARTPLUGSWITCH_DATA["sysinfo"]) + get_static_devices.return_value = SmartDevices([], [switch]) + + await async_setup_component(hass, tplink.DOMAIN, config) + await hass.async_block_till_done() + + assert SmartPlugSensor.call_count == 0 + + +async def test_smartstrip_device(hass: HomeAssistant): + """Test discover a SmartStrip devices.""" + config = { + tplink.DOMAIN: { + CONF_DISCOVERY: True, + } + } + + class SmartStrip(smartstrip.SmartStrip): + """Moked SmartStrip class.""" + + def get_sysinfo(self): + return SMARTSTRIPWITCH_DATA["sysinfo"] + + with patch( + "homeassistant.components.tplink.common.Discover.discover" + ) as discover, patch( + "homeassistant.components.tplink.common.SmartDevice._query_helper" + ), patch( + "homeassistant.components.tplink.common.SmartPlug.get_sysinfo", + return_value=SMARTSTRIPWITCH_DATA["sysinfo"], + ), patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" + ): + + strip = SmartStrip("123.123.123.123") + discover.return_value = {"123.123.123.123": strip} + + assert await async_setup_component(hass, tplink.DOMAIN, config) + await hass.async_block_till_done() + + assert hass.data.get(tplink.DOMAIN) + assert hass.data[tplink.DOMAIN].get(COORDINATORS) + assert hass.data[tplink.DOMAIN][COORDINATORS].get(strip.mac) + assert isinstance( + hass.data[tplink.DOMAIN][COORDINATORS][strip.mac], + tplink.SmartPlugDataUpdateCoordinator, + ) + data = hass.data[tplink.DOMAIN][COORDINATORS][strip.mac].data + assert data[CONF_ALIAS] == strip.sys_info["children"][0]["alias"] + assert data[CONF_DEVICE_ID] == "1" async def test_no_config_creates_no_entry(hass): @@ -230,6 +381,42 @@ async def test_no_config_creates_no_entry(hass): assert mock_setup.call_count == 0 +async def test_not_ready(hass: HomeAssistant): + """Test for not ready when configured devices are not available.""" + config = { + tplink.DOMAIN: { + CONF_DISCOVERY: False, + CONF_SWITCH: [{CONF_HOST: "321.321.321.321"}], + } + } + + with patch("homeassistant.components.tplink.common.Discover.discover"), patch( + "homeassistant.components.tplink.get_static_devices" + ) as get_static_devices, patch( + "homeassistant.components.tplink.common.SmartDevice._query_helper" + ), patch( + "homeassistant.components.tplink.light.async_setup_entry", + return_value=mock_coro(True), + ), patch( + "homeassistant.components.tplink.switch.async_setup_entry", + return_value=mock_coro(True), + ), patch( + "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False + ): + + switch = SmartPlug("321.321.321.321") + switch.get_sysinfo = MagicMock(side_effect=SmartDeviceException()) + get_static_devices.return_value = SmartDevices([], [switch]) + + await async_setup_component(hass, tplink.DOMAIN, config) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(tplink.DOMAIN) + + assert len(entries) == 1 + assert entries[0].state is config_entries.ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize("platform", ["switch", "light"]) async def test_unload(hass, platform): """Test that the async_unload_entry works.""" @@ -238,21 +425,35 @@ async def test_unload(hass, platform): entry.add_to_hass(hass) with patch( + "homeassistant.components.tplink.get_static_devices" + ) as get_static_devices, patch( "homeassistant.components.tplink.common.SmartDevice._query_helper" ), patch( f"homeassistant.components.tplink.{platform}.async_setup_entry", return_value=mock_coro(True), - ) as light_setup: + ) as async_setup_entry: config = { tplink.DOMAIN: { platform: [{CONF_HOST: "123.123.123.123"}], CONF_DISCOVERY: False, } } + + light = SmartBulb("123.123.123.123") + switch = SmartPlug("321.321.321.321") + switch.get_sysinfo = MagicMock(return_value=SMARTPLUGSWITCH_DATA["sysinfo"]) + switch.get_emeter_realtime = MagicMock( + return_value=EmeterStatus(SMARTPLUGSWITCH_DATA["realtime"]) + ) + if platform == "light": + get_static_devices.return_value = SmartDevices([light], []) + elif platform == "switch": + get_static_devices.return_value = SmartDevices([], [switch]) + assert await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() - assert len(light_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 1 assert tplink.DOMAIN in hass.data assert await tplink.async_unload_entry(hass, entry) diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index ea8809bc679..c9b07529ea4 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, DOMAIN as LIGHT_DOMAIN, ) -from homeassistant.components.tplink.common import ( +from homeassistant.components.tplink.const import ( CONF_DIMMER, CONF_DISCOVERY, CONF_LIGHT, From 6590e464af2b8598df034d5e0f948ff7fa7c833b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 29 Jul 2021 20:05:53 +0200 Subject: [PATCH 023/903] Integration. Add device class, last_reset, state_class (#53698) Co-authored-by: Franck Nijhof --- .../components/integration/sensor.py | 29 ++++- tests/components/integration/test_sensor.py | 116 ++++++++++++++++-- 2 files changed, 132 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 0ab4ac0d2c4..dea8970f4f7 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -4,8 +4,16 @@ import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, CONF_METHOD, CONF_NAME, @@ -20,6 +28,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util # mypy: allow-untyped-defs, no-check-untyped-defs @@ -115,16 +124,26 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] + self._attr_state_class = STATE_CLASS_MEASUREMENT async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() state = await self.async_get_last_state() + self._attr_last_reset = dt_util.utcnow() if state: try: self._state = Decimal(state.state) - except ValueError as err: + except (DecimalException, ValueError) as err: _LOGGER.warning("Could not restore last state: %s", err) + else: + last_reset = dt_util.parse_datetime( + state.attributes.get(ATTR_LAST_RESET, "") + ) + self._attr_last_reset = ( + last_reset if last_reset else dt_util.utc_from_timestamp(0) + ) + self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) @callback def calc_integration(event): @@ -143,7 +162,11 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_of_measurement = self._unit_template.format( "" if unit is None else unit ) - + if ( + self.device_class is None + and new_state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + ): + self._attr_device_class = DEVICE_CLASS_ENERGY try: # integration as the Riemann integral of previous measures. area = 0 diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 3afa5c14c22..dd6bf980d0f 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -2,12 +2,22 @@ from datetime import timedelta from unittest.mock import patch -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, TIME_SECONDS +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + TIME_SECONDS, +) +from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from tests.common import mock_restore_cache -async def test_state(hass): + +async def test_state(hass) -> None: """Test integration sensor state.""" config = { "sensor": { @@ -19,15 +29,25 @@ async def test_state(hass): } } - assert await async_setup_component(hass, "sensor", config) - - entity_id = config["sensor"]["source"] - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() - - now = dt_util.utcnow() + timedelta(seconds=3600) + now = dt_util.utcnow() with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set(entity_id, 1, {}, force_update=True) + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state is not None + assert state.attributes.get("last_reset") == now.isoformat() + assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT + assert "device_class" not in state.attributes + + future_now = dt_util.utcnow() + timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=future_now): + hass.states.async_set( + entity_id, 1, {"device_class": DEVICE_CLASS_POWER}, force_update=True + ) await hass.async_block_till_done() state = hass.states.get("sensor.integration") @@ -37,6 +57,82 @@ async def test_state(hass): assert round(float(state.state), config["sensor"]["round"]) == 1.0 assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR + assert state.attributes.get("device_class") == DEVICE_CLASS_ENERGY + assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT + assert state.attributes.get("last_reset") == now.isoformat() + + +async def test_restore_state(hass: HomeAssistant) -> None: + """Test integration sensor state is restored correctly.""" + mock_restore_cache( + hass, + ( + State( + "sensor.integration", + "100.0", + { + "last_reset": "2019-10-06T21:00:00", + "device_class": DEVICE_CLASS_ENERGY, + }, + ), + ), + ) + + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + "unit": ENERGY_KILO_WATT_HOUR, + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state + assert state.state == "100.00" + assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR + assert state.attributes.get("device_class") == DEVICE_CLASS_ENERGY + assert state.attributes.get("last_reset") == "2019-10-06T21:00:00" + + +async def test_restore_state_failed(hass: HomeAssistant) -> None: + """Test integration sensor state is restored correctly.""" + mock_restore_cache( + hass, + ( + State( + "sensor.integration", + "INVALID", + { + "last_reset": "2019-10-06T21:00:00.000000", + }, + ), + ), + ) + + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + "unit": ENERGY_KILO_WATT_HOUR, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state + assert state.state == "0" + assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR + assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT + assert state.attributes.get("last_reset") != "2019-10-06T21:00:00" + assert "device_class" not in state.attributes async def test_trapezoidal(hass): From 2b0b8736f2d181159ae7a32f2681bdd3c2daa4d6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 29 Jul 2021 20:54:51 +0200 Subject: [PATCH 024/903] Set state class measurement also for Total Energy for AVM Fritz!Smarthome devices (#53707) --- homeassistant/components/fritzbox/sensor.py | 2 +- tests/components/fritzbox/test_switch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 24b3d9cc5ff..d325e592faf 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -96,7 +96,7 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}_total_energy", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: None, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, coordinator, ain, diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index cb6ae85f889..951528f1e7d 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -76,7 +76,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_LAST_RESET] == "1970-01-01T00:00:00+00:00" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Total Energy" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT async def test_turn_on(hass: HomeAssistant, fritz: Mock): From 4da9116025804fe22b97fa5996763aedde44dd30 Mon Sep 17 00:00:00 2001 From: Gerard Date: Thu, 29 Jul 2021 20:57:46 +0200 Subject: [PATCH 025/903] Bump bimmer_connected to 0.7.16 to fix parking light issue (#53687) --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index aff9e4fd647..17aaa166942 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.15"], + "requirements": ["bimmer_connected==0.7.16"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 063d8d0b4fd..e9a46b9e079 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -367,7 +367,7 @@ beautifulsoup4==4.9.3 bellows==0.26.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.15 +bimmer_connected==0.7.16 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 062e5068c91..29938321ee3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -223,7 +223,7 @@ base36==0.1.1 bellows==0.26.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.15 +bimmer_connected==0.7.16 # homeassistant.components.blebox blebox_uniapi==1.3.3 From 007ecc51e5b677ca8ed689b8e9073b9da722ccbe Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 29 Jul 2021 20:58:48 +0200 Subject: [PATCH 026/903] Revert "Add Automate Pulse Hub v2 support (#39501)" (#53704) --- .coveragerc | 6 - CODEOWNERS | 1 - homeassistant/components/automate/__init__.py | 36 ----- homeassistant/components/automate/base.py | 93 ----------- .../components/automate/config_flow.py | 37 ----- homeassistant/components/automate/const.py | 6 - homeassistant/components/automate/cover.py | 147 ------------------ homeassistant/components/automate/helpers.py | 46 ------ homeassistant/components/automate/hub.py | 89 ----------- .../components/automate/manifest.json | 13 -- .../components/automate/strings.json | 19 --- .../components/automate/translations/ca.json | 19 --- .../components/automate/translations/de.json | 19 --- .../components/automate/translations/en.json | 19 --- .../components/automate/translations/et.json | 19 --- .../components/automate/translations/he.json | 19 --- .../components/automate/translations/nl.json | 19 --- .../components/automate/translations/pl.json | 19 --- .../components/automate/translations/ru.json | 19 --- .../automate/translations/zh-Hant.json | 19 --- homeassistant/generated/config_flows.py | 1 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/automate/__init__.py | 1 - tests/components/automate/test_config_flow.py | 69 -------- 25 files changed, 741 deletions(-) delete mode 100644 homeassistant/components/automate/__init__.py delete mode 100644 homeassistant/components/automate/base.py delete mode 100644 homeassistant/components/automate/config_flow.py delete mode 100644 homeassistant/components/automate/const.py delete mode 100644 homeassistant/components/automate/cover.py delete mode 100644 homeassistant/components/automate/helpers.py delete mode 100644 homeassistant/components/automate/hub.py delete mode 100644 homeassistant/components/automate/manifest.json delete mode 100644 homeassistant/components/automate/strings.json delete mode 100644 homeassistant/components/automate/translations/ca.json delete mode 100644 homeassistant/components/automate/translations/de.json delete mode 100644 homeassistant/components/automate/translations/en.json delete mode 100644 homeassistant/components/automate/translations/et.json delete mode 100644 homeassistant/components/automate/translations/he.json delete mode 100644 homeassistant/components/automate/translations/nl.json delete mode 100644 homeassistant/components/automate/translations/pl.json delete mode 100644 homeassistant/components/automate/translations/ru.json delete mode 100644 homeassistant/components/automate/translations/zh-Hant.json delete mode 100644 tests/components/automate/__init__.py delete mode 100644 tests/components/automate/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index c3516739798..1f28a9a2aee 100644 --- a/.coveragerc +++ b/.coveragerc @@ -75,12 +75,6 @@ omit = homeassistant/components/asuswrt/router.py homeassistant/components/aten_pe/* homeassistant/components/atome/* - homeassistant/components/automate/__init__.py - homeassistant/components/automate/base.py - homeassistant/components/automate/const.py - homeassistant/components/automate/cover.py - homeassistant/components/automate/helpers.py - homeassistant/components/automate/hub.py homeassistant/components/aurora/__init__.py homeassistant/components/aurora/binary_sensor.py homeassistant/components/aurora/const.py diff --git a/CODEOWNERS b/CODEOWNERS index c4cb6d242d0..29906631254 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,7 +56,6 @@ homeassistant/components/august/* @bdraco homeassistant/components/aurora/* @djtimca homeassistant/components/aurora_abb_powerone/* @davet2001 homeassistant/components/auth/* @home-assistant/core -homeassistant/components/automate/* @sillyfrog homeassistant/components/automation/* @home-assistant/core homeassistant/components/avea/* @pattyland homeassistant/components/awair/* @ahayworth @danielsjf diff --git a/homeassistant/components/automate/__init__.py b/homeassistant/components/automate/__init__.py deleted file mode 100644 index c4f34d96a05..00000000000 --- a/homeassistant/components/automate/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -"""The Automate Pulse Hub v2 integration.""" -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from .const import DOMAIN -from .hub import PulseHub - -PLATFORMS = ["cover"] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Automate Pulse Hub v2 from a config entry.""" - hub = PulseHub(hass, entry) - - if not await hub.async_setup(): - return False - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = hub - - hass.config_entries.async_setup_platforms(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - hub = hass.data[DOMAIN][entry.entry_id] - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - if not await hub.async_reset(): - return False - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok diff --git a/homeassistant/components/automate/base.py b/homeassistant/components/automate/base.py deleted file mode 100644 index de37933e54d..00000000000 --- a/homeassistant/components/automate/base.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Base class for Automate Roller Blinds.""" -import logging - -import aiopulse2 - -from homeassistant.core import callback -from homeassistant.helpers import entity -from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_registry import async_get_registry as get_ent_reg - -from .const import AUTOMATE_ENTITY_REMOVE, DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -class AutomateBase(entity.Entity): - """Base representation of an Automate roller.""" - - def __init__(self, roller: aiopulse2.Roller) -> None: - """Initialize the roller.""" - self.roller = roller - - @property - def available(self) -> bool: - """Return True if roller and hub is available.""" - return self.roller.online and self.roller.hub.connected - - async def async_remove_and_unregister(self): - """Unregister from entity and device registry and call entity remove function.""" - _LOGGER.info("Removing %s %s", self.__class__.__name__, self.unique_id) - - ent_registry = await get_ent_reg(self.hass) - if self.entity_id in ent_registry.entities: - ent_registry.async_remove(self.entity_id) - - dev_registry = await get_dev_reg(self.hass) - device = dev_registry.async_get_device( - identifiers={(DOMAIN, self.unique_id)}, connections=set() - ) - if device is not None: - dev_registry.async_update_device( - device.id, remove_config_entry_id=self.registry_entry.config_entry_id - ) - - await self.async_remove() - - async def async_added_to_hass(self): - """Entity has been added to hass.""" - self.roller.callback_subscribe(self.notify_update) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - AUTOMATE_ENTITY_REMOVE.format(self.roller.id), - self.async_remove_and_unregister, - ) - ) - - async def async_will_remove_from_hass(self): - """Entity being removed from hass.""" - self.roller.callback_unsubscribe(self.notify_update) - - @callback - def notify_update(self, roller: aiopulse2.Roller): - """Write updated device state information.""" - _LOGGER.debug( - "Device update notification received: %s (%r)", roller.id, roller.name - ) - self.async_write_ha_state() - - @property - def should_poll(self): - """Report that Automate entities do not need polling.""" - return False - - @property - def unique_id(self): - """Return the unique ID of this roller.""" - return self.roller.id - - @property - def name(self): - """Return the name of roller.""" - return self.roller.name - - @property - def device_info(self): - """Return the device info.""" - attrs = { - "identifiers": {(DOMAIN, self.roller.id)}, - } - return attrs diff --git a/homeassistant/components/automate/config_flow.py b/homeassistant/components/automate/config_flow.py deleted file mode 100644 index 45d3a5b9349..00000000000 --- a/homeassistant/components/automate/config_flow.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Config flow for Automate Pulse Hub v2 integration.""" -import logging - -import aiopulse2 -import voluptuous as vol - -from homeassistant import config_entries - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema({vol.Required("host"): str}) - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Automate Pulse Hub v2.""" - - VERSION = 1 - - async def async_step_user(self, user_input=None): - """Handle the initial step once we have info from the user.""" - if user_input is not None: - try: - hub = aiopulse2.Hub(user_input["host"]) - await hub.test() - title = hub.name - except Exception: # pylint: disable=broad-except - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={"base": "cannot_connect"}, - ) - - return self.async_create_entry(title=title, data=user_input) - - return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) diff --git a/homeassistant/components/automate/const.py b/homeassistant/components/automate/const.py deleted file mode 100644 index 0c1dc1bd2e5..00000000000 --- a/homeassistant/components/automate/const.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Constants for the Automate Pulse Hub v2 integration.""" - -DOMAIN = "automate" - -AUTOMATE_HUB_UPDATE = "automate_hub_update_{}" -AUTOMATE_ENTITY_REMOVE = "automate_entity_remove_{}" diff --git a/homeassistant/components/automate/cover.py b/homeassistant/components/automate/cover.py deleted file mode 100644 index 86dcda10adf..00000000000 --- a/homeassistant/components/automate/cover.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Support for Automate Roller Blinds.""" -import aiopulse2 - -from homeassistant.components.cover import ( - ATTR_POSITION, - DEVICE_CLASS_SHADE, - SUPPORT_CLOSE, - SUPPORT_CLOSE_TILT, - SUPPORT_OPEN, - SUPPORT_OPEN_TILT, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, - SUPPORT_STOP, - SUPPORT_STOP_TILT, - CoverEntity, -) -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -from .base import AutomateBase -from .const import AUTOMATE_HUB_UPDATE, DOMAIN -from .helpers import async_add_automate_entities - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Automate Rollers from a config entry.""" - hub = hass.data[DOMAIN][config_entry.entry_id] - - current = set() - - @callback - def async_add_automate_covers(): - async_add_automate_entities( - hass, AutomateCover, config_entry, current, async_add_entities - ) - - hub.cleanup_callbacks.append( - async_dispatcher_connect( - hass, - AUTOMATE_HUB_UPDATE.format(config_entry.entry_id), - async_add_automate_covers, - ) - ) - - -class AutomateCover(AutomateBase, CoverEntity): - """Representation of a Automate cover device.""" - - @property - def current_cover_position(self): - """Return the current position of the roller blind. - - None is unknown, 0 is closed, 100 is fully open. - """ - position = None - if self.roller.closed_percent is not None: - position = 100 - self.roller.closed_percent - return position - - @property - def current_cover_tilt_position(self): - """Return the current tilt of the roller blind. - - None is unknown, 0 is closed, 100 is fully open. - """ - return None - - @property - def supported_features(self): - """Flag supported features.""" - supported_features = 0 - if self.current_cover_position is not None: - supported_features |= ( - SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION - ) - if self.current_cover_tilt_position is not None: - supported_features |= ( - SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT - | SUPPORT_STOP_TILT - | SUPPORT_SET_TILT_POSITION - ) - - return supported_features - - @property - def device_info(self): - """Return the device info.""" - attrs = super().device_info - attrs["manufacturer"] = "Automate" - attrs["model"] = self.roller.devicetype - attrs["sw_version"] = self.roller.version - attrs["via_device"] = (DOMAIN, self.roller.hub.id) - attrs["name"] = self.name - return attrs - - @property - def device_class(self): - """Class of the cover, a shade.""" - return DEVICE_CLASS_SHADE - - @property - def is_opening(self): - """Is cover opening/moving up.""" - return self.roller.action == aiopulse2.MovingAction.up - - @property - def is_closing(self): - """Is cover closing/moving down.""" - return self.roller.action == aiopulse2.MovingAction.down - - @property - def is_closed(self): - """Return if the cover is closed.""" - return self.roller.closed_percent == 100 - - async def async_close_cover(self, **kwargs): - """Close the roller.""" - await self.roller.move_down() - - async def async_open_cover(self, **kwargs): - """Open the roller.""" - await self.roller.move_up() - - async def async_stop_cover(self, **kwargs): - """Stop the roller.""" - await self.roller.move_stop() - - async def async_set_cover_position(self, **kwargs): - """Move the roller shutter to a specific position.""" - await self.roller.move_to(100 - kwargs[ATTR_POSITION]) - - async def async_close_cover_tilt(self, **kwargs): - """Close the roller.""" - await self.roller.move_down() - - async def async_open_cover_tilt(self, **kwargs): - """Open the roller.""" - await self.roller.move_up() - - async def async_stop_cover_tilt(self, **kwargs): - """Stop the roller.""" - await self.roller.move_stop() - - async def async_set_cover_tilt(self, **kwargs): - """Tilt the roller shutter to a specific position.""" - await self.roller.move_to(100 - kwargs[ATTR_POSITION]) diff --git a/homeassistant/components/automate/helpers.py b/homeassistant/components/automate/helpers.py deleted file mode 100644 index 92130eeb79b..00000000000 --- a/homeassistant/components/automate/helpers.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Helper functions for Automate Pulse.""" -import logging - -from homeassistant.core import callback -from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -@callback -def async_add_automate_entities( - hass, entity_class, config_entry, current, async_add_entities -): - """Add any new entities.""" - hub = hass.data[DOMAIN][config_entry.entry_id] - _LOGGER.debug("Looking for new %s on: %s", entity_class.__name__, hub.host) - - api = hub.api.rollers - - new_items = [] - for unique_id, roller in api.items(): - if unique_id not in current: - _LOGGER.debug("New %s %s", entity_class.__name__, unique_id) - new_item = entity_class(roller) - current.add(unique_id) - new_items.append(new_item) - - async_add_entities(new_items) - - -async def update_devices(hass, config_entry, api): - """Tell hass that device info has been updated.""" - dev_registry = await get_dev_reg(hass) - - for api_item in api.values(): - # Update Device name - device = dev_registry.async_get_device( - identifiers={(DOMAIN, api_item.id)}, connections=set() - ) - if device is not None: - dev_registry.async_update_device( - device.id, - name=api_item.name, - ) diff --git a/homeassistant/components/automate/hub.py b/homeassistant/components/automate/hub.py deleted file mode 100644 index 78e1b5873fa..00000000000 --- a/homeassistant/components/automate/hub.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Code to handle a Pulse Hub.""" -from __future__ import annotations - -import asyncio -import logging - -import aiopulse2 - -from homeassistant.helpers.dispatcher import async_dispatcher_send - -from .const import AUTOMATE_ENTITY_REMOVE, AUTOMATE_HUB_UPDATE -from .helpers import update_devices - -_LOGGER = logging.getLogger(__name__) - - -class PulseHub: - """Manages a single Pulse Hub.""" - - def __init__(self, hass, config_entry): - """Initialize the system.""" - self.config_entry = config_entry - self.hass = hass - self.api: aiopulse2.Hub | None = None - self.tasks = [] - self.current_rollers = {} - self.cleanup_callbacks = [] - - @property - def title(self): - """Return the title of the hub shown in the integrations list.""" - return f"{self.api.name} ({self.api.host})" - - @property - def host(self): - """Return the host of this hub.""" - return self.config_entry.data["host"] - - async def async_setup(self): - """Set up a hub based on host parameter.""" - host = self.host - - hub = aiopulse2.Hub(host, propagate_callbacks=True) - - self.api = hub - - hub.callback_subscribe(self.async_notify_update) - self.tasks.append(asyncio.create_task(hub.run())) - - _LOGGER.debug("Hub setup complete") - return True - - async def async_reset(self): - """Reset this hub to default state.""" - for cleanup_callback in self.cleanup_callbacks: - cleanup_callback() - - # If not setup - if self.api is None: - return False - - self.api.callback_unsubscribe(self.async_notify_update) - await self.api.stop() - del self.api - self.api = None - - # Wait for any running tasks to complete - await asyncio.wait(self.tasks) - - return True - - async def async_notify_update(self, hub=None): - """Evaluate entities when hub reports that update has occurred.""" - _LOGGER.debug("Hub {self.title} updated") - - await update_devices(self.hass, self.config_entry, self.api.rollers) - self.hass.config_entries.async_update_entry(self.config_entry, title=self.title) - - async_dispatcher_send( - self.hass, AUTOMATE_HUB_UPDATE.format(self.config_entry.entry_id) - ) - - for unique_id in list(self.current_rollers): - if unique_id not in self.api.rollers: - _LOGGER.debug("Notifying remove of %s", unique_id) - self.current_rollers.pop(unique_id) - async_dispatcher_send( - self.hass, AUTOMATE_ENTITY_REMOVE.format(unique_id) - ) diff --git a/homeassistant/components/automate/manifest.json b/homeassistant/components/automate/manifest.json deleted file mode 100644 index 071aaf1589f..00000000000 --- a/homeassistant/components/automate/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "domain": "automate", - "name": "Automate Pulse Hub v2", - "config_flow": true, - "iot_class": "local_push", - "documentation": "https://www.home-assistant.io/integrations/automate", - "requirements": [ - "aiopulse2==0.6.0" - ], - "codeowners": [ - "@sillyfrog" - ] -} \ No newline at end of file diff --git a/homeassistant/components/automate/strings.json b/homeassistant/components/automate/strings.json deleted file mode 100644 index 8a8131f0f67..00000000000 --- a/homeassistant/components/automate/strings.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "host": "[%key:common::config_flow::data::host%]" - } - } - }, - "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%]" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/automate/translations/ca.json b/homeassistant/components/automate/translations/ca.json deleted file mode 100644 index 69cd7de20aa..00000000000 --- a/homeassistant/components/automate/translations/ca.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "El dispositiu ja est\u00e0 configurat" - }, - "error": { - "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", - "unknown": "Error inesperat" - }, - "step": { - "user": { - "data": { - "host": "Amfitri\u00f3" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/automate/translations/de.json b/homeassistant/components/automate/translations/de.json deleted file mode 100644 index fa773dbf708..00000000000 --- a/homeassistant/components/automate/translations/de.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" - }, - "error": { - "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifizierung", - "unknown": "Unerwarteter Fehler" - }, - "step": { - "user": { - "data": { - "host": "Host" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/automate/translations/en.json b/homeassistant/components/automate/translations/en.json deleted file mode 100644 index 2ad35962b25..00000000000 --- a/homeassistant/components/automate/translations/en.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "host": "Host" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/automate/translations/et.json b/homeassistant/components/automate/translations/et.json deleted file mode 100644 index da2c8eef4d2..00000000000 --- a/homeassistant/components/automate/translations/et.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Seade on juba h\u00e4\u00e4lestatud" - }, - "error": { - "cannot_connect": "\u00dchendamine nurjus", - "invalid_auth": "Tuvastamine nurjus", - "unknown": "Ootamatu t\u00f5rge" - }, - "step": { - "user": { - "data": { - "host": "Host" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/automate/translations/he.json b/homeassistant/components/automate/translations/he.json deleted file mode 100644 index accc8a0a610..00000000000 --- a/homeassistant/components/automate/translations/he.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" - }, - "error": { - "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", - "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", - "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" - }, - "step": { - "user": { - "data": { - "host": "\u05de\u05d0\u05e8\u05d7" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/automate/translations/nl.json b/homeassistant/components/automate/translations/nl.json deleted file mode 100644 index bb92bf9e593..00000000000 --- a/homeassistant/components/automate/translations/nl.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Apparaat is al geconfigureerd" - }, - "error": { - "cannot_connect": "Kan geen verbinding maken", - "invalid_auth": "Ongeldige authenticatie", - "unknown": "Onverwachte fout" - }, - "step": { - "user": { - "data": { - "host": "Host" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/automate/translations/pl.json b/homeassistant/components/automate/translations/pl.json deleted file mode 100644 index 647c0921e3a..00000000000 --- a/homeassistant/components/automate/translations/pl.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" - }, - "error": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", - "invalid_auth": "Niepoprawne uwierzytelnienie", - "unknown": "Nieoczekiwany b\u0142\u0105d" - }, - "step": { - "user": { - "data": { - "host": "Nazwa hosta lub adres IP" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/automate/translations/ru.json b/homeassistant/components/automate/translations/ru.json deleted file mode 100644 index 1212d054ff7..00000000000 --- a/homeassistant/components/automate/translations/ru.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." - }, - "error": { - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", - "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", - "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." - }, - "step": { - "user": { - "data": { - "host": "\u0425\u043e\u0441\u0442" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/automate/translations/zh-Hant.json b/homeassistant/components/automate/translations/zh-Hant.json deleted file mode 100644 index 0fbaa19828f..00000000000 --- a/homeassistant/components/automate/translations/zh-Hant.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" - }, - "error": { - "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", - "unknown": "\u672a\u9810\u671f\u932f\u8aa4" - }, - "step": { - "user": { - "data": { - "host": "\u4e3b\u6a5f\u7aef" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 89b1a9ba8ae..4cb9e2e3c4b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -28,7 +28,6 @@ FLOWS = [ "atag", "august", "aurora", - "automate", "awair", "axis", "azure_devops", diff --git a/requirements_all.txt b/requirements_all.txt index e9a46b9e079..96d5c1c3360 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -220,9 +220,6 @@ aionotify==0.2.0 # homeassistant.components.notion aionotion==3.0.2 -# homeassistant.components.automate -aiopulse2==0.6.0 - # homeassistant.components.acmeda aiopulse==0.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29938321ee3..a5f527a05e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -142,9 +142,6 @@ aiomusiccast==0.8.2 # homeassistant.components.notion aionotion==3.0.2 -# homeassistant.components.automate -aiopulse2==0.6.0 - # homeassistant.components.acmeda aiopulse==0.4.2 diff --git a/tests/components/automate/__init__.py b/tests/components/automate/__init__.py deleted file mode 100644 index 6a87ba942e3..00000000000 --- a/tests/components/automate/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Automate Pulse Hub v2 integration.""" diff --git a/tests/components/automate/test_config_flow.py b/tests/components/automate/test_config_flow.py deleted file mode 100644 index fea2fa995cd..00000000000 --- a/tests/components/automate/test_config_flow.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Test the Automate Pulse Hub v2 config flow.""" -from unittest.mock import Mock, patch - -from homeassistant import config_entries, setup -from homeassistant.components.automate.const import DOMAIN - - -def mock_hub(testfunc=None): - """Mock aiopulse2.Hub.""" - Hub = Mock() - Hub.name = "Name of the device" - - async def hub_test(): - if testfunc: - testfunc() - - Hub.test = hub_test - - return Hub - - -async def test_form(hass): - """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] is None - - with patch("aiopulse2.Hub", return_value=mock_hub()), patch( - "homeassistant.components.automate.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - }, - ) - - assert result2["type"] == "create_entry" - assert result2["title"] == "Name of the device" - assert result2["data"] == { - "host": "1.1.1.1", - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_cannot_connect(hass): - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - def raise_error(): - raise ConnectionRefusedError - - with patch("aiopulse2.Hub", return_value=mock_hub(raise_error)): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - }, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} From 204426009fef1388d1e8cc7d77a8b5f073ab6153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 29 Jul 2021 21:03:13 +0200 Subject: [PATCH 027/903] Clean up Surpetcare (#53699) --- .../components/surepetcare/binary_sensor.py | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 5a9ae733db0..8f2b77c3c7a 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass, config, async_add_entities, discovery_info=None ) -> None: - """Set up Sure PetCare Flaps sensors based on a config entry.""" + """Set up Sure PetCare Flaps binary sensors based on a config entry.""" if discovery_info is None: return @@ -42,8 +42,7 @@ async def async_setup_platform( EntityType.FELAQUA, ]: entities.append(DeviceConnectivity(surepy_entity.id, spc)) - - if surepy_entity.type == EntityType.PET: + elif surepy_entity.type == EntityType.PET: entities.append(Pet(surepy_entity.id, spc)) elif surepy_entity.type == EntityType.HUB: entities.append(Hub(surepy_entity.id, spc)) @@ -75,16 +74,10 @@ class SurePetcareBinarySensor(BinarySensorEntity): else: name = f"Unnamed {surepy_entity.type.name.capitalize()}" - self._name = f"{surepy_entity.type.name.capitalize()} {name.capitalize()}" - self._attr_device_class = device_class + self._attr_name = f"{surepy_entity.type.name.capitalize()} {name.capitalize()}" self._attr_unique_id = f"{surepy_entity.household_id}-{self._id}" - @property - def name(self) -> str: - """Return the name of the device if any.""" - return self._name - @abstractmethod @callback def _async_update(self) -> None: @@ -99,7 +92,7 @@ class SurePetcareBinarySensor(BinarySensorEntity): class Hub(SurePetcareBinarySensor): - """Sure Petcare Pet.""" + """Sure Petcare Hub.""" def __init__(self, _id: int, spc: SurePetcareAPI) -> None: """Initialize a Sure Petcare Hub.""" @@ -120,7 +113,7 @@ class Hub(SurePetcareBinarySensor): } else: self._attr_extra_state_attributes = None - _LOGGER.debug("%s -> state: %s", self._name, state) + _LOGGER.debug("%s -> state: %s", self.name, state) self.async_write_ha_state() @@ -147,12 +140,12 @@ class Pet(SurePetcareBinarySensor): } else: self._attr_extra_state_attributes = None - _LOGGER.debug("%s -> state: %s", self._name, state) + _LOGGER.debug("%s -> state: %s", self.name, state) self.async_write_ha_state() class DeviceConnectivity(SurePetcareBinarySensor): - """Sure Petcare Pet.""" + """Sure Petcare Device.""" def __init__( self, @@ -161,15 +154,11 @@ class DeviceConnectivity(SurePetcareBinarySensor): ) -> None: """Initialize a Sure Petcare Device.""" super().__init__(_id, spc, DEVICE_CLASS_CONNECTIVITY) + self._attr_name = f"{self.name}_connectivity" self._attr_unique_id = ( f"{self._spc.states[self._id].household_id}-{self._id}-connectivity" ) - @property - def name(self) -> str: - """Return the name of the device if any.""" - return f"{self._name}_connectivity" - @callback def _async_update(self) -> None: """Get the latest data and update the state.""" @@ -183,5 +172,5 @@ class DeviceConnectivity(SurePetcareBinarySensor): } else: self._attr_extra_state_attributes = None - _LOGGER.debug("%s -> state: %s", self._name, state) + _LOGGER.debug("%s -> state: %s", self.name, state) self.async_write_ha_state() From c6213b36ad0da3a885fdee0c0dd648804bab9f13 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 29 Jul 2021 21:08:53 +0200 Subject: [PATCH 028/903] Only disable a device if all associated config entries are disabled (#53681) --- homeassistant/helpers/device_registry.py | 11 +++++++ tests/helpers/test_device_registry.py | 42 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9f09bbbf642..b22b1740a4f 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -670,6 +670,7 @@ def async_config_entry_disabled_by_changed( the config entry is disabled, enable devices in the registry that are associated with a config entry when the config entry is enabled and the devices are marked DISABLED_CONFIG_ENTRY. + Only disable a device if all associated config entries are disabled. """ devices = async_entries_for_config_entry(registry, config_entry.entry_id) @@ -681,10 +682,20 @@ def async_config_entry_disabled_by_changed( registry.async_update_device(device.id, disabled_by=None) return + enabled_config_entries = { + entry.entry_id + for entry in registry.hass.config_entries.async_entries() + if not entry.disabled_by + } + for device in devices: if device.disabled: # Device already disabled, do not overwrite continue + if len(device.config_entries) > 1 and device.config_entries.intersection( + enabled_config_entries + ): + continue registry.async_update_device(device.id, disabled_by=DISABLED_CONFIG_ENTRY) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 037e1aec8c2..557647c5c7f 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1253,3 +1253,45 @@ async def test_disable_config_entry_disables_devices(hass, registry): entry2 = registry.async_get(entry2.id) assert entry2.disabled assert entry2.disabled_by == device_registry.DISABLED_USER + + +async def test_only_disable_device_if_all_config_entries_are_disabled(hass, registry): + """Test that we only disable device if all related config entries are disabled.""" + config_entry1 = MockConfigEntry(domain="light") + config_entry1.add_to_hass(hass) + config_entry2 = MockConfigEntry(domain="light") + config_entry2.add_to_hass(hass) + + registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry1 = registry.async_get_or_create( + config_entry_id=config_entry2.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert len(entry1.config_entries) == 2 + assert not entry1.disabled + + await hass.config_entries.async_set_disabled_by( + config_entry1.entry_id, config_entries.DISABLED_USER + ) + await hass.async_block_till_done() + + entry1 = registry.async_get(entry1.id) + assert not entry1.disabled + + await hass.config_entries.async_set_disabled_by( + config_entry2.entry_id, config_entries.DISABLED_USER + ) + await hass.async_block_till_done() + + entry1 = registry.async_get(entry1.id) + assert entry1.disabled + assert entry1.disabled_by == device_registry.DISABLED_CONFIG_ENTRY + + await hass.config_entries.async_set_disabled_by(config_entry1.entry_id, None) + await hass.async_block_till_done() + + entry1 = registry.async_get(entry1.id) + assert not entry1.disabled From fb3b8b66862cfc4462d1cd084e85f5aa666deecc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 29 Jul 2021 21:09:14 +0200 Subject: [PATCH 029/903] Fix `last_reset_topic` config replaces `state_topic` for sensor platform (#53677) --- homeassistant/components/mqtt/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 0c234fbbbea..239af7b450a 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -196,7 +196,7 @@ class MqttSensor(MqttEntity, SensorEntity): self.async_write_ha_state() if CONF_LAST_RESET_TOPIC in self._config: - topics["state_topic"] = { + topics["last_reset_topic"] = { "topic": self._config[CONF_LAST_RESET_TOPIC], "msg_callback": last_reset_message_received, "qos": self._config[CONF_QOS], From e9a00ad4ce37080c42eb436e2f9a5f88be125521 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 29 Jul 2021 21:10:53 +0200 Subject: [PATCH 030/903] Add last reset to Shelly's energy entities (#53710) --- homeassistant/components/shelly/const.py | 3 +++ homeassistant/components/shelly/entity.py | 4 ++-- homeassistant/components/shelly/sensor.py | 21 +++++++++++++++++---- homeassistant/components/shelly/utils.py | 2 +- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 2c401829c30..49e33dfd5e1 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -92,3 +92,6 @@ KELVIN_MIN_VALUE_WHITE: Final = 2700 KELVIN_MIN_VALUE_COLOR: Final = 3000 UPTIME_DEVIATION: Final = 5 + +LAST_RESET_UPTIME: Final = "uptime" +LAST_RESET_NEVER: Final = "never" diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index c8b23f71bd7..a1ce2e671d1 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from datetime import datetime import logging from typing import Any, Callable, Final, cast @@ -180,7 +179,7 @@ class BlockAttributeDescription: # Callable (settings, block), return true if entity should be removed removal_condition: Callable[[dict, aioshelly.Block], bool] | None = None extra_state_attributes: Callable[[aioshelly.Block], dict | None] | None = None - last_reset: datetime | None = None + last_reset: str | None = None @dataclass @@ -286,6 +285,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): self._unit: None | str | Callable[[dict], str] = unit self._unique_id: str = f"{super().unique_id}-{self.attribute}" self._name = get_entity_name(wrapper.device, block, self.description.name) + self._last_value: str | None = None @property def unique_id(self) -> str: diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 7c2ffdbc470..56e4f63bc75 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt -from .const import SHAIR_MAX_WORK_HOURS +from .const import LAST_RESET_NEVER, LAST_RESET_UPTIME, SHAIR_MAX_WORK_HOURS from .entity import ( BlockAttributeDescription, RestAttributeDescription, @@ -114,6 +114,7 @@ SENSORS: Final = { value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, + last_reset=LAST_RESET_UPTIME, ), ("emeter", "energy"): BlockAttributeDescription( name="Energy", @@ -121,7 +122,7 @@ SENSORS: Final = { value=lambda value: round(value / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + last_reset=LAST_RESET_NEVER, ), ("emeter", "energyReturned"): BlockAttributeDescription( name="Energy Returned", @@ -129,7 +130,7 @@ SENSORS: Final = { value=lambda value: round(value / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + last_reset=LAST_RESET_NEVER, ), ("light", "energy"): BlockAttributeDescription( name="Energy", @@ -138,6 +139,7 @@ SENSORS: Final = { device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, default_enabled=False, + last_reset=LAST_RESET_UPTIME, ), ("relay", "energy"): BlockAttributeDescription( name="Energy", @@ -145,6 +147,7 @@ SENSORS: Final = { value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, + last_reset=LAST_RESET_UPTIME, ), ("roller", "rollerEnergy"): BlockAttributeDescription( name="Energy", @@ -152,6 +155,7 @@ SENSORS: Final = { value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, + last_reset=LAST_RESET_UPTIME, ), ("sensor", "concentration"): BlockAttributeDescription( name="Gas Concentration", @@ -264,7 +268,16 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): @property def last_reset(self) -> datetime | None: """State class of sensor.""" - return self.description.last_reset + if self.description.last_reset == LAST_RESET_UPTIME: + self._last_value = get_device_uptime( + self.wrapper.device.status, self._last_value + ) + return dt.parse_datetime(self._last_value) + + if self.description.last_reset == LAST_RESET_NEVER: + return dt.utc_from_timestamp(0) + + return None @property def unit_of_measurement(self) -> str | None: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d8ce5ae9e45..d1e2947d5ac 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -136,7 +136,7 @@ def is_momentary_input(settings: dict[str, Any], block: aioshelly.Block) -> bool return button_type in ["momentary", "momentary_on_release"] -def get_device_uptime(status: dict[str, Any], last_uptime: str) -> str: +def get_device_uptime(status: dict[str, Any], last_uptime: str | None) -> str: """Return device uptime string, tolerate up to 5 seconds deviation.""" delta_uptime = utcnow() - timedelta(seconds=status["uptime"]) From 120a0bead08e162847770dcca97b6653e8b8686d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 29 Jul 2021 21:25:18 +0200 Subject: [PATCH 031/903] Add state class support to DSMR Reader (#53715) --- .../components/dsmr_reader/definitions.py | 998 ++++++++++-------- .../components/dsmr_reader/sensor.py | 74 +- 2 files changed, 553 insertions(+), 519 deletions(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 51aaca24c02..1a46f86132b 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -1,5 +1,13 @@ """Definitions for DSMR Reader sensors added to MQTT.""" +from __future__ import annotations +from dataclasses import dataclass +from typing import Callable + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) from homeassistant.const import ( CURRENCY_EURO, DEVICE_CLASS_CURRENT, @@ -13,6 +21,7 @@ from homeassistant.const import ( POWER_KILO_WATT, VOLUME_CUBIC_METERS, ) +from homeassistant.util import dt as dt_util def dsmr_transform(value): @@ -29,462 +38,533 @@ def tariff_transform(value): return "high" -DEFINITIONS = { - "dsmr/reading/electricity_delivered_1": { - "name": "Low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_returned_1": { - "name": "Low tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_delivered_2": { - "name": "High tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_returned_2": { - "name": "High tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_currently_delivered": { - "name": "Current power usage", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/electricity_currently_returned": { - "name": "Current power return", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_delivered_l1": { - "name": "Current power usage L1", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_delivered_l2": { - "name": "Current power usage L2", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_delivered_l3": { - "name": "Current power usage L3", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_returned_l1": { - "name": "Current power return L1", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_returned_l2": { - "name": "Current power return L2", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_returned_l3": { - "name": "Current power return L3", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/extra_device_delivered": { - "name": "Gas meter usage", - "enable_default": True, - "icon": "mdi:fire", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/reading/phase_voltage_l1": { - "name": "Current voltage L1", - "enable_default": True, - "device_class": DEVICE_CLASS_VOLTAGE, - "unit": ELECTRIC_POTENTIAL_VOLT, - }, - "dsmr/reading/phase_voltage_l2": { - "name": "Current voltage L2", - "enable_default": True, - "device_class": DEVICE_CLASS_VOLTAGE, - "unit": ELECTRIC_POTENTIAL_VOLT, - }, - "dsmr/reading/phase_voltage_l3": { - "name": "Current voltage L3", - "enable_default": True, - "device_class": DEVICE_CLASS_VOLTAGE, - "unit": ELECTRIC_POTENTIAL_VOLT, - }, - "dsmr/reading/phase_power_current_l1": { - "name": "Phase power current L1", - "enable_default": True, - "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRIC_CURRENT_AMPERE, - }, - "dsmr/reading/phase_power_current_l2": { - "name": "Phase power current L2", - "enable_default": True, - "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRIC_CURRENT_AMPERE, - }, - "dsmr/reading/phase_power_current_l3": { - "name": "Phase power current L3", - "enable_default": True, - "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRIC_CURRENT_AMPERE, - }, - "dsmr/reading/timestamp": { - "name": "Telegram timestamp", - "enable_default": False, - "device_class": DEVICE_CLASS_TIMESTAMP, - }, - "dsmr/consumption/gas/delivered": { - "name": "Gas usage", - "enable_default": True, - "icon": "mdi:fire", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/consumption/gas/currently_delivered": { - "name": "Current gas usage", - "enable_default": True, - "icon": "mdi:fire", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/consumption/gas/read_at": { - "name": "Gas meter read", - "enable_default": True, - "device_class": DEVICE_CLASS_TIMESTAMP, - }, - "dsmr/day-consumption/electricity1": { - "name": "Low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity2": { - "name": "High tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity1_returned": { - "name": "Low tariff return", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity2_returned": { - "name": "High tariff return", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity_merged": { - "name": "Power usage total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity_returned_merged": { - "name": "Power return total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity1_cost": { - "name": "Low tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/electricity2_cost": { - "name": "High tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/electricity_cost_merged": { - "name": "Power total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/gas": { - "name": "Gas usage", - "enable_default": True, - "icon": "mdi:counter", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/day-consumption/gas_cost": { - "name": "Gas cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/total_cost": { - "name": "Total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_delivered_1": { - "name": "Low tariff delivered price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_delivered_2": { - "name": "High tariff delivered price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_returned_1": { - "name": "Low tariff returned price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_returned_2": { - "name": "High tariff returned price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_gas": { - "name": "Gas price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/fixed_cost": { - "name": "Current day fixed cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/meter-stats/dsmr_version": { - "name": "DSMR version", - "enable_default": True, - "icon": "mdi:alert-circle", - "transform": dsmr_transform, - }, - "dsmr/meter-stats/electricity_tariff": { - "name": "Electricity tariff", - "enable_default": True, - "icon": "mdi:flash", - "transform": tariff_transform, - }, - "dsmr/meter-stats/power_failure_count": { - "name": "Power failure count", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/long_power_failure_count": { - "name": "Long power failure count", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_sag_count_l1": { - "name": "Voltage sag L1", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_sag_count_l2": { - "name": "Voltage sag L2", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_sag_count_l3": { - "name": "Voltage sag L3", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_swell_count_l1": { - "name": "Voltage swell L1", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_swell_count_l2": { - "name": "Voltage swell L2", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_swell_count_l3": { - "name": "Voltage swell L3", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/rejected_telegrams": { - "name": "Rejected telegrams", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/current-month/electricity1": { - "name": "Current month low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity2": { - "name": "Current month high tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity1_returned": { - "name": "Current month low tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity2_returned": { - "name": "Current month high tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity_merged": { - "name": "Current month power usage total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity_returned_merged": { - "name": "Current month power return total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity1_cost": { - "name": "Current month low tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/electricity2_cost": { - "name": "Current month high tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/electricity_cost_merged": { - "name": "Current month power total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/gas": { - "name": "Current month gas usage", - "enable_default": True, - "icon": "mdi:counter", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/current-month/gas_cost": { - "name": "Current month gas cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/fixed_cost": { - "name": "Current month fixed cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/total_cost": { - "name": "Current month total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/electricity1": { - "name": "Current year low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity2": { - "name": "Current year high tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity1_returned": { - "name": "Current year low tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity2_returned": { - "name": "Current year high tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity_merged": { - "name": "Current year power usage total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity_returned_merged": { - "name": "Current year power returned total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity1_cost": { - "name": "Current year low tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/electricity2_cost": { - "name": "Current year high tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/electricity_cost_merged": { - "name": "Current year power total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/gas": { - "name": "Current year gas usage", - "enable_default": True, - "icon": "mdi:counter", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/current-year/gas_cost": { - "name": "Current year gas cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/fixed_cost": { - "name": "Current year fixed cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/total_cost": { - "name": "Current year total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, -} +@dataclass +class DSMRReaderSensorEntityDescription(SensorEntityDescription): + """Sensor entity description for DSMR Reader.""" + + state: Callable | None = None + + +SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_delivered_1", + name="Low tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_returned_1", + name="Low tariff returned", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_delivered_2", + name="High tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_returned_2", + name="High tariff returned", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_currently_delivered", + name="Current power usage", + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_currently_returned", + name="Current power return", + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_delivered_l1", + name="Current power usage L1", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_delivered_l2", + name="Current power usage L2", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_delivered_l3", + name="Current power usage L3", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_returned_l1", + name="Current power return L1", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_returned_l2", + name="Current power return L2", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_returned_l3", + name="Current power return L3", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/extra_device_delivered", + name="Gas meter usage", + entity_registry_enabled_default=False, + icon="mdi:fire", + unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_voltage_l1", + name="Current voltage L1", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_voltage_l2", + name="Current voltage L2", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_voltage_l3", + name="Current voltage L3", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_power_current_l1", + name="Phase power current L1", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_power_current_l2", + name="Phase power current L2", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_power_current_l3", + name="Phase power current L3", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/timestamp", + name="Telegram timestamp", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/consumption/gas/delivered", + name="Gas usage", + icon="mdi:fire", + unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/consumption/gas/currently_delivered", + name="Current gas usage", + icon="mdi:fire", + unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/consumption/gas/read_at", + name="Gas meter read", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity1", + name="Low tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity2", + name="High tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity1_returned", + name="Low tariff return", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity2_returned", + name="High tariff return", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity_merged", + name="Power usage total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity_returned_merged", + name="Power return total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity1_cost", + name="Low tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity2_cost", + name="High tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity_cost_merged", + name="Power total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/gas", + name="Gas usage", + icon="mdi:counter", + unit_of_measurement=VOLUME_CUBIC_METERS, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/gas_cost", + name="Gas cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/total_cost", + name="Total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", + name="Low tariff delivered price", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", + name="High tariff delivered price", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", + name="Low tariff returned price", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", + name="High tariff returned price", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_gas", + name="Gas price", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/fixed_cost", + name="Current day fixed cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/dsmr_version", + name="DSMR version", + entity_registry_enabled_default=False, + icon="mdi:alert-circle", + state=dsmr_transform, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/electricity_tariff", + name="Electricity tariff", + icon="mdi:flash", + state=tariff_transform, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/power_failure_count", + name="Power failure count", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/long_power_failure_count", + name="Long power failure count", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_sag_count_l1", + name="Voltage sag L1", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_sag_count_l2", + name="Voltage sag L2", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_sag_count_l3", + name="Voltage sag L3", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_swell_count_l1", + name="Voltage swell L1", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_swell_count_l2", + name="Voltage swell L2", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_swell_count_l3", + name="Voltage swell L3", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/rejected_telegrams", + name="Rejected telegrams", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity1", + name="Current month low tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity2", + name="Current month high tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity1_returned", + name="Current month low tariff returned", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity2_returned", + name="Current month high tariff returned", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity_merged", + name="Current month power usage total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity_returned_merged", + name="Current month power return total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity1_cost", + name="Current month low tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity2_cost", + name="Current month high tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity_cost_merged", + name="Current month power total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/gas", + name="Current month gas usage", + icon="mdi:counter", + unit_of_measurement=VOLUME_CUBIC_METERS, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/gas_cost", + name="Current month gas cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/fixed_cost", + name="Current month fixed cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/total_cost", + name="Current month total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity1", + name="Current year low tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity2", + name="Current year high tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity1_returned", + name="Current year low tariff returned", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity2_returned", + name="Current year high tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity_merged", + name="Current year power usage total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity_returned_merged", + name="Current year power returned total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity1_cost", + name="Current year low tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity2_cost", + name="Current year high tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity_cost_merged", + name="Current year power total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/gas", + name="Current year gas usage", + icon="mdi:counter", + unit_of_measurement=VOLUME_CUBIC_METERS, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/gas_cost", + name="Current year gas cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/fixed_cost", + name="Current year fixed cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/total_cost", + name="Current year total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), +) diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 0ee5932c1bb..39356db46b5 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -4,39 +4,27 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.core import callback from homeassistant.util import slugify -from .definitions import DEFINITIONS +from .definitions import SENSORS, DSMRReaderSensorEntityDescription DOMAIN = "dsmr_reader" async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up DSMR Reader sensors.""" - - sensors = [] - for topic in DEFINITIONS: - sensors.append(DSMRSensor(topic)) - - async_add_entities(sensors) + async_add_entities(DSMRSensor(description) for description in SENSORS) class DSMRSensor(SensorEntity): """Representation of a DSMR sensor that is updated via MQTT.""" - def __init__(self, topic): + entity_description: DSMRReaderSensorEntityDescription + + def __init__(self, description: DSMRReaderSensorEntityDescription) -> None: """Initialize the sensor.""" + self.entity_description = description - self._definition = DEFINITIONS[topic] - - self._entity_id = slugify(topic.replace("/", "_")) - self._topic = topic - - self._name = self._definition.get("name", topic.split("/")[-1]) - self._device_class = self._definition.get("device_class") - self._enable_default = self._definition.get("enable_default") - self._unit_of_measurement = self._definition.get("unit") - self._icon = self._definition.get("icon") - self._transform = self._definition.get("transform") - self._state = None + slug = slugify(description.key.replace("/", "_")) + self.entity_id = f"sensor.{slug}" async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -44,47 +32,13 @@ class DSMRSensor(SensorEntity): @callback def message_received(message): """Handle new MQTT messages.""" - - if self._transform is not None: - self._state = self._transform(message.payload) + if self.entity_description.state is not None: + self._attr_state = self.entity_description.state(message.payload) else: - self._state = message.payload + self._attr_state = message.payload self.async_write_ha_state() - await mqtt.async_subscribe(self.hass, self._topic, message_received, 1) - - @property - def name(self): - """Return the name of the sensor supplied in constructor.""" - return self._name - - @property - def entity_id(self): - """Return the entity ID for this sensor.""" - return f"sensor.{self._entity_id}" - - @property - def state(self): - """Return the current state of the entity.""" - return self._state - - @property - def device_class(self): - """Return the device_class of this sensor.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the unit_of_measurement of this sensor.""" - return self._unit_of_measurement - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enable_default - - @property - def icon(self): - """Return the icon of this sensor.""" - return self._icon + await mqtt.async_subscribe( + self.hass, self.entity_description.key, message_received, 1 + ) From 3c0e4b1fd94178ba245ae1fdb0837d78e055288e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 29 Jul 2021 21:25:43 +0200 Subject: [PATCH 032/903] Fix zwave_js meter sensor state class (#53716) --- homeassistant/components/zwave_js/sensor.py | 3 +-- tests/components/zwave_js/test_sensor.py | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 39aa2f30604..f4b303aa1af 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -229,8 +229,6 @@ class ZWaveNumericSensor(ZwaveSensorBase): class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): """Representation of a Z-Wave Meter CC sensor.""" - _attr_state_class = STATE_CLASS_MEASUREMENT - def __init__( self, config_entry: ConfigEntry, @@ -241,6 +239,7 @@ class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): super().__init__(config_entry, client, info) # Entity class attributes + self._attr_state_class = STATE_CLASS_MEASUREMENT self._attr_last_reset = dt.utc_from_timestamp(0) self._attr_device_class = DEVICE_CLASS_POWER if self.info.primary_value.metadata.unit == "kWh": diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 29fed2b5c55..ebe406fb951 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch from zwave_js_server.event import Event -from homeassistant.components.sensor import ATTR_LAST_RESET +from homeassistant.components.sensor import ATTR_LAST_RESET, STATE_CLASS_MEASUREMENT from homeassistant.components.zwave_js.const import ( ATTR_METER_TYPE, ATTR_VALUE, @@ -62,6 +62,7 @@ async def test_energy_sensors(hass, hank_binary_switch, integration): assert state.state == "0.0" assert state.attributes["unit_of_measurement"] == POWER_WATT assert state.attributes["device_class"] == DEVICE_CLASS_POWER + assert state.attributes["state_class"] == STATE_CLASS_MEASUREMENT state = hass.states.get(ENERGY_SENSOR) @@ -69,6 +70,7 @@ async def test_energy_sensors(hass, hank_binary_switch, integration): assert state.state == "0.16" assert state.attributes["unit_of_measurement"] == ENERGY_KILO_WATT_HOUR assert state.attributes["device_class"] == DEVICE_CLASS_ENERGY + assert state.attributes["state_class"] == STATE_CLASS_MEASUREMENT async def test_disabled_notification_sensor(hass, multisensor_6, integration): From 7c98fc94d4c82e96e751b41aa0cad8997e2e8edf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 29 Jul 2021 21:34:22 +0200 Subject: [PATCH 033/903] Fix SolarEdge statistics; missing device_class (#53720) --- homeassistant/components/solaredge/const.py | 57 ++++++++++++-------- homeassistant/components/solaredge/models.py | 16 ++---- homeassistant/components/solaredge/sensor.py | 45 ++++++++-------- 3 files changed, 59 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 81d9bc5aebe..872781bf19c 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -3,10 +3,16 @@ from datetime import timedelta import logging from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT -from homeassistant.const import ENERGY_WATT_HOUR, PERCENTAGE, POWER_WATT +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + ENERGY_WATT_HOUR, + PERCENTAGE, + POWER_WATT, +) from homeassistant.util import dt as dt_util -from .models import SolarEdgeSensor +from .models import SolarEdgeSensorEntityDescription DOMAIN = "solaredge" @@ -29,7 +35,7 @@ SCAN_INTERVAL = timedelta(minutes=15) # Supported overview sensors SENSOR_TYPES = [ - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="lifetime_energy", json_key="lifeTimeData", name="Lifetime energy", @@ -37,138 +43,143 @@ SENSOR_TYPES = [ last_reset=dt_util.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="energy_this_year", json_key="lastYearData", name="Energy this year", entity_registry_enabled_default=False, icon="mdi:solar-power", unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="energy_this_month", json_key="lastMonthData", name="Energy this month", entity_registry_enabled_default=False, icon="mdi:solar-power", unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="energy_today", json_key="lastDayData", name="Energy today", entity_registry_enabled_default=False, icon="mdi:solar-power", unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="current_power", json_key="currentPower", name="Current Power", icon="mdi:solar-power", state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="site_details", name="Site details", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="meters", json_key="meters", name="Meters", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="sensors", json_key="sensors", name="Sensors", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="gateways", json_key="gateways", name="Gateways", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="batteries", json_key="batteries", name="Batteries", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="inverters", json_key="inverters", name="Inverters", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="power_consumption", json_key="LOAD", name="Power Consumption", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="solar_power", json_key="PV", name="Solar Power", entity_registry_enabled_default=False, icon="mdi:solar-power", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="grid_power", json_key="GRID", name="Grid Power", entity_registry_enabled_default=False, icon="mdi:power-plug", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="storage_power", json_key="STORAGE", name="Storage Power", entity_registry_enabled_default=False, icon="mdi:car-battery", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="purchased_power", json_key="Purchased", name="Imported Power", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="production_power", json_key="Production", name="Production Power", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="consumption_power", json_key="Consumption", name="Consumption Power", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="selfconsumption_power", json_key="SelfConsumption", name="SelfConsumption Power", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="feedin_power", json_key="FeedIn", name="Exported Power", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="storage_level", json_key="STORAGE", name="Storage Level", diff --git a/homeassistant/components/solaredge/models.py b/homeassistant/components/solaredge/models.py index f91db9ee9ff..ce24d854aac 100644 --- a/homeassistant/components/solaredge/models.py +++ b/homeassistant/components/solaredge/models.py @@ -2,20 +2,12 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime + +from homeassistant.components.sensor import SensorEntityDescription @dataclass -class SolarEdgeSensor: - """Represents an SolarEdge Sensor.""" - - key: str - name: str +class SolarEdgeSensorEntityDescription(SensorEntityDescription): + """Sensor entity description for SolarEdge.""" json_key: str | None = None - device_class: str | None = None - entity_registry_enabled_default: bool = True - icon: str | None = None - last_reset: datetime | None = None - state_class: str | None = None - unit_of_measurement: str | None = None diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 23cfa116599..85e01a2d7ee 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -21,7 +21,7 @@ from .coordinator import ( SolarEdgeOverviewDataService, SolarEdgePowerFlowDataService, ) -from .models import SolarEdgeSensor +from .models import SolarEdgeSensorEntityDescription async def async_setup_entry( @@ -68,7 +68,8 @@ class SolarEdgeSensorFactory: self.services: dict[ str, tuple[ - type[SolarEdgeSensor | SolarEdgeOverviewSensor], SolarEdgeDataService + type[SolarEdgeSensorEntity | SolarEdgeOverviewSensor], + SolarEdgeDataService, ], ] = {"site_details": (SolarEdgeDetailsSensor, details)} @@ -99,7 +100,9 @@ class SolarEdgeSensorFactory: ): self.services[key] = (SolarEdgeEnergyDetailsSensor, energy) - def create_sensor(self, sensor_type: SolarEdgeSensor) -> SolarEdgeSensor: + def create_sensor( + self, sensor_type: SolarEdgeSensorEntityDescription + ) -> SolarEdgeSensorEntityDescription: """Create and return a sensor based on the sensor_key.""" sensor_class, service = self.services[sensor_type.key] @@ -109,27 +112,21 @@ class SolarEdgeSensorFactory: class SolarEdgeSensorEntity(CoordinatorEntity, SensorEntity): """Abstract class for a solaredge sensor.""" + entity_description: SolarEdgeSensorEntityDescription + def __init__( self, platform_name: str, - sensor_type: SolarEdgeSensor, + description: SolarEdgeSensorEntityDescription, data_service: SolarEdgeDataService, ) -> None: """Initialize the sensor.""" super().__init__(data_service.coordinator) self.platform_name = platform_name - self.sensor_type = sensor_type + self.entity_description = description self.data_service = data_service - self._attr_device_class = sensor_type.device_class - self._attr_entity_registry_enabled_default = ( - sensor_type.entity_registry_enabled_default - ) - self._attr_icon = sensor_type.icon - self._attr_last_reset = sensor_type.last_reset - self._attr_name = f"{platform_name} ({sensor_type.name})" - self._attr_state_class = sensor_type.state_class - self._attr_unit_of_measurement = sensor_type.unit_of_measurement + self._attr_name = f"{platform_name} ({description.name})" class SolarEdgeOverviewSensor(SolarEdgeSensorEntity): @@ -138,7 +135,7 @@ class SolarEdgeOverviewSensor(SolarEdgeSensorEntity): @property def state(self) -> str | None: """Return the state of the sensor.""" - return self.data_service.data.get(self.sensor_type.json_key) + return self.data_service.data.get(self.entity_description.json_key) class SolarEdgeDetailsSensor(SolarEdgeSensorEntity): @@ -161,12 +158,12 @@ class SolarEdgeInventorySensor(SolarEdgeSensorEntity): @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - return self.data_service.attributes.get(self.sensor_type.json_key) + return self.data_service.attributes.get(self.entity_description.json_key) @property def state(self) -> str | None: """Return the state of the sensor.""" - return self.data_service.data.get(self.sensor_type.json_key) + return self.data_service.data.get(self.entity_description.json_key) class SolarEdgeEnergyDetailsSensor(SolarEdgeSensorEntity): @@ -181,12 +178,12 @@ class SolarEdgeEnergyDetailsSensor(SolarEdgeSensorEntity): @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - return self.data_service.attributes.get(self.sensor_type.json_key) + return self.data_service.attributes.get(self.entity_description.json_key) @property def state(self) -> str | None: """Return the state of the sensor.""" - return self.data_service.data.get(self.sensor_type.json_key) + return self.data_service.data.get(self.entity_description.json_key) class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): @@ -197,23 +194,23 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): def __init__( self, platform_name: str, - sensor_type: SolarEdgeSensor, + description: SolarEdgeSensorEntityDescription, data_service: SolarEdgeDataService, ) -> None: """Initialize the power flow sensor.""" - super().__init__(platform_name, sensor_type, data_service) + super().__init__(platform_name, description, data_service) self._attr_unit_of_measurement = data_service.unit @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - return self.data_service.attributes.get(self.sensor_type.json_key) + return self.data_service.attributes.get(self.entity_description.json_key) @property def state(self) -> str | None: """Return the state of the sensor.""" - return self.data_service.data.get(self.sensor_type.json_key) + return self.data_service.data.get(self.entity_description.json_key) class SolarEdgeStorageLevelSensor(SolarEdgeSensorEntity): @@ -224,7 +221,7 @@ class SolarEdgeStorageLevelSensor(SolarEdgeSensorEntity): @property def state(self) -> str | None: """Return the state of the sensor.""" - attr = self.data_service.attributes.get(self.sensor_type.json_key) + attr = self.data_service.attributes.get(self.entity_description.json_key) if attr and "soc" in attr: return attr["soc"] return None From 2d83ad321115645a2103d577c3920df0c6afec4d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Jul 2021 12:34:32 -0700 Subject: [PATCH 034/903] Bump frontend to 20210729.0 (#53717) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index cf835396fba..ac791977038 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210728.0" + "home-assistant-frontend==20210729.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0556c6e5452..e1acf1dba15 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210728.0 +home-assistant-frontend==20210729.0 httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 96d5c1c3360..b7f8ec34eda 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -782,7 +782,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210728.0 +home-assistant-frontend==20210729.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5f527a05e6..e8b1a136048 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -449,7 +449,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210728.0 +home-assistant-frontend==20210729.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 390101720d174a6b6aada1c1bb8890e0f8f9a123 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 29 Jul 2021 22:55:26 +0200 Subject: [PATCH 035/903] Fix DSMR reconnecting loop without timeout (#53722) --- homeassistant/components/dsmr/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index d5674b34520..5afc229a727 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -165,6 +165,9 @@ async def async_setup_entry( LOGGER.exception("Error connecting to DSMR") transport = None protocol = None + + # throttle reconnect attempts + await asyncio.sleep(entry.data[CONF_RECONNECT_INTERVAL]) except CancelledError: if stop_listener: stop_listener() # pylint: disable=not-callable From 6b7a4d2d3c8d10cc3cdd27b07139a93932eadc89 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Jul 2021 14:26:05 -0700 Subject: [PATCH 036/903] Revert "Allow uploading large snapshots (#53528)" (#53729) This reverts commit cdce14d63db209acedb9888e726b813a069bf720. --- homeassistant/components/hassio/http.py | 54 ++++++++++++++++++------- tests/components/hassio/test_http.py | 49 ++++++---------------- 2 files changed, 53 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 73e5549be9a..302cc00bb9f 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -1,15 +1,16 @@ """HTTP Support for Hass.io.""" from __future__ import annotations +import asyncio import logging import os import re import aiohttp from aiohttp import web -from aiohttp.client import ClientError, ClientTimeout -from aiohttp.hdrs import CONTENT_TYPE +from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE from aiohttp.web_exceptions import HTTPBadGateway +import async_timeout from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.onboarding import async_is_onboarded @@ -19,6 +20,8 @@ from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID, X_HASSIO _LOGGER = logging.getLogger(__name__) +MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 + NO_TIMEOUT = re.compile( r"^(?:" r"|homeassistant/update" @@ -72,28 +75,48 @@ class HassIOView(HomeAssistantView): async def _command_proxy( self, path: str, request: web.Request - ) -> web.StreamResponse: + ) -> web.Response | web.StreamResponse: """Return a client request with proxy origin for Hass.io supervisor. This method is a coroutine. """ + read_timeout = _get_timeout(path) + client_timeout = 10 + data = None headers = _init_header(request) if path in ("snapshots/new/upload", "backups/new/upload"): # We need to reuse the full content type that includes the boundary headers[ "Content-Type" ] = request._stored_content_type # pylint: disable=protected-access + + # Backups are big, so we need to adjust the allowed size + request._client_max_size = ( # pylint: disable=protected-access + MAX_UPLOAD_SIZE + ) + client_timeout = 300 + try: - # Stream the request to the supervisor - client = await self._websession.request( - method=request.method, - url=f"http://{self._host}/{path}", + with async_timeout.timeout(client_timeout): + data = await request.read() + + method = getattr(self._websession, request.method.lower()) + client = await method( + f"http://{self._host}/{path}", + data=data, headers=headers, - data=request.content, - timeout=_get_timeout(path), + timeout=read_timeout, ) - # Stream the supervisor response back + # Simple request + if int(client.headers.get(CONTENT_LENGTH, 0)) < 4194000: + # Return Response + body = await client.read() + return web.Response( + content_type=client.content_type, status=client.status, body=body + ) + + # Stream response response = web.StreamResponse(status=client.status, headers=client.headers) response.content_type = client.content_type @@ -103,9 +126,12 @@ class HassIOView(HomeAssistantView): return response - except ClientError as err: + except aiohttp.ClientError as err: _LOGGER.error("Client error on api %s request %s", path, err) + except asyncio.TimeoutError: + _LOGGER.error("Client timeout error on API request %s", path) + raise HTTPBadGateway() @@ -125,11 +151,11 @@ def _init_header(request: web.Request) -> dict[str, str]: return headers -def _get_timeout(path: str) -> ClientTimeout: +def _get_timeout(path: str) -> int: """Return timeout for a URL path.""" if NO_TIMEOUT.match(path): - return ClientTimeout(connect=10) - return ClientTimeout(connect=10, total=300) + return 0 + return 300 def _need_auth(hass, path: str) -> bool: diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index 881d3cc26ed..fc4bb3e6a0d 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -1,13 +1,11 @@ """The tests for the hassio component.""" -from aiohttp.client import ClientError -from aiohttp.streams import StreamReader -from aiohttp.test_utils import TestClient +import asyncio +from unittest.mock import patch + import pytest from homeassistant.components.hassio.http import _need_auth -from tests.test_util.aiohttp import AiohttpClientMocker - async def test_forward_request(hassio_client, aioclient_mock): """Test fetching normal path.""" @@ -108,6 +106,16 @@ async def test_forward_log_request(hassio_client, aioclient_mock): assert len(aioclient_mock.mock_calls) == 1 +async def test_bad_gateway_when_cannot_find_supervisor(hassio_client): + """Test we get a bad gateway error if we can't find supervisor.""" + with patch( + "homeassistant.components.hassio.http.async_timeout.timeout", + side_effect=asyncio.TimeoutError, + ): + resp = await hassio_client.get("/api/hassio/addons/test/info") + assert resp.status == 502 + + async def test_forwarding_user_info(hassio_client, hass_admin_user, aioclient_mock): """Test that we forward user info correctly.""" aioclient_mock.get("http://127.0.0.1/hello") @@ -163,37 +171,6 @@ async def test_backup_download_headers(hassio_client, aioclient_mock): assert resp.headers["Content-Disposition"] == content_disposition -async def test_supervisor_client_error( - hassio_client: TestClient, aioclient_mock: AiohttpClientMocker -): - """Test any client error from the supervisor returns a 502.""" - # Create a request that throws a ClientError - async def raise_client_error(*args): - raise ClientError() - - aioclient_mock.get( - "http://127.0.0.1/test/raise/error", - side_effect=raise_client_error, - ) - - # Verify it returns bad gateway - resp = await hassio_client.get("/api/hassio/test/raise/error") - assert resp.status == 502 - assert len(aioclient_mock.mock_calls) == 1 - - -async def test_streamed_requests( - hassio_client: TestClient, aioclient_mock: AiohttpClientMocker -): - """Test requests get proxied to the supervisor as a stream.""" - aioclient_mock.get("http://127.0.0.1/test/stream") - await hassio_client.get("/api/hassio/test/stream", data="Test data") - assert len(aioclient_mock.mock_calls) == 1 - - # Verify the request body is passed as a StreamReader - assert isinstance(aioclient_mock.mock_calls[0][2], StreamReader) - - def test_need_auth(hass): """Test if the requested path needs authentication.""" assert not _need_auth(hass, "addons/test/logo") From de7a885045a73ed858d1eaeae22a34388667b762 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 29 Jul 2021 23:26:39 +0200 Subject: [PATCH 037/903] Fix effect selector of light.turn_on service (#53726) --- homeassistant/components/light/services.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 778203a1c93..3b7df4e70c5 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -296,11 +296,7 @@ turn_on: name: Effect description: Light effect. selector: - select: - options: - - colorloop - - random - - white + text: turn_off: name: Turn off From 6d30e596ca84f84f40856606813147953127c935 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 29 Jul 2021 23:26:57 +0200 Subject: [PATCH 038/903] Fix zwave_js current and voltage meter sensor device class (#53723) --- homeassistant/components/zwave_js/sensor.py | 11 ++++++++--- tests/components/zwave_js/common.py | 4 +++- tests/components/zwave_js/test_sensor.py | 20 ++++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index f4b303aa1af..304a80f7940 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -22,8 +22,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + DEVICE_CLASS_CURRENT, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -142,8 +144,14 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): return DEVICE_CLASS_HUMIDITY if "temperature" in property_lower: return DEVICE_CLASS_TEMPERATURE + if self.info.primary_value.metadata.unit == "A": + return DEVICE_CLASS_CURRENT if self.info.primary_value.metadata.unit == "W": return DEVICE_CLASS_POWER + if self.info.primary_value.metadata.unit == "kWh": + return DEVICE_CLASS_ENERGY + if self.info.primary_value.metadata.unit == "V": + return DEVICE_CLASS_VOLTAGE if self.info.primary_value.metadata.unit == "Lux": return DEVICE_CLASS_ILLUMINANCE return None @@ -241,9 +249,6 @@ class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): # Entity class attributes self._attr_state_class = STATE_CLASS_MEASUREMENT self._attr_last_reset = dt.utc_from_timestamp(0) - self._attr_device_class = DEVICE_CLASS_POWER - if self.info.primary_value.metadata.unit == "kWh": - self._attr_device_class = DEVICE_CLASS_ENERGY @callback def async_update_last_reset( diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index c7100b22bd5..8c8a3f2e576 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -3,8 +3,10 @@ from datetime import datetime, timezone AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" HUMIDITY_SENSOR = "sensor.multisensor_6_humidity" -ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2" POWER_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" +ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2" +VOLTAGE_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_3" +CURRENT_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_4" SWITCH_ENTITY = "switch.smart_plug_with_two_usb_ports" LOW_BATTERY_BINARY_SENSOR = "binary_sensor.multisensor_6_low_battery_level" ENABLED_LEGACY_BINARY_SENSOR = "binary_sensor.z_wave_door_window_sensor_any" diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index ebe406fb951..9fa4152ad6b 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -12,10 +12,14 @@ from homeassistant.components.zwave_js.const import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_WATT, TEMP_CELSIUS, @@ -25,6 +29,7 @@ from homeassistant.helpers import entity_registry as er from .common import ( AIR_TEMPERATURE_SENSOR, BASIC_SENSOR, + CURRENT_SENSOR, DATETIME_LAST_RESET, DATETIME_ZERO, ENERGY_SENSOR, @@ -34,6 +39,7 @@ from .common import ( METER_SENSOR, NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, + VOLTAGE_SENSOR, ) @@ -72,6 +78,20 @@ async def test_energy_sensors(hass, hank_binary_switch, integration): assert state.attributes["device_class"] == DEVICE_CLASS_ENERGY assert state.attributes["state_class"] == STATE_CLASS_MEASUREMENT + state = hass.states.get(VOLTAGE_SENSOR) + + assert state + assert state.state == "122.96" + assert state.attributes["unit_of_measurement"] == ELECTRIC_POTENTIAL_VOLT + assert state.attributes["device_class"] == DEVICE_CLASS_VOLTAGE + + state = hass.states.get(CURRENT_SENSOR) + + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == ELECTRIC_CURRENT_AMPERE + assert state.attributes["device_class"] == DEVICE_CLASS_CURRENT + async def test_disabled_notification_sensor(hass, multisensor_6, integration): """Test sensor is created from Notification CC and is disabled.""" From edf0e0bd0813bc2e9f7b5842cd832e90151e6c23 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 30 Jul 2021 01:16:08 +0200 Subject: [PATCH 039/903] Add energy device class to deCONZ consumption sensors (#53731) --- homeassistant/components/deconz/sensor.py | 7 +++++++ tests/components/deconz/test_sensor.py | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index e0f12303946..9282f2d26cc 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, @@ -40,6 +41,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.util import dt as dt_util from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice @@ -51,6 +53,7 @@ ATTR_DAYLIGHT = "daylight" ATTR_EVENT_ID = "event_id" DEVICE_CLASS = { + Consumption: DEVICE_CLASS_ENERGY, Humidity: DEVICE_CLASS_HUMIDITY, LightLevel: DEVICE_CLASS_ILLUMINANCE, Power: DEVICE_CLASS_POWER, @@ -65,6 +68,7 @@ ICON = { } STATE_CLASS = { + Consumption: STATE_CLASS_MEASUREMENT, Humidity: STATE_CLASS_MEASUREMENT, Pressure: STATE_CLASS_MEASUREMENT, Temperature: STATE_CLASS_MEASUREMENT, @@ -158,6 +162,9 @@ class DeconzSensor(DeconzDevice, SensorEntity): self._attr_state_class = STATE_CLASS.get(type(self._device)) self._attr_unit_of_measurement = UNIT_OF_MEASUREMENT.get(type(self._device)) + if device.type in Consumption.ZHATYPE: + self._attr_last_reset = dt_util.utc_from_timestamp(0) + @callback def async_update_callback(self, force_update=False): """Update the sensor's state.""" diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index d9c4adf1388..7f8bce24d80 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, @@ -118,7 +119,7 @@ async def test_sensors(hass, aioclient_mock, mock_deconz_websocket): consumption_sensor = hass.states.get("sensor.consumption_sensor") assert consumption_sensor.state == "0.002" - assert ATTR_DEVICE_CLASS not in consumption_sensor.attributes + assert consumption_sensor.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY assert not hass.states.get("sensor.clip_light_level_sensor") From 5eba3e485b74ac3f7f9cfecc83e41c60b6e23a6c Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 29 Jul 2021 19:16:47 -0400 Subject: [PATCH 040/903] Bump up ZHA dependencies (#53732) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index c8df2ddeb30..fa117a3f1ff 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -10,7 +10,7 @@ "zha-quirks==0.0.59", "zigpy-cc==0.5.2", "zigpy-deconz==0.12.0", - "zigpy==0.36.0", + "zigpy==0.36.1", "zigpy-xbee==0.13.0", "zigpy-zigate==0.7.3", "zigpy-znp==0.5.2" diff --git a/requirements_all.txt b/requirements_all.txt index b7f8ec34eda..e68071a3578 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2465,7 +2465,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.2 # homeassistant.components.zha -zigpy==0.36.0 +zigpy==0.36.1 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8b1a136048..62038a7814e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1362,7 +1362,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.2 # homeassistant.components.zha -zigpy==0.36.0 +zigpy==0.36.1 # homeassistant.components.zwave_js zwave-js-server-python==0.28.0 From 0815eede4bbc3310d340b8640084161da04ea576 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 30 Jul 2021 01:20:03 +0200 Subject: [PATCH 041/903] Replace lists with tuples (2) (#53685) --- homeassistant/components/acmeda/cover.py | 2 +- homeassistant/components/airvisual/sensor.py | 4 ++-- .../components/androidtv/media_player.py | 2 +- homeassistant/components/atag/sensor.py | 8 +++---- homeassistant/components/auth/login_flow.py | 4 ++-- .../components/channels/media_player.py | 2 +- homeassistant/components/derivative/sensor.py | 4 ++-- .../components/devolo_home_control/climate.py | 4 ++-- .../components/dunehd/config_flow.py | 2 +- homeassistant/components/ecobee/sensor.py | 4 ++-- .../components/environment_canada/sensor.py | 4 ++-- .../components/esphome/config_flow.py | 4 ++-- homeassistant/components/evohome/__init__.py | 2 +- homeassistant/components/evohome/climate.py | 4 ++-- homeassistant/components/filter/sensor.py | 2 +- .../components/integration/sensor.py | 4 ++-- .../components/isy994/binary_sensor.py | 2 +- homeassistant/components/isy994/helpers.py | 10 ++++---- homeassistant/components/isy994/sensor.py | 6 ++--- homeassistant/components/lcn/helpers.py | 4 ++-- homeassistant/components/maxcube/climate.py | 2 +- .../components/media_player/__init__.py | 2 +- .../media_player/reproduce_state.py | 4 ++-- .../components/meteo_france/sensor.py | 4 ++-- homeassistant/components/mobile_app/notify.py | 2 +- .../components/modbus/base_platform.py | 4 ++-- homeassistant/components/modbus/climate.py | 4 ++-- homeassistant/components/modbus/validators.py | 2 +- homeassistant/components/motioneye/camera.py | 4 ++-- homeassistant/components/mqtt/discovery.py | 2 +- homeassistant/components/netatmo/camera.py | 6 ++--- homeassistant/components/netatmo/climate.py | 10 ++++---- homeassistant/components/ozw/__init__.py | 10 ++++---- homeassistant/components/ozw/sensor.py | 4 ++-- homeassistant/components/ozw/websocket_api.py | 2 +- .../components/plex/media_browser.py | 2 +- homeassistant/components/plex/media_player.py | 2 +- homeassistant/components/plex/server.py | 4 ++-- homeassistant/components/radarr/sensor.py | 2 +- homeassistant/components/sensibo/climate.py | 2 +- homeassistant/components/smappee/sensor.py | 14 +++++------ homeassistant/components/smappee/switch.py | 6 ++--- homeassistant/components/somfy/climate.py | 2 +- .../components/spotify/media_player.py | 2 +- homeassistant/components/statistics/sensor.py | 2 +- .../components/systemmonitor/sensor.py | 8 +++---- homeassistant/components/template/fan.py | 10 ++++---- homeassistant/components/tesla/sensor.py | 2 +- .../components/thermoworks_smoke/sensor.py | 2 +- homeassistant/components/upcloud/__init__.py | 2 +- homeassistant/components/uvc/camera.py | 2 +- homeassistant/components/vilfo/config_flow.py | 2 +- .../components/vizio/media_player.py | 2 +- .../components/volumio/browse_media.py | 4 ++-- .../components/volumio/media_player.py | 2 +- homeassistant/components/xbox/browse_media.py | 2 +- .../components/xiaomi_aqara/binary_sensor.py | 24 +++++++++---------- .../components/xiaomi_aqara/cover.py | 2 +- .../components/xiaomi_aqara/light.py | 2 +- .../components/xiaomi_aqara/sensor.py | 14 +++++------ .../components/xiaomi_aqara/switch.py | 18 +++++++------- 61 files changed, 137 insertions(+), 139 deletions(-) diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 82c61202cd3..6c1de528abe 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -61,7 +61,7 @@ class AcmedaCover(AcmedaBase, CoverEntity): None is unknown, 0 is closed, 100 is fully open. """ position = None - if self.roller.type in [7, 10]: + if self.roller.type in (7, 10): position = 100 - self.roller.closed_percent return position diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 693742217e5..2f8dd07c625 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -156,10 +156,10 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] sensors: list[AirVisualGeographySensor | AirVisualNodeProSensor] - if config_entry.data[CONF_INTEGRATION_TYPE] in [ + if config_entry.data[CONF_INTEGRATION_TYPE] in ( INTEGRATION_TYPE_GEOGRAPHY_COORDS, INTEGRATION_TYPE_GEOGRAPHY_NAME, - ]: + ): sensors = [ AirVisualGeographySensor( coordinator, diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 98d1ac0ae18..08ae2999e37 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -456,7 +456,7 @@ class ADBDevice(MediaPlayerEntity): async def async_get_media_image(self): """Fetch current playing image.""" - if not self._screencap or self.state in [STATE_OFF, None] or not self.available: + if not self._screencap or self.state in (STATE_OFF, None) or not self.available: return None, None self._attr_media_image_hash = ( f"{datetime.now().timestamp()}" if self._screencap else None diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py index 93164bd14bf..014c6cb463e 100644 --- a/homeassistant/components/atag/sensor.py +++ b/homeassistant/components/atag/sensor.py @@ -37,18 +37,18 @@ class AtagSensor(AtagEntity, SensorEntity): """Initialize Atag sensor.""" super().__init__(coordinator, SENSORS[sensor]) self._attr_name = sensor - if coordinator.data.report[self._id].sensorclass in [ + if coordinator.data.report[self._id].sensorclass in ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, - ]: + ): self._attr_device_class = coordinator.data.report[self._id].sensorclass - if coordinator.data.report[self._id].measure in [ + if coordinator.data.report[self._id].measure in ( PRESSURE_BAR, TEMP_CELSIUS, TEMP_FAHRENHEIT, PERCENTAGE, TIME_HOURS, - ]: + ): self._attr_unit_of_measurement = coordinator.data.report[self._id].measure @property diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index c951e652356..b01e6e0c01e 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -248,10 +248,10 @@ class LoginFlowResourceView(HomeAssistantView): if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: # @log_invalid_auth does not work here since it returns HTTP 200 # need manually log failed login attempts - if result.get("errors") is not None and result["errors"].get("base") in [ + if result.get("errors") is not None and result["errors"].get("base") in ( "invalid_auth", "invalid_code", - ]: + ): await process_wrong_login(request) return self.json(_prepare_result_json(result)) diff --git a/homeassistant/components/channels/media_player.py b/homeassistant/components/channels/media_player.py index 2b62039989a..bfdc12c35ce 100644 --- a/homeassistant/components/channels/media_player.py +++ b/homeassistant/components/channels/media_player.py @@ -255,7 +255,7 @@ class ChannelsPlayer(MediaPlayerEntity): if media_type == MEDIA_TYPE_CHANNEL: response = self.client.play_channel(media_id) self.update_state(response) - elif media_type in [MEDIA_TYPE_MOVIE, MEDIA_TYPE_EPISODE, MEDIA_TYPE_TVSHOW]: + elif media_type in (MEDIA_TYPE_MOVIE, MEDIA_TYPE_EPISODE, MEDIA_TYPE_TVSHOW): response = self.client.play_recording(media_id) self.update_state(response) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 12fc4ddd7ba..c8b639a1db1 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -136,8 +136,8 @@ class DerivativeSensor(RestoreEntity, SensorEntity): new_state = event.data.get("new_state") if ( old_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) ): return diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index ff4d8a01198..f6efc3094d3 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -31,11 +31,11 @@ async def async_setup_entry( for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"]: for device in gateway.multi_level_switch_devices: for multi_level_switch in device.multi_level_switch_property: - if device.device_model_uid in [ + if device.device_model_uid in ( "devolo.model.Thermostat:Valve", "devolo.model.Room:Thermostat", "devolo.model.Eurotronic:Spirit:Device", - ]: + ): entities.append( DevoloClimateDeviceEntity( homecontrol=gateway, diff --git a/homeassistant/components/dunehd/config_flow.py b/homeassistant/components/dunehd/config_flow.py index b6aec1e62f5..6c6f12280f5 100644 --- a/homeassistant/components/dunehd/config_flow.py +++ b/homeassistant/components/dunehd/config_flow.py @@ -21,7 +21,7 @@ _LOGGER: Final = logging.getLogger(__name__) def host_valid(host: str) -> bool: """Return True if hostname or IP address is valid.""" try: - if ipaddress.ip_address(host).version in [4, 6]: + if ipaddress.ip_address(host).version in (4, 6): return True except ValueError: pass diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 24ba36bedd8..97f9fe6eae0 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -109,11 +109,11 @@ class EcobeeSensor(SensorEntity): @property def state(self): """Return the state of the sensor.""" - if self._state in [ + if self._state in ( ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN, "unknown", - ]: + ): return None if self.type == "temperature": diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 232bc558da1..2c16eca9ea1 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -137,10 +137,10 @@ class ECSensor(SensorEntity): else: self._state = value - if sensor_data.get("unit") == "C" or self.sensor_type in [ + if sensor_data.get("unit") == "C" or self.sensor_type in ( "wind_chill", "humidex", - ]: + ): self._unit = TEMP_CELSIUS self._device_class = DEVICE_CLASS_TEMPERATURE else: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 247484ba317..940fee11076 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -111,10 +111,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): for entry in self._async_current_entries(): already_configured = False - if CONF_HOST in entry.data and entry.data[CONF_HOST] in [ + if CONF_HOST in entry.data and entry.data[CONF_HOST] in ( address, discovery_info[CONF_HOST], - ]: + ): # Is this address or IP address already configured? already_configured = True elif DomainData.get(self.hass).is_entry_loaded(entry): diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 045a742485b..cec59742992 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -531,7 +531,7 @@ class EvoDevice(Entity): return if payload["unique_id"] != self._unique_id: return - if payload["service"] in [SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE]: + if payload["service"] in (SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE): await self.async_zone_svc_request(payload["service"], payload["data"]) return await self.async_tcs_svc_request(payload["service"], payload["data"]) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 8021ad6ba24..6dc2809630d 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -194,7 +194,7 @@ class EvoZone(EvoChild, EvoClimateEntity): @property def hvac_mode(self) -> str: """Return the current operating mode of a Zone.""" - if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: + if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): return HVAC_MODE_AUTO is_off = self.target_temperature <= self.min_temp return HVAC_MODE_OFF if is_off else HVAC_MODE_HEAT @@ -207,7 +207,7 @@ class EvoZone(EvoChild, EvoClimateEntity): @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: + if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) return EVO_PRESET_TO_HA.get(self._evo_device.setpointStatus["setpointMode"]) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index e303dc1cf96..97412823b30 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -209,7 +209,7 @@ class SensorFilter(SensorEntity): self.async_write_ha_state() return - if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._state = new_state.state self.async_write_ha_state() return diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index dea8970f4f7..2b7d89decea 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -152,8 +152,8 @@ class IntegrationSensor(RestoreEntity, SensorEntity): new_state = event.data.get("new_state") if ( old_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) ): return diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 4a259dac6d8..c091ca6f96a 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -397,7 +397,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity): The ISY uses both DON and DOF commands (alternating) for a heartbeat. """ - if event.control in [CMD_ON, CMD_OFF]: + if event.control in (CMD_ON, CMD_OFF): self.async_heartbeat() @callback diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index b9b1a71901c..d1790fcc13c 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -114,10 +114,10 @@ def _check_for_insteon_type( return True # Thermostats, which has a "Heat" and "Cool" sub-node on address 2 and 3 - if platform == CLIMATE and subnode_id in [ + if platform == CLIMATE and subnode_id in ( SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_HEAT, - ]: + ): hass_isy_data[ISY994_NODES][BINARY_SENSOR].append(node) return True @@ -184,7 +184,7 @@ def _check_for_uom_id( This is used for versions of the ISY firmware that report uoms as a single ID. We can often infer what type of device it is by that ID. """ - if not hasattr(node, "uom") or node.uom in [None, ""]: + if not hasattr(node, "uom") or node.uom in (None, ""): # Node doesn't have a uom (Scenes for example) return False @@ -220,7 +220,7 @@ def _check_for_states_in_uom( possible "human readable" states. This filter passes if all of the possible states fit inside the given filter. """ - if not hasattr(node, "uom") or node.uom in [None, ""]: + if not hasattr(node, "uom") or node.uom in (None, ""): # Node doesn't have a uom (Scenes for example) return False @@ -413,7 +413,7 @@ def convert_isy_value_to_hass( """ if value is None or value == ISY_VALUE_UNKNOWN: return None - if uom in [UOM_DOUBLE_TEMP, UOM_ISYV4_DEGREES]: + if uom in (UOM_DOUBLE_TEMP, UOM_ISYV4_DEGREES): return round(float(value) / 2.0, 1) if precision not in ("0", 0): return round(float(value) / 10 ** int(precision), int(precision)) diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 79c5663f964..ebf32384d85 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -61,7 +61,7 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): if isy_states: return isy_states - if uom in [UOM_ON_OFF, UOM_INDEX]: + if uom in (UOM_ON_OFF, UOM_INDEX): return uom return UOM_FRIENDLY_NAME.get(uom) @@ -80,7 +80,7 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): if isinstance(uom, dict): return uom.get(value, value) - if uom in [UOM_INDEX, UOM_ON_OFF]: + if uom in (UOM_INDEX, UOM_ON_OFF): return self._node.formatted # Check if this is an index type and get formatted value @@ -101,7 +101,7 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): """Get the Home Assistant unit of measurement for the device.""" raw_units = self.raw_unit_of_measurement # Check if this is a known index pair UOM - if isinstance(raw_units, dict) or raw_units in [UOM_ON_OFF, UOM_INDEX]: + if isinstance(raw_units, dict) or raw_units in (UOM_ON_OFF, UOM_INDEX): return None if raw_units in (TEMP_FAHRENHEIT, TEMP_CELSIUS, UOM_DOUBLE_TEMP): return self.hass.config.units.temperature_unit diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 53026d0294c..5aaede430c5 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -79,9 +79,9 @@ def get_device_connection( def get_resource(domain_name: str, domain_data: ConfigType) -> str: """Return the resource for the specified domain_data.""" - if domain_name in ["switch", "light"]: + if domain_name in ("switch", "light"): return cast(str, domain_data["output"]) - if domain_name in ["binary_sensor", "sensor"]: + if domain_name in ("binary_sensor", "sensor"): return cast(str, domain_data["source"]) if domain_name == "cover": return cast(str, domain_data["motor"]) diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index 175f44b9d0e..d3dd780134a 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -119,7 +119,7 @@ class MaxCubeClimate(ClimateEntity): def hvac_mode(self): """Return current operation mode.""" mode = self._device.mode - if mode in [MAX_DEVICE_MODE_AUTOMATIC, MAX_DEVICE_MODE_BOOST]: + if mode in (MAX_DEVICE_MODE_AUTOMATIC, MAX_DEVICE_MODE_BOOST): return HVAC_MODE_AUTO if ( mode == MAX_DEVICE_MODE_MANUAL diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 6978b90c897..2b80736bc7a 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -817,7 +817,7 @@ class MediaPlayerEntity(Entity): await self.hass.async_add_executor_job(self.toggle) return - if self.state in [STATE_OFF, STATE_IDLE]: + if self.state in (STATE_OFF, STATE_IDLE): await self.async_turn_on() else: await self.async_turn_off() diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py index 115d6da447d..79688130a36 100644 --- a/homeassistant/components/media_player/reproduce_state.py +++ b/homeassistant/components/media_player/reproduce_state.py @@ -63,12 +63,12 @@ async def _async_reproduce_states( # entities that are off have no other attributes to restore return - if state.state in [ + if state.state in ( STATE_ON, STATE_PLAYING, STATE_IDLE, STATE_PAUSED, - ]: + ): await call_service(SERVICE_TURN_ON, []) if ATTR_MEDIA_VOLUME_LEVEL in state.attributes: diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index b710686554f..ed1978d160d 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -56,7 +56,7 @@ async def async_setup_entry( if coordinator_alert: entities.append(MeteoFranceAlertSensor(sensor_type, coordinator_alert)) - elif sensor_type in ["rain_chance", "freeze_chance", "snow_chance"]: + elif sensor_type in ("rain_chance", "freeze_chance", "snow_chance"): if coordinator_forecast.data.probability_forecast: entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) else: @@ -129,7 +129,7 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): else: value = data[path[1]] - if self._type in ["wind_speed", "wind_gust"]: + if self._type in ("wind_speed", "wind_gust"): # convert API wind speed from m/s to km/h value = round(value * 3.6) return value diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 162ec8afeab..c98fdeb9999 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -153,7 +153,7 @@ class MobileAppNotificationService(BaseNotificationService): ) result = await response.json() - if response.status in [HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED]: + if response.status in (HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED): log_rate_limits(self.hass, entry_data[ATTR_DEVICE_NAME], result) continue diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index b321183fd66..b1d51a285be 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -101,7 +101,7 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): def _swap_registers(self, registers): """Do swap as needed.""" - if self._swap in [CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE]: + if self._swap in (CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE): # convert [12][34] --> [21][43] for i, register in enumerate(registers): registers[i] = int.from_bytes( @@ -109,7 +109,7 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): byteorder="big", signed=False, ) - if self._swap in [CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE]: + if self._swap in (CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE): # convert [12][34] ==> [34][12] registers.reverse() return registers diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 1353828b926..16334d883a9 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -114,14 +114,14 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): target_temperature = ( float(kwargs.get(ATTR_TEMPERATURE)) - self._offset ) / self._scale - if self._data_type in [ + if self._data_type in ( DATA_TYPE_INT16, DATA_TYPE_INT32, DATA_TYPE_INT64, DATA_TYPE_UINT16, DATA_TYPE_UINT32, DATA_TYPE_UINT64, - ]: + ): target_temperature = int(target_temperature) as_bytes = struct.pack(self._structure, target_temperature) raw_regs = [ diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 9d72b611adc..8b94151e233 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -67,7 +67,7 @@ def struct_validator(config): name = config[CONF_NAME] structure = config.get(CONF_STRUCTURE) swap_type = config.get(CONF_SWAP) - if data_type in [DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT]: + if data_type in (DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT): error = f"{name} with {data_type} is not valid, trying to convert" _LOGGER.warning(error) try: diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 0727646b64d..e7ff75812f6 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -126,10 +126,10 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): ) -> dict[str, Any]: """Convert a motionEye camera to MjpegCamera internal properties.""" auth = None - if camera.get(KEY_STREAMING_AUTH_MODE) in [ + if camera.get(KEY_STREAMING_AUTH_MODE) in ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, - ]: + ): auth = camera[KEY_STREAMING_AUTH_MODE] return { diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 0659baa9144..6996072d226 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -291,7 +291,7 @@ async def async_start( # noqa: C901 result and result["type"] == RESULT_TYPE_ABORT and result["reason"] - in ["already_configured", "single_instance_allowed"] + in ("already_configured", "single_instance_allowed") ): unsub = hass.data[INTEGRATION_UNSUBSCRIBE].pop(key, None) if unsub is None: diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 346f9e93647..32d0eb46286 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -168,13 +168,13 @@ class NetatmoCamera(NetatmoBase, Camera): return if data["home_id"] == self._home_id and data["camera_id"] == self._id: - if data[WEBHOOK_PUSH_TYPE] in ["NACamera-off", "NACamera-disconnection"]: + if data[WEBHOOK_PUSH_TYPE] in ("NACamera-off", "NACamera-disconnection"): self.is_streaming = False self._status = "off" - elif data[WEBHOOK_PUSH_TYPE] in [ + elif data[WEBHOOK_PUSH_TYPE] in ( "NACamera-on", WEBHOOK_NACAMERA_CONNECTION, - ]: + ): self.is_streaming = True self._status = "on" elif data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE: diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index ccc5816a28b..1eaf47f7162 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -396,7 +396,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ) if ( - preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] + preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self._model == NA_VALVE and self.hvac_mode == HVAC_MODE_HEAT ): @@ -405,7 +405,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): STATE_NETATMO_HOME, ) elif ( - preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] and self._model == NA_VALVE + preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self._model == NA_VALVE ): await self._home_status.async_set_room_thermpoint( self._id, @@ -413,17 +413,17 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): DEFAULT_MAX_TEMP, ) elif ( - preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] + preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self.hvac_mode == HVAC_MODE_HEAT ): await self._home_status.async_set_room_thermpoint( self._id, STATE_NETATMO_HOME ) - elif preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX]: + elif preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX): await self._home_status.async_set_room_thermpoint( self._id, PRESET_MAP_NETATMO[preset_mode] ) - elif preset_mode in [PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY]: + elif preset_mode in (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY): await self._home_status.async_set_thermmode(PRESET_MAP_NETATMO[preset_mode]) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) diff --git a/homeassistant/components/ozw/__init__.py b/homeassistant/components/ozw/__init__.py index fc84a8ac7b0..c3c23ea6741 100644 --- a/homeassistant/components/ozw/__init__.py +++ b/homeassistant/components/ozw/__init__.py @@ -150,7 +150,7 @@ async def async_setup_entry( # noqa: C901 # The actual removal action of a Z-Wave node is reported as instance event # Only when this event is detected we cleanup the device and entities from hass # Note: Find a more elegant way of doing this, e.g. a notification of this event from OZW - if event in ["removenode", "removefailednode"] and "Node" in event_data: + if event in ("removenode", "removefailednode") and "Node" in event_data: removed_nodes.append(event_data["Node"]) @callback @@ -160,9 +160,7 @@ async def async_setup_entry( # noqa: C901 node_id = value.node.node_id # Filter out CommandClasses we're definitely not interested in. - if value.command_class in [ - CommandClass.MANUFACTURER_SPECIFIC, - ]: + if value.command_class in (CommandClass.MANUFACTURER_SPECIFIC,): return _LOGGER.debug( @@ -213,10 +211,10 @@ async def async_setup_entry( # noqa: C901 value.command_class, ) # Handle a scene activation message - if value.command_class in [ + if value.command_class in ( CommandClass.SCENE_ACTIVATION, CommandClass.CENTRAL_SCENE, - ]: + ): async_handle_scene_activated(hass, value) return diff --git a/homeassistant/components/ozw/sensor.py b/homeassistant/components/ozw/sensor.py index 3c3d4c3ca36..0ff08a87d16 100644 --- a/homeassistant/components/ozw/sensor.py +++ b/homeassistant/components/ozw/sensor.py @@ -88,11 +88,11 @@ class ZwaveSensorBase(ZWaveDeviceEntity, SensorEntity): def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" # We hide some of the more advanced sensors by default to not overwhelm users - if self.values.primary.command_class in [ + if self.values.primary.command_class in ( CommandClass.BASIC, CommandClass.INDICATOR, CommandClass.NOTIFICATION, - ]: + ): return False return True diff --git a/homeassistant/components/ozw/websocket_api.py b/homeassistant/components/ozw/websocket_api.py index 708b9045b57..4b96c577bf2 100644 --- a/homeassistant/components/ozw/websocket_api.py +++ b/homeassistant/components/ozw/websocket_api.py @@ -122,7 +122,7 @@ def _get_config_params(node, *args): for param in raw_values: schema = {} - if param["type"] in ["Byte", "Int", "Short"]: + if param["type"] in ("Byte", "Int", "Short"): schema = vol.Schema( { vol.Required(param["label"], default=param["value"]): vol.All( diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index e19d86e89ec..ac3c6e8f8f8 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -191,7 +191,7 @@ def browse_media( # noqa: C901 return BrowseMedia(**payload) try: - if media_content_type in ["server", None]: + if media_content_type in ("server", None): return server_payload(entity.plex_server) if media_content_type == "library": diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 1033c4286ac..2e89ede121d 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -256,7 +256,7 @@ class PlexMediaPlayer(MediaPlayerEntity): @property def _is_player_active(self): """Report if the client is playing media.""" - return self.state in [STATE_PLAYING, STATE_PAUSED] + return self.state in (STATE_PLAYING, STATE_PAUSED) @property def _active_media_plexapi_type(self): diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index dc05a727fee..e667a8a77ac 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -291,10 +291,10 @@ class PlexServer: media = self.fetch_item(rating_key) active_session.update_media(media) - if active_session.media_content_id != rating_key and state in [ + if active_session.media_content_id != rating_key and state in ( "playing", "paused", - ]: + ): await self.hass.async_add_executor_job(update_with_new_media) async_dispatcher_send( diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index add2580ee87..407f491f63a 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -194,7 +194,7 @@ class RadarrSensor(SensorEntity): return if res.status_code == HTTP_OK: - if self.type in ["upcoming", "movies", "commands"]: + if self.type in ("upcoming", "movies", "commands"): self.data = res.json() self._state = len(self.data) elif self.type == "diskspace": diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index d34ea040cdc..c4589205e34 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -357,7 +357,7 @@ class SensiboClimate(ClimateEntity): if change_needed: await self._async_set_ac_state_property("on", state != HVAC_MODE_OFF, True) - if state in [STATE_ON, HVAC_MODE_OFF]: + if state in (STATE_ON, HVAC_MODE_OFF): self._external_state = None else: self._external_state = state diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index fb00886f1f6..c7f30a8b954 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -268,7 +268,7 @@ class SmappeeSensor(SensorEntity): @property def name(self): """Return the name for this sensor.""" - if self._sensor in ["sensor", "load"]: + if self._sensor in ("sensor", "load"): return ( f"{self._service_location.service_location_name} - " f"{self._sensor.title()} - {self._name}" @@ -301,7 +301,7 @@ class SmappeeSensor(SensorEntity): self, ): """Return the unique ID for this sensor.""" - if self._sensor in ["load", "sensor"]: + if self._sensor in ("load", "sensor"): return ( f"{self._service_location.device_serial_number}-" f"{self._service_location.service_location_id}-" @@ -337,11 +337,11 @@ class SmappeeSensor(SensorEntity): self._state = self._service_location.solar_power elif self._sensor == "alwayson": self._state = self._service_location.alwayson - elif self._sensor in [ + elif self._sensor in ( "phase_voltages_a", "phase_voltages_b", "phase_voltages_c", - ]: + ): phase_voltages = self._service_location.phase_voltages if phase_voltages is not None: if self._sensor == "phase_voltages_a": @@ -350,7 +350,7 @@ class SmappeeSensor(SensorEntity): self._state = phase_voltages[1] elif self._sensor == "phase_voltages_c": self._state = phase_voltages[2] - elif self._sensor in ["line_voltages_a", "line_voltages_b", "line_voltages_c"]: + elif self._sensor in ("line_voltages_a", "line_voltages_b", "line_voltages_c"): line_voltages = self._service_location.line_voltages if line_voltages is not None: if self._sensor == "line_voltages_a": @@ -359,14 +359,14 @@ class SmappeeSensor(SensorEntity): self._state = line_voltages[1] elif self._sensor == "line_voltages_c": self._state = line_voltages[2] - elif self._sensor in [ + elif self._sensor in ( "power_today", "power_current_hour", "power_last_5_minutes", "solar_today", "solar_current_hour", "alwayson_today", - ]: + ): trend_value = self._service_location.aggregated_values.get(self._sensor) self._state = round(trend_value) if trend_value is not None else None elif self._sensor == "load": diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index 46322f413e9..3ba5e6b2a97 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -14,7 +14,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] for service_location in smappee_base.smappee.service_locations.values(): for actuator_id, actuator in service_location.actuators.items(): - if actuator.type in ["SWITCH", "COMFORT_PLUG"]: + if actuator.type in ("SWITCH", "COMFORT_PLUG"): entities.append( SmappeeActuator( smappee_base, @@ -102,7 +102,7 @@ class SmappeeActuator(SwitchEntity): def turn_on(self, **kwargs): """Turn on Comport Plug.""" - if self._actuator_type in ["SWITCH", "COMFORT_PLUG"]: + if self._actuator_type in ("SWITCH", "COMFORT_PLUG"): self._service_location.set_actuator_state(self._actuator_id, state="ON_ON") elif self._actuator_type == "INFINITY_OUTPUT_MODULE": self._service_location.set_actuator_state( @@ -111,7 +111,7 @@ class SmappeeActuator(SwitchEntity): def turn_off(self, **kwargs): """Turn off Comport Plug.""" - if self._actuator_type in ["SWITCH", "COMFORT_PLUG"]: + if self._actuator_type in ("SWITCH", "COMFORT_PLUG"): self._service_location.set_actuator_state( self._actuator_id, state="OFF_OFF" ) diff --git a/homeassistant/components/somfy/climate.py b/homeassistant/components/somfy/climate.py index 0963321100c..d4461e91e1b 100644 --- a/homeassistant/components/somfy/climate.py +++ b/homeassistant/components/somfy/climate.py @@ -165,7 +165,7 @@ class SomfyClimate(SomfyEntity, ClimateEntity): temperature = self._climate.get_night_temperature() elif preset_mode == PRESET_FROST_GUARD: temperature = self._climate.get_frost_protection_temperature() - elif preset_mode in [PRESET_MANUAL, PRESET_GEOFENCING]: + elif preset_mode in (PRESET_MANUAL, PRESET_GEOFENCING): temperature = self.target_temperature else: raise ValueError(f"Preset mode not supported: {preset_mode}") diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index c88aa453d2c..fedec630c35 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -491,7 +491,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity): ) raise NotImplementedError - if media_content_type in [None, "library"]: + if media_content_type in (None, "library"): return await self.hass.async_add_executor_job(library_payload) payload = { diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index b1ea6cfb50f..de32603c207 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -180,7 +180,7 @@ class StatisticsSensor(SensorEntity): def _add_state_to_queue(self, new_state): """Add the state to the queue.""" - if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): return try: diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index a218e627eb6..bc3e922a923 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -414,20 +414,20 @@ def _update( # noqa: C901 err.pid, err.name, ) - elif type_ in ["network_out", "network_in"]: + elif type_ in ("network_out", "network_in"): counters = _net_io_counters() if data.argument in counters: counter = counters[data.argument][IO_COUNTER[type_]] state = round(counter / 1024 ** 2, 1) else: state = None - elif type_ in ["packets_out", "packets_in"]: + elif type_ in ("packets_out", "packets_in"): counters = _net_io_counters() if data.argument in counters: state = counters[data.argument][IO_COUNTER[type_]] else: state = None - elif type_ in ["throughput_network_out", "throughput_network_in"]: + elif type_ in ("throughput_network_out", "throughput_network_in"): counters = _net_io_counters() if data.argument in counters: counter = counters[data.argument][IO_COUNTER[type_]] @@ -445,7 +445,7 @@ def _update( # noqa: C901 value = counter else: state = None - elif type_ in ["ipv4_address", "ipv6_address"]: + elif type_ in ("ipv4_address", "ipv6_address"): addresses = _net_if_addrs() if data.argument in addresses: for addr in addresses[data.argument]: diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 563a9af2849..7289eeb72e6 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -465,7 +465,7 @@ class TemplateFan(TemplateEntity, FanEntity): # Validate state if result in _VALID_STATES: self._state = result - elif result in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + elif result in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._state = None else: _LOGGER.error( @@ -529,7 +529,7 @@ class TemplateFan(TemplateEntity, FanEntity): self._speed = speed self._percentage = self.speed_to_percentage(speed) self._preset_mode = speed if speed in self.preset_modes else None - elif speed in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + elif speed in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._speed = None self._percentage = 0 self._preset_mode = None @@ -573,7 +573,7 @@ class TemplateFan(TemplateEntity, FanEntity): self._speed = preset_mode self._percentage = None self._preset_mode = preset_mode - elif preset_mode in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + elif preset_mode in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._speed = None self._percentage = None self._preset_mode = None @@ -594,7 +594,7 @@ class TemplateFan(TemplateEntity, FanEntity): self._oscillating = True elif oscillating == "False" or oscillating is False: self._oscillating = False - elif oscillating in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + elif oscillating in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._oscillating = None else: _LOGGER.error( @@ -608,7 +608,7 @@ class TemplateFan(TemplateEntity, FanEntity): # Validate direction if direction in _VALID_DIRECTIONS: self._direction = direction - elif direction in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + elif direction in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._direction = None else: _LOGGER.error( diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py index 40c7aa8548d..ad585082b48 100644 --- a/homeassistant/components/tesla/sensor.py +++ b/homeassistant/components/tesla/sensor.py @@ -44,7 +44,7 @@ class TeslaSensor(TeslaDevice, SensorEntity): if self.type == "outside": return self.tesla_device.get_outside_temp() return self.tesla_device.get_inside_temp() - if self.tesla_device.type in ["range sensor", "mileage sensor"]: + if self.tesla_device.type in ("range sensor", "mileage sensor"): units = self.tesla_device.measurement if units == "LENGTH_MILES": return self.tesla_device.get_value() diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index 1bdbbc5fcc3..4b14c9a9305 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -160,7 +160,7 @@ class ThermoworksSmokeSensor(SensorEntity): } # set extended attributes for main probe sensors - if self.type in [PROBE_1, PROBE_2]: + if self.type in (PROBE_1, PROBE_2): for key, val in values.items(): # add all attributes that don't contain any probe name # or contain a matching probe name diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 636fa7a2b8a..9b76c209403 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -286,7 +286,7 @@ class UpCloudServerEntity(CoordinatorEntity): """Return True if entity is available.""" return super().available and STATE_MAP.get( self._server.state, self._server.state - ) in [STATE_ON, STATE_OFF] + ) in (STATE_ON, STATE_OFF) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index 6bbd868a8bd..74bf175f75a 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -131,7 +131,7 @@ class UnifiVideoCamera(Camera): return self._caminfo["recordingSettings"][ "fullTimeRecordEnabled" - ] or recording_state in ["MOTION_INPROGRESS", "MOTION_FINISHED"] + ] or recording_state in ("MOTION_INPROGRESS", "MOTION_FINISHED") @property def motion_detection_enabled(self): diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py index 9483542f19b..acc83b7f115 100644 --- a/homeassistant/components/vilfo/config_flow.py +++ b/homeassistant/components/vilfo/config_flow.py @@ -32,7 +32,7 @@ RESULT_INVALID_AUTH = "invalid_auth" def host_valid(host): """Return True if hostname or IP address is valid.""" try: - if ipaddress.ip_address(host).version in [4, 6]: + if ipaddress.ip_address(host).version in (4, 6): return True except ValueError: disallowed = re.compile(r"[^a-zA-Z\d\-]") diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 0cb2884a8b8..05caae0ec08 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -87,7 +87,7 @@ async def async_setup_entry( ( key for key in config_entry.data.get(CONF_APPS, {}) - if key in [CONF_INCLUDE, CONF_EXCLUDE] + if key in (CONF_INCLUDE, CONF_EXCLUDE) ), None, ) diff --git a/homeassistant/components/volumio/browse_media.py b/homeassistant/components/volumio/browse_media.py index 41330c37473..25fe929aaf1 100644 --- a/homeassistant/components/volumio/browse_media.py +++ b/homeassistant/components/volumio/browse_media.py @@ -73,9 +73,9 @@ def _item_to_children_media_class(item, info=None): def _item_to_media_class(item, parent_item=None): if "type" not in item: return MEDIA_CLASS_DIRECTORY - if item["type"] in ["webradio", "mywebradio"]: + if item["type"] in ("webradio", "mywebradio"): return MEDIA_CLASS_CHANNEL - if item["type"] in ["song", "cuesong"]: + if item["type"] in ("song", "cuesong"): return MEDIA_CLASS_TRACK if item.get("artist"): return MEDIA_CLASS_ALBUM diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 850f44343c2..36555a0f94e 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -259,7 +259,7 @@ class Volumio(MediaPlayerEntity): async def async_browse_media(self, media_content_type=None, media_content_id=None): """Implement the websocket media browsing helper.""" self.thumbnail_cache = {} - if media_content_type in [None, "library"]: + if media_content_type in (None, "library"): return await browse_top_level(self._volumio) return await browse_node( diff --git a/homeassistant/components/xbox/browse_media.py b/homeassistant/components/xbox/browse_media.py index 0c3eec95c6f..d1438a46f23 100644 --- a/homeassistant/components/xbox/browse_media.py +++ b/homeassistant/components/xbox/browse_media.py @@ -45,7 +45,7 @@ async def build_item_response( """Create response payload for the provided media query.""" apps: InstalledPackagesList = await client.smartglass.get_installed_apps(device_id) - if media_content_type in [None, "library"]: + if media_content_type in (None, "library"): library_info = BrowseMedia( media_class=MEDIA_CLASS_DIRECTORY, media_content_id="library", diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 3d9437e3778..41a99426c67 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -32,23 +32,23 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] for entity in gateway.devices["binary_sensor"]: model = entity["model"] - if model in ["motion", "sensor_motion", "sensor_motion.aq2"]: + if model in ("motion", "sensor_motion", "sensor_motion.aq2"): entities.append(XiaomiMotionSensor(entity, hass, gateway, config_entry)) - elif model in ["magnet", "sensor_magnet", "sensor_magnet.aq2"]: + elif model in ("magnet", "sensor_magnet", "sensor_magnet.aq2"): entities.append(XiaomiDoorSensor(entity, gateway, config_entry)) elif model == "sensor_wleak.aq1": entities.append(XiaomiWaterLeakSensor(entity, gateway, config_entry)) - elif model in ["smoke", "sensor_smoke"]: + elif model in ("smoke", "sensor_smoke"): entities.append(XiaomiSmokeSensor(entity, gateway, config_entry)) - elif model in ["natgas", "sensor_natgas"]: + elif model in ("natgas", "sensor_natgas"): entities.append(XiaomiNatgasSensor(entity, gateway, config_entry)) - elif model in [ + elif model in ( "switch", "sensor_switch", "sensor_switch.aq2", "sensor_switch.aq3", "remote.b1acn01", - ]: + ): if "proto" not in entity or int(entity["proto"][0:1]) == 1: data_key = "status" else: @@ -56,13 +56,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append( XiaomiButton(entity, "Switch", data_key, hass, gateway, config_entry) ) - elif model in [ + elif model in ( "86sw1", "sensor_86sw1", "sensor_86sw1.aq1", "remote.b186acn01", "remote.b186acn02", - ]: + ): if "proto" not in entity or int(entity["proto"][0:1]) == 1: data_key = "channel_0" else: @@ -72,13 +72,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entity, "Wall Switch", data_key, hass, gateway, config_entry ) ) - elif model in [ + elif model in ( "86sw2", "sensor_86sw2", "sensor_86sw2.aq1", "remote.b286acn01", "remote.b286acn02", - ]: + ): if "proto" not in entity or int(entity["proto"][0:1]) == 1: data_key_left = "channel_0" data_key_right = "channel_1" @@ -115,9 +115,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config_entry, ) ) - elif model in ["cube", "sensor_cube", "sensor_cube.aqgl01"]: + elif model in ("cube", "sensor_cube", "sensor_cube.aqgl01"): entities.append(XiaomiCube(entity, hass, gateway, config_entry)) - elif model in ["vibration", "vibration.aq1"]: + elif model in ("vibration", "vibration.aq1"): entities.append( XiaomiVibration(entity, "Vibration", "status", gateway, config_entry) ) diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index 0ef74da83ff..db41a4d719a 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -16,7 +16,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] for device in gateway.devices["cover"]: model = device["model"] - if model in ["curtain", "curtain.aq2", "curtain.hagl04"]: + if model in ("curtain", "curtain.aq2", "curtain.hagl04"): if "proto" not in device or int(device["proto"][0:1]) == 1: data_key = DATA_KEY_PROTO_V1 else: diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index 30f72a7ba59..4064df5f259 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -24,7 +24,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] for device in gateway.devices["light"]: model = device["model"] - if model in ["gateway", "gateway.v3"]: + if model in ("gateway", "gateway.v3"): entities.append( XiaomiGatewayLight(device, "Gateway Light", gateway, config_entry) ) diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index fa3d265f12f..cc49bb14251 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -47,7 +47,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append( XiaomiSensor(device, "Humidity", "humidity", gateway, config_entry) ) - elif device["model"] in ["weather", "weather.v1"]: + elif device["model"] in ("weather", "weather.v1"): entities.append( XiaomiSensor( device, "Temperature", "temperature", gateway, config_entry @@ -63,13 +63,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append( XiaomiSensor(device, "Illumination", "lux", gateway, config_entry) ) - elif device["model"] in ["gateway", "gateway.v3", "acpartner.v3"]: + elif device["model"] in ("gateway", "gateway.v3", "acpartner.v3"): entities.append( XiaomiSensor( device, "Illumination", "illumination", gateway, config_entry ) ) - elif device["model"] in ["vibration"]: + elif device["model"] in ("vibration",): entities.append( XiaomiSensor( device, "Bed Activity", "bed_activity", gateway, config_entry @@ -151,13 +151,13 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): value = data.get(self._data_key) if value is None: return False - if self._data_key in ["coordination", "status"]: + if self._data_key in ("coordination", "status"): self._state = value return True value = float(value) - if self._data_key in ["temperature", "humidity", "pressure"]: + if self._data_key in ("temperature", "humidity", "pressure"): value /= 100 - elif self._data_key in ["illumination"]: + elif self._data_key in ("illumination",): value = max(value - 300, 0) if self._data_key == "temperature" and (value < -50 or value > 60): return False @@ -165,7 +165,7 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): return False if self._data_key == "pressure" and value == 0: return False - if self._data_key in ["illumination", "lux"]: + if self._data_key in ("illumination", "lux"): self._state = round(value) else: self._state = round(value, 1) diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index c17cf080a60..139a7a57dbc 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -37,34 +37,34 @@ async def async_setup_entry(hass, config_entry, async_add_entities): device, "Plug", data_key, True, gateway, config_entry ) ) - elif model in [ + elif model in ( "ctrl_neutral1", "ctrl_neutral1.aq1", "switch_b1lacn02", "switch.b1lacn02", - ]: + ): entities.append( XiaomiGenericSwitch( device, "Wall Switch", "channel_0", False, gateway, config_entry ) ) - elif model in [ + elif model in ( "ctrl_ln1", "ctrl_ln1.aq1", "switch_b1nacn02", "switch.b1nacn02", - ]: + ): entities.append( XiaomiGenericSwitch( device, "Wall Switch LN", "channel_0", False, gateway, config_entry ) ) - elif model in [ + elif model in ( "ctrl_neutral2", "ctrl_neutral2.aq1", "switch_b2lacn02", "switch.b2lacn02", - ]: + ): entities.append( XiaomiGenericSwitch( device, @@ -85,12 +85,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config_entry, ) ) - elif model in [ + elif model in ( "ctrl_ln2", "ctrl_ln2.aq1", "switch_b2nacn02", "switch.b2nacn02", - ]: + ): entities.append( XiaomiGenericSwitch( device, @@ -111,7 +111,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config_entry, ) ) - elif model in ["86plug", "ctrl_86plug", "ctrl_86plug.aq1"]: + elif model in ("86plug", "ctrl_86plug", "ctrl_86plug.aq1"): if "proto" not in device or int(device["proto"][0:1]) == 1: data_key = "status" else: From 45e4f80cfe4f55fff7b9582060c5cc4b47daeca6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 30 Jul 2021 06:18:09 +0200 Subject: [PATCH 042/903] Fix exception handling in DataUpdateCoordinator in TP-Link (#53734) Co-authored-by: Paulus Schoutsen --- homeassistant/components/tplink/__init__.py | 81 +++++++++++---------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 9d5263687f8..e309d2c5082 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -26,7 +26,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.dt import utc_from_timestamp from .common import SmartDevices, async_discover_devices, get_static_devices @@ -185,45 +185,50 @@ class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict: """Fetch all device and sensor data from api.""" - info = self.smartplug.sys_info - data = { - CONF_HOST: self.smartplug.host, - CONF_MAC: info["mac"], - CONF_MODEL: info["model"], - CONF_SW_VERSION: info["sw_ver"], - } - if self.smartplug.context is None: - data[CONF_ALIAS] = info["alias"] - data[CONF_DEVICE_ID] = info["mac"] - data[CONF_STATE] = self.smartplug.state == self.smartplug.SWITCH_STATE_ON - else: - plug_from_context = next( - c - for c in self.smartplug.sys_info["children"] - if c["id"] == self.smartplug.context - ) - data[CONF_ALIAS] = plug_from_context["alias"] - data[CONF_DEVICE_ID] = self.smartplug.context - data[CONF_STATE] = plug_from_context["state"] == 1 - if self.smartplug.has_emeter: - emeter_readings = self.smartplug.get_emeter_realtime() - data[CONF_EMETER_PARAMS] = { - ATTR_CURRENT_POWER_W: round(float(emeter_readings["power"]), 2), - ATTR_TOTAL_ENERGY_KWH: round(float(emeter_readings["total"]), 3), - ATTR_VOLTAGE: round(float(emeter_readings["voltage"]), 1), - ATTR_CURRENT_A: round(float(emeter_readings["current"]), 2), - ATTR_LAST_RESET: {ATTR_TOTAL_ENERGY_KWH: utc_from_timestamp(0)}, + try: + info = self.smartplug.sys_info + data = { + CONF_HOST: self.smartplug.host, + CONF_MAC: info["mac"], + CONF_MODEL: info["model"], + CONF_SW_VERSION: info["sw_ver"], } - emeter_statics = self.smartplug.get_emeter_daily() - data[CONF_EMETER_PARAMS][ATTR_LAST_RESET][ - ATTR_TODAY_ENERGY_KWH - ] = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - if emeter_statics.get(int(time.strftime("%e"))): - data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = round( - float(emeter_statics[int(time.strftime("%e"))]), 3 + if self.smartplug.context is None: + data[CONF_ALIAS] = info["alias"] + data[CONF_DEVICE_ID] = info["mac"] + data[CONF_STATE] = ( + self.smartplug.state == self.smartplug.SWITCH_STATE_ON ) else: - # today's consumption not available, when device was off all the day - data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = 0.0 + plug_from_context = next( + c + for c in self.smartplug.sys_info["children"] + if c["id"] == self.smartplug.context + ) + data[CONF_ALIAS] = plug_from_context["alias"] + data[CONF_DEVICE_ID] = self.smartplug.context + data[CONF_STATE] = plug_from_context["state"] == 1 + if self.smartplug.has_emeter: + emeter_readings = self.smartplug.get_emeter_realtime() + data[CONF_EMETER_PARAMS] = { + ATTR_CURRENT_POWER_W: round(float(emeter_readings["power"]), 2), + ATTR_TOTAL_ENERGY_KWH: round(float(emeter_readings["total"]), 3), + ATTR_VOLTAGE: round(float(emeter_readings["voltage"]), 1), + ATTR_CURRENT_A: round(float(emeter_readings["current"]), 2), + ATTR_LAST_RESET: {ATTR_TOTAL_ENERGY_KWH: utc_from_timestamp(0)}, + } + emeter_statics = self.smartplug.get_emeter_daily() + data[CONF_EMETER_PARAMS][ATTR_LAST_RESET][ + ATTR_TODAY_ENERGY_KWH + ] = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + if emeter_statics.get(int(time.strftime("%e"))): + data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = round( + float(emeter_statics[int(time.strftime("%e"))]), 3 + ) + else: + # today's consumption not available, when device was off all the day + data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = 0.0 + except SmartDeviceException as ex: + raise UpdateFailed(ex) from ex return data From 8972fae0caf1b68fbc997cc3b2dd396c57df0fc0 Mon Sep 17 00:00:00 2001 From: Ryan Johnson <43426700+ryanjohnsontv@users.noreply.github.com> Date: Thu, 29 Jul 2021 23:19:32 -0500 Subject: [PATCH 043/903] Bump pyatv to 0.8.2 (#53659) --- homeassistant/components/apple_tv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index d4eb322f4d7..a726e616641 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -3,7 +3,7 @@ "name": "Apple TV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/apple_tv", - "requirements": ["pyatv==0.8.1"], + "requirements": ["pyatv==0.8.2"], "zeroconf": ["_mediaremotetv._tcp.local.", "_touch-able._tcp.local."], "after_dependencies": ["discovery"], "codeowners": ["@postlund"], diff --git a/requirements_all.txt b/requirements_all.txt index e68071a3578..75313a53947 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1326,7 +1326,7 @@ pyatmo==5.2.3 pyatome==0.1.1 # homeassistant.components.apple_tv -pyatv==0.8.1 +pyatv==0.8.2 # homeassistant.components.bbox pybbox==0.0.5-alpha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62038a7814e..7a49e3faae2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -751,7 +751,7 @@ pyatag==0.3.5.3 pyatmo==5.2.3 # homeassistant.components.apple_tv -pyatv==0.8.1 +pyatv==0.8.2 # homeassistant.components.blackbird pyblackbird==0.5 From 2b2cddb5f0024c03aacad0f6053afc90f994764f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 30 Jul 2021 06:50:02 +0200 Subject: [PATCH 044/903] Extract smartthings switch energy attributes into sensors (#53719) --- .../components/smartthings/__init__.py | 3 +- .../components/smartthings/sensor.py | 263 ++++++++++++++---- .../components/smartthings/switch.py | 12 +- tests/components/smartthings/test_sensor.py | 46 ++- tests/components/smartthings/test_switch.py | 8 +- 5 files changed, 257 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 231cfa95263..bc64b173f20 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -9,6 +9,7 @@ import logging from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from pysmartapp.event import EVENT_TYPE_DEVICE from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings.device import DeviceEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -412,7 +413,7 @@ class DeviceBroker: class SmartThingsEntity(Entity): """Defines a SmartThings entity.""" - def __init__(self, device): + def __init__(self, device: DeviceEntity) -> None: """Initialize the instance.""" self._device = device self._dispatcher_remove = None diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index b8bb071fc0a..7a7f9a51855 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -3,18 +3,26 @@ from __future__ import annotations from collections import namedtuple from collections.abc import Sequence +from datetime import datetime from pysmartthings import Attribute, Capability +from pysmartthings.device import DeviceEntity -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( AREA_SQUARE_METERS, CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLTAGE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, LIGHT_LUX, @@ -25,26 +33,27 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, VOLUME_CUBIC_METERS, ) +from homeassistant.util.dt import utc_from_timestamp from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN -Map = namedtuple("map", "attribute name default_unit device_class") +Map = namedtuple("map", "attribute name default_unit device_class state_class") CAPABILITY_TO_SENSORS = { Capability.activity_lighting_mode: [ - Map(Attribute.lighting_mode, "Activity Lighting Mode", None, None) + Map(Attribute.lighting_mode, "Activity Lighting Mode", None, None, None) ], Capability.air_conditioner_mode: [ - Map(Attribute.air_conditioner_mode, "Air Conditioner Mode", None, None) + Map(Attribute.air_conditioner_mode, "Air Conditioner Mode", None, None, None) ], Capability.air_quality_sensor: [ - Map(Attribute.air_quality, "Air Quality", "CAQI", None) + Map(Attribute.air_quality, "Air Quality", "CAQI", None, STATE_CLASS_MEASUREMENT) ], - Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None)], - Capability.audio_volume: [Map(Attribute.volume, "Volume", PERCENTAGE, None)], + Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None, None)], + Capability.audio_volume: [Map(Attribute.volume, "Volume", PERCENTAGE, None, None)], Capability.battery: [ - Map(Attribute.battery, "Battery", PERCENTAGE, DEVICE_CLASS_BATTERY) + Map(Attribute.battery, "Battery", PERCENTAGE, DEVICE_CLASS_BATTERY, None) ], Capability.body_mass_index_measurement: [ Map( @@ -52,57 +61,80 @@ CAPABILITY_TO_SENSORS = { "Body Mass Index", f"{MASS_KILOGRAMS}/{AREA_SQUARE_METERS}", None, + STATE_CLASS_MEASUREMENT, ) ], Capability.body_weight_measurement: [ - Map(Attribute.body_weight_measurement, "Body Weight", MASS_KILOGRAMS, None) + Map( + Attribute.body_weight_measurement, + "Body Weight", + MASS_KILOGRAMS, + None, + STATE_CLASS_MEASUREMENT, + ) ], Capability.carbon_dioxide_measurement: [ Map( Attribute.carbon_dioxide, "Carbon Dioxide Measurement", CONCENTRATION_PARTS_PER_MILLION, - None, + DEVICE_CLASS_CO2, + STATE_CLASS_MEASUREMENT, ) ], Capability.carbon_monoxide_detector: [ - Map(Attribute.carbon_monoxide, "Carbon Monoxide Detector", None, None) + Map(Attribute.carbon_monoxide, "Carbon Monoxide Detector", None, None, None) ], Capability.carbon_monoxide_measurement: [ Map( Attribute.carbon_monoxide_level, "Carbon Monoxide Measurement", CONCENTRATION_PARTS_PER_MILLION, - None, + DEVICE_CLASS_CO, + STATE_CLASS_MEASUREMENT, ) ], Capability.dishwasher_operating_state: [ - Map(Attribute.machine_state, "Dishwasher Machine State", None, None), - Map(Attribute.dishwasher_job_state, "Dishwasher Job State", None, None), + Map(Attribute.machine_state, "Dishwasher Machine State", None, None, None), + Map(Attribute.dishwasher_job_state, "Dishwasher Job State", None, None, None), Map( Attribute.completion_time, "Dishwasher Completion Time", None, DEVICE_CLASS_TIMESTAMP, + None, ), ], - Capability.dryer_mode: [Map(Attribute.dryer_mode, "Dryer Mode", None, None)], + Capability.dryer_mode: [Map(Attribute.dryer_mode, "Dryer Mode", None, None, None)], Capability.dryer_operating_state: [ - Map(Attribute.machine_state, "Dryer Machine State", None, None), - Map(Attribute.dryer_job_state, "Dryer Job State", None, None), + Map(Attribute.machine_state, "Dryer Machine State", None, None, None), + Map(Attribute.dryer_job_state, "Dryer Job State", None, None, None), Map( Attribute.completion_time, "Dryer Completion Time", None, DEVICE_CLASS_TIMESTAMP, + None, ), ], Capability.dust_sensor: [ - Map(Attribute.fine_dust_level, "Fine Dust Level", None, None), - Map(Attribute.dust_level, "Dust Level", None, None), + Map( + Attribute.fine_dust_level, + "Fine Dust Level", + None, + None, + STATE_CLASS_MEASUREMENT, + ), + Map(Attribute.dust_level, "Dust Level", None, None, STATE_CLASS_MEASUREMENT), ], Capability.energy_meter: [ - Map(Attribute.energy, "Energy Meter", ENERGY_KILO_WATT_HOUR, None) + Map( + Attribute.energy, + "Energy Meter", + ENERGY_KILO_WATT_HOUR, + DEVICE_CLASS_ENERGY, + STATE_CLASS_MEASUREMENT, + ) ], Capability.equivalent_carbon_dioxide_measurement: [ Map( @@ -110,6 +142,7 @@ CAPABILITY_TO_SENSORS = { "Equivalent Carbon Dioxide Measurement", CONCENTRATION_PARTS_PER_MILLION, None, + STATE_CLASS_MEASUREMENT, ) ], Capability.formaldehyde_measurement: [ @@ -118,50 +151,94 @@ CAPABILITY_TO_SENSORS = { "Formaldehyde Measurement", CONCENTRATION_PARTS_PER_MILLION, None, + STATE_CLASS_MEASUREMENT, ) ], Capability.gas_meter: [ - Map(Attribute.gas_meter, "Gas Meter", ENERGY_KILO_WATT_HOUR, None), - Map(Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None), - Map(Attribute.gas_meter_time, "Gas Meter Time", None, DEVICE_CLASS_TIMESTAMP), - Map(Attribute.gas_meter_volume, "Gas Meter Volume", VOLUME_CUBIC_METERS, None), + Map( + Attribute.gas_meter, + "Gas Meter", + ENERGY_KILO_WATT_HOUR, + None, + STATE_CLASS_MEASUREMENT, + ), + Map(Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None, None), + Map( + Attribute.gas_meter_time, + "Gas Meter Time", + None, + DEVICE_CLASS_TIMESTAMP, + None, + ), + Map( + Attribute.gas_meter_volume, + "Gas Meter Volume", + VOLUME_CUBIC_METERS, + None, + STATE_CLASS_MEASUREMENT, + ), ], Capability.illuminance_measurement: [ - Map(Attribute.illuminance, "Illuminance", LIGHT_LUX, DEVICE_CLASS_ILLUMINANCE) + Map( + Attribute.illuminance, + "Illuminance", + LIGHT_LUX, + DEVICE_CLASS_ILLUMINANCE, + STATE_CLASS_MEASUREMENT, + ) ], Capability.infrared_level: [ - Map(Attribute.infrared_level, "Infrared Level", PERCENTAGE, None) + Map( + Attribute.infrared_level, + "Infrared Level", + PERCENTAGE, + None, + STATE_CLASS_MEASUREMENT, + ) ], Capability.media_input_source: [ - Map(Attribute.input_source, "Media Input Source", None, None) + Map(Attribute.input_source, "Media Input Source", None, None, None) ], Capability.media_playback_repeat: [ - Map(Attribute.playback_repeat_mode, "Media Playback Repeat", None, None) + Map(Attribute.playback_repeat_mode, "Media Playback Repeat", None, None, None) ], Capability.media_playback_shuffle: [ - Map(Attribute.playback_shuffle, "Media Playback Shuffle", None, None) + Map(Attribute.playback_shuffle, "Media Playback Shuffle", None, None, None) ], Capability.media_playback: [ - Map(Attribute.playback_status, "Media Playback Status", None, None) + Map(Attribute.playback_status, "Media Playback Status", None, None, None) ], - Capability.odor_sensor: [Map(Attribute.odor_level, "Odor Sensor", None, None)], - Capability.oven_mode: [Map(Attribute.oven_mode, "Oven Mode", None, None)], + Capability.odor_sensor: [ + Map(Attribute.odor_level, "Odor Sensor", None, None, None) + ], + Capability.oven_mode: [Map(Attribute.oven_mode, "Oven Mode", None, None, None)], Capability.oven_operating_state: [ - Map(Attribute.machine_state, "Oven Machine State", None, None), - Map(Attribute.oven_job_state, "Oven Job State", None, None), - Map(Attribute.completion_time, "Oven Completion Time", None, None), + Map(Attribute.machine_state, "Oven Machine State", None, None, None), + Map(Attribute.oven_job_state, "Oven Job State", None, None, None), + Map(Attribute.completion_time, "Oven Completion Time", None, None, None), ], Capability.oven_setpoint: [ - Map(Attribute.oven_setpoint, "Oven Set Point", None, None) + Map(Attribute.oven_setpoint, "Oven Set Point", None, None, None) + ], + Capability.power_meter: [ + Map( + Attribute.power, + "Power Meter", + POWER_WATT, + DEVICE_CLASS_POWER, + STATE_CLASS_MEASUREMENT, + ) + ], + Capability.power_source: [ + Map(Attribute.power_source, "Power Source", None, None, None) ], - Capability.power_meter: [Map(Attribute.power, "Power Meter", POWER_WATT, None)], - Capability.power_source: [Map(Attribute.power_source, "Power Source", None, None)], Capability.refrigeration_setpoint: [ Map( Attribute.refrigeration_setpoint, "Refrigeration Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.relative_humidity_measurement: [ @@ -170,6 +247,7 @@ CAPABILITY_TO_SENSORS = { "Relative Humidity Measurement", PERCENTAGE, DEVICE_CLASS_HUMIDITY, + STATE_CLASS_MEASUREMENT, ) ], Capability.robot_cleaner_cleaning_mode: [ @@ -178,25 +256,43 @@ CAPABILITY_TO_SENSORS = { "Robot Cleaner Cleaning Mode", None, None, + None, ) ], Capability.robot_cleaner_movement: [ - Map(Attribute.robot_cleaner_movement, "Robot Cleaner Movement", None, None) + Map( + Attribute.robot_cleaner_movement, "Robot Cleaner Movement", None, None, None + ) ], Capability.robot_cleaner_turbo_mode: [ - Map(Attribute.robot_cleaner_turbo_mode, "Robot Cleaner Turbo Mode", None, None) + Map( + Attribute.robot_cleaner_turbo_mode, + "Robot Cleaner Turbo Mode", + None, + None, + None, + ) ], Capability.signal_strength: [ - Map(Attribute.lqi, "LQI Signal Strength", None, None), - Map(Attribute.rssi, "RSSI Signal Strength", None, None), + Map(Attribute.lqi, "LQI Signal Strength", None, None, STATE_CLASS_MEASUREMENT), + Map( + Attribute.rssi, + "RSSI Signal Strength", + None, + DEVICE_CLASS_SIGNAL_STRENGTH, + STATE_CLASS_MEASUREMENT, + ), + ], + Capability.smoke_detector: [ + Map(Attribute.smoke, "Smoke Detector", None, None, None) ], - Capability.smoke_detector: [Map(Attribute.smoke, "Smoke Detector", None, None)], Capability.temperature_measurement: [ Map( Attribute.temperature, "Temperature Measurement", None, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ) ], Capability.thermostat_cooling_setpoint: [ @@ -205,10 +301,11 @@ CAPABILITY_TO_SENSORS = { "Thermostat Cooling Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.thermostat_fan_mode: [ - Map(Attribute.thermostat_fan_mode, "Thermostat Fan Mode", None, None) + Map(Attribute.thermostat_fan_mode, "Thermostat Fan Mode", None, None, None) ], Capability.thermostat_heating_setpoint: [ Map( @@ -216,10 +313,11 @@ CAPABILITY_TO_SENSORS = { "Thermostat Heating Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.thermostat_mode: [ - Map(Attribute.thermostat_mode, "Thermostat Mode", None, None) + Map(Attribute.thermostat_mode, "Thermostat Mode", None, None, None) ], Capability.thermostat_operating_state: [ Map( @@ -227,6 +325,7 @@ CAPABILITY_TO_SENSORS = { "Thermostat Operating State", None, None, + None, ) ], Capability.thermostat_setpoint: [ @@ -235,12 +334,13 @@ CAPABILITY_TO_SENSORS = { "Thermostat Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.three_axis: [], Capability.tv_channel: [ - Map(Attribute.tv_channel, "Tv Channel", None, None), - Map(Attribute.tv_channel_name, "Tv Channel Name", None, None), + Map(Attribute.tv_channel, "Tv Channel", None, None, None), + Map(Attribute.tv_channel_name, "Tv Channel Name", None, None, None), ], Capability.tvoc_measurement: [ Map( @@ -248,23 +348,39 @@ CAPABILITY_TO_SENSORS = { "Tvoc Measurement", CONCENTRATION_PARTS_PER_MILLION, None, + STATE_CLASS_MEASUREMENT, ) ], Capability.ultraviolet_index: [ - Map(Attribute.ultraviolet_index, "Ultraviolet Index", None, None) + Map( + Attribute.ultraviolet_index, + "Ultraviolet Index", + None, + None, + STATE_CLASS_MEASUREMENT, + ) ], Capability.voltage_measurement: [ - Map(Attribute.voltage, "Voltage Measurement", ELECTRIC_POTENTIAL_VOLT, None) + Map( + Attribute.voltage, + "Voltage Measurement", + ELECTRIC_POTENTIAL_VOLT, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + ) + ], + Capability.washer_mode: [ + Map(Attribute.washer_mode, "Washer Mode", None, None, None) ], - Capability.washer_mode: [Map(Attribute.washer_mode, "Washer Mode", None, None)], Capability.washer_operating_state: [ - Map(Attribute.machine_state, "Washer Machine State", None, None), - Map(Attribute.washer_job_state, "Washer Job State", None, None), + Map(Attribute.machine_state, "Washer Machine State", None, None, None), + Map(Attribute.washer_job_state, "Washer Job State", None, None, None), Map( Attribute.completion_time, "Washer Completion Time", None, DEVICE_CLASS_TIMESTAMP, + None, ), ], } @@ -292,11 +408,34 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors.extend( [ SmartThingsSensor( - device, m.attribute, m.name, m.default_unit, m.device_class + device, + m.attribute, + m.name, + m.default_unit, + m.device_class, + m.state_class, ) 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] + sensors.extend( + [ + SmartThingsSensor( + device, + m.attribute, + m.name, + m.default_unit, + m.device_class, + m.state_class, + ) + for m in maps + ] + ) + async_add_entities(sensors) @@ -311,14 +450,21 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Sensor.""" def __init__( - self, device, attribute: str, name: str, default_unit: str, device_class: str - ): + self, + device: DeviceEntity, + attribute: str, + name: str, + default_unit: str, + device_class: str, + state_class: str | None, + ) -> None: """Init the class.""" super().__init__(device) self._attribute = attribute self._name = name self._device_class = device_class self._default_unit = default_unit + self._attr_state_class = state_class @property def name(self) -> str: @@ -346,6 +492,13 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): unit = self._device.status.attributes[self._attribute].unit return UNITS.get(unit, unit) if unit else self._default_unit + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + if self._attribute == Attribute.energy: + return utc_from_timestamp(0) + return None + class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Three Axis Sensor.""" diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 7b8364d9ba3..7e92ba4f663 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Sequence -from pysmartthings import Attribute, Capability +from pysmartthings import Capability from homeassistant.components.switch import SwitchEntity @@ -48,16 +48,6 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): # the entity state ahead of receiving the confirming push updates self.async_write_ha_state() - @property - def current_power_w(self): - """Return the current power usage in W.""" - return self._device.status.attributes[Attribute.power].value - - @property - def today_energy_kwh(self): - """Return the today total energy usage in kWh.""" - return self._device.status.attributes[Attribute.energy].value - @property def is_on(self) -> bool: """Return true if light is on.""" diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 4af88e27fe4..ffb577c903a 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -6,7 +6,11 @@ real HTTP calls are not initiated during testing. """ from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability -from homeassistant.components.sensor import DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN +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 @@ -33,6 +37,8 @@ async def test_mapping_integrity(): 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, device_factory): @@ -95,6 +101,44 @@ async def test_entity_and_device_attributes(hass, device_factory): assert entry.manufacturer == "Unavailable" +async def test_energy_sensors_for_switch_device(hass, device_factory): + """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}, + ) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + # 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}" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + 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}" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + async def test_update_from_signal(hass, device_factory): """Test the binary_sensor updates when receiving a signal.""" # Arrange diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 7c202fad12e..c884d601baf 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -7,11 +7,7 @@ real HTTP calls are not initiated during testing. from pysmartthings import Attribute, Capability from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.components.switch import ( - ATTR_CURRENT_POWER_W, - ATTR_TODAY_ENERGY_KWH, - DOMAIN as SWITCH_DOMAIN, -) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -72,8 +68,6 @@ async def test_turn_on(hass, device_factory): state = hass.states.get("switch.switch_1") assert state is not None assert state.state == "on" - assert state.attributes[ATTR_CURRENT_POWER_W] == 355 - assert state.attributes[ATTR_TODAY_ENERGY_KWH] == 11.422 async def test_update_from_signal(hass, device_factory): From 7ae158f9a1195b93b90f926116bae9f0b59df695 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Thu, 29 Jul 2021 22:08:13 -0700 Subject: [PATCH 045/903] wemo light brightness fixes (#53740) --- homeassistant/components/wemo/light.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 0767c6b6603..8a098904bb0 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -229,14 +229,13 @@ class WemoDimmer(WemoSubscriptionEntity, LightEntity): if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] brightness = int((brightness / 255) * 100) - else: - brightness = 255 - - with self._wemo_exception_handler("turn on"): - if self.wemo.on(): + with self._wemo_exception_handler("set brightness"): + self.wemo.set_brightness(brightness) + self._state = WEMO_ON + else: + with self._wemo_exception_handler("turn on"): + self.wemo.on() self._state = WEMO_ON - - self.wemo.set_brightness(brightness) self.schedule_update_ha_state() From 96b02153b9bd2ff21247b555c224b6d5152ae058 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Fri, 30 Jul 2021 01:08:52 -0400 Subject: [PATCH 046/903] Only allow one Mazda vehicle status request at a time (#53736) --- homeassistant/components/mazda/__init__.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index 921dd4c06c5..c64a3b35993 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -28,7 +28,6 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, UpdateFailed, ) -from homeassistant.util.async_ import gather_with_concurrency from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_VEHICLES, DOMAIN, SERVICES @@ -143,14 +142,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: vehicles = await with_timeout(mazda_client.get_vehicles()) - vehicle_status_tasks = [ - with_timeout(mazda_client.get_vehicle_status(vehicle["id"])) - for vehicle in vehicles - ] - statuses = await gather_with_concurrency(5, *vehicle_status_tasks) - - for vehicle, status in zip(vehicles, statuses): - vehicle["status"] = status + # The Mazda API can throw an error when multiple simultaneous requests are + # made for the same account, so we can only make one request at a time here + for vehicle in vehicles: + vehicle["status"] = await with_timeout( + mazda_client.get_vehicle_status(vehicle["id"]) + ) hass.data[DOMAIN][entry.entry_id][DATA_VEHICLES] = vehicles From 1e33017db8b3c4a644b7680a7fdffe560eaba54f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 30 Jul 2021 07:10:16 +0200 Subject: [PATCH 047/903] Fix Xiaomi humidifier name migration (#53738) --- homeassistant/components/xiaomi_miio/__init__.py | 6 ++---- homeassistant/components/xiaomi_miio/const.py | 1 - homeassistant/components/xiaomi_miio/humidifier.py | 6 +----- homeassistant/components/xiaomi_miio/number.py | 7 +------ homeassistant/components/xiaomi_miio/select.py | 7 +------ homeassistant/components/xiaomi_miio/sensor.py | 9 ++------- homeassistant/components/xiaomi_miio/switch.py | 7 +------ 7 files changed, 8 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 4ca64fc175c..e858c7ee797 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -21,7 +21,6 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, - KEY_MIGRATE_ENTITY_NAME, MODELS_AIR_MONITOR, MODELS_FAN, MODELS_HUMIDIFIER, @@ -112,12 +111,13 @@ async def async_create_miio_device_and_coordinator( else: device = AirHumidifier(host, token, model=model) - # Removing fan platform entity for humidifiers and cache the name and entity name for migration + # Removing fan platform entity for humidifiers and migrate the name to the config entry for migration entity_registry = er.async_get(hass) entity_id = entity_registry.async_get_entity_id("fan", DOMAIN, entry.unique_id) if entity_id: # This check is entities that have a platform migration only and should be removed in the future migrate_entity_name = entity_registry.async_get(entity_id).name + hass.config_entries.async_update_entry(entry, title=migrate_entity_name) entity_registry.async_remove(entity_id) async def async_update_data(): @@ -142,8 +142,6 @@ async def async_create_miio_device_and_coordinator( KEY_DEVICE: device, KEY_COORDINATOR: coordinator, } - if migrate_entity_name: - hass.data[DOMAIN][entry.entry_id][KEY_MIGRATE_ENTITY_NAME] = migrate_entity_name # Trigger first data fetch await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 05499efb5d3..0d8d5bc0014 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -18,7 +18,6 @@ CONF_CLOUD_SUBDEVICES = "cloud_subdevices" # Keys KEY_COORDINATOR = "coordinator" KEY_DEVICE = "device" -KEY_MIGRATE_ENTITY_NAME = "migrate_entity_name" # Attributes ATTR_AVAILABLE = "available" diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 9d9f49229d1..eb45a716254 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -24,7 +24,6 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, - KEY_MIGRATE_ENTITY_NAME, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, @@ -52,10 +51,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = config_entry.data[CONF_MODEL] unique_id = config_entry.unique_id coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - if KEY_MIGRATE_ENTITY_NAME in hass.data[DOMAIN][config_entry.entry_id]: - name = hass.data[DOMAIN][config_entry.entry_id][KEY_MIGRATE_ENTITY_NAME] - else: - name = config_entry.title + name = config_entry.title if model in MODELS_HUMIDIFIER_MIOT: air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 4d0e92b104f..6855faa6391 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -13,7 +13,6 @@ from .const import ( FEATURE_SET_MOTOR_SPEED, KEY_COORDINATOR, KEY_DEVICE, - KEY_MIGRATE_ENTITY_NAME, MODEL_AIRHUMIDIFIER_CA4, ) from .device import XiaomiCoordinatedMiioEntity @@ -58,10 +57,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = config_entry.data[CONF_MODEL] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - if KEY_MIGRATE_ENTITY_NAME in hass.data[DOMAIN][config_entry.entry_id]: - name = hass.data[DOMAIN][config_entry.entry_id][KEY_MIGRATE_ENTITY_NAME] - else: - name = config_entry.title if model not in [MODEL_AIRHUMIDIFIER_CA4]: return @@ -69,7 +64,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for number in NUMBER_TYPES.values(): entities.append( XiaomiAirHumidifierNumber( - f"{name} {number.name}", + f"{config_entry.title} {number.name}", device, config_entry, f"{number.short_name}_{config_entry.unique_id}", diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 055d8073739..77aba961244 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -16,7 +16,6 @@ from .const import ( FEATURE_SET_LED_BRIGHTNESS, KEY_COORDINATOR, KEY_DEVICE, - KEY_MIGRATE_ENTITY_NAME, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, @@ -67,10 +66,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = config_entry.data[CONF_MODEL] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - if KEY_MIGRATE_ENTITY_NAME in hass.data[DOMAIN][config_entry.entry_id]: - name = hass.data[DOMAIN][config_entry.entry_id][KEY_MIGRATE_ENTITY_NAME] - else: - name = config_entry.title if model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: entity_class = XiaomiAirHumidifierSelector @@ -84,7 +79,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for selector in SELECTOR_TYPES.values(): entities.append( entity_class( - f"{name} {selector.name}", + f"{config_entry.title} {selector.name}", device, config_entry, f"{selector.short_name}_{config_entry.unique_id}", diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 3c28d8496e7..413971aa880 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -45,7 +45,6 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, - KEY_MIGRATE_ENTITY_NAME, MODELS_HUMIDIFIER_MIOT, ) from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity @@ -190,11 +189,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = config_entry.data[CONF_MODEL] device = None sensors = [] - if KEY_MIGRATE_ENTITY_NAME in hass.data[DOMAIN][config_entry.entry_id]: - name = hass.data[DOMAIN][config_entry.entry_id][KEY_MIGRATE_ENTITY_NAME] - else: - name = config_entry.title - if model in MODELS_HUMIDIFIER_MIOT: device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] @@ -205,6 +199,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = HUMIDIFIER_SENSORS else: unique_id = config_entry.unique_id + name = config_entry.title _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) device = AirQualityMonitor(host, token) @@ -214,7 +209,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in sensors: entities.append( XiaomiGenericSensor( - f"{name} {sensor.replace('_', ' ').title()}", + f"{config_entry.title} {sensor.replace('_', ' ').title()}", device, config_entry, f"{sensor}_{config_entry.unique_id}", diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 35ac4c7da4f..c86c98de34c 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -41,7 +41,6 @@ from .const import ( FEATURE_SET_DRY, KEY_COORDINATOR, KEY_DEVICE, - KEY_MIGRATE_ENTITY_NAME, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, @@ -235,10 +234,6 @@ async def async_setup_coordinated_entry(hass, config_entry, async_add_entities): unique_id = config_entry.unique_id device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - if KEY_MIGRATE_ENTITY_NAME in hass.data[DOMAIN][config_entry.entry_id]: - name = hass.data[DOMAIN][config_entry.entry_id][KEY_MIGRATE_ENTITY_NAME] - else: - name = config_entry.title if DATA_KEY not in hass.data: hass.data[DATA_KEY] = {} @@ -256,7 +251,7 @@ async def async_setup_coordinated_entry(hass, config_entry, async_add_entities): if feature & device_features: entities.append( XiaomiGenericCoordinatedSwitch( - f"{name} {switch.name}", + f"{config_entry.title} {switch.name}", device, config_entry, f"{switch.short_name}_{unique_id}", From 4a95ed9b7fbc69a495a810ba55130be22490989b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 30 Jul 2021 07:10:41 +0200 Subject: [PATCH 048/903] Fix Xiaomi-miio switch platform setup (#53739) --- homeassistant/components/xiaomi_miio/switch.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index c86c98de34c..bdf3085f236 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -218,13 +218,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the switch from a config entry.""" - if ( - config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY - or config_entry.data[CONF_MODEL] == "lumi.acpartner.v3" - ): - await async_setup_other_entry(hass, config_entry, async_add_entities) - else: + if config_entry.data[CONF_MODEL] in MODELS_HUMIDIFIER: await async_setup_coordinated_entry(hass, config_entry, async_add_entities) + else: + await async_setup_other_entry(hass, config_entry, async_add_entities) async def async_setup_coordinated_entry(hass, config_entry, async_add_entities): From 692665e46c648835c882f38a945d99d8fa8170ff Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 30 Jul 2021 07:11:15 +0200 Subject: [PATCH 049/903] Apply left suggestions #53596 for TP-Link (#53737) --- homeassistant/components/tplink/const.py | 55 ------------- homeassistant/components/tplink/sensor.py | 62 ++++++++++++++- homeassistant/components/tplink/switch.py | 4 +- tests/components/tplink/test_init.py | 95 +++++++---------------- 4 files changed, 91 insertions(+), 125 deletions(-) diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 93cad889a2f..888d671096d 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -3,23 +3,6 @@ from __future__ import annotations import datetime -from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - SensorEntityDescription, -) -from homeassistant.components.switch import ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH -from homeassistant.const import ( - ATTR_VOLTAGE, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_VOLTAGE, - ELECTRIC_CURRENT_AMPERE, - ELECTRIC_POTENTIAL_VOLT, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, -) - DOMAIN = "tplink" COORDINATORS = "coordinators" @@ -41,41 +24,3 @@ CONF_SWITCH = "switch" CONF_SENSOR = "sensor" PLATFORMS = [CONF_LIGHT, CONF_SENSOR, CONF_SWITCH] - -ENERGY_SENSORS: list[SensorEntityDescription] = [ - SensorEntityDescription( - key=ATTR_CURRENT_POWER_W, - unit_of_measurement=POWER_WATT, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - name="Current Consumption", - ), - SensorEntityDescription( - key=ATTR_TOTAL_ENERGY_KWH, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - name="Total Consumption", - ), - SensorEntityDescription( - key=ATTR_TODAY_ENERGY_KWH, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - name="Today's Consumption", - ), - SensorEntityDescription( - key=ATTR_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - name="Voltage", - ), - SensorEntityDescription( - key=ATTR_CURRENT_A, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - name="Current", - ), -] diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 24b93e90963..bb6596b82d1 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -1,18 +1,32 @@ """Support for TPLink HS100/HS110/HS200 smart switch energy sensors.""" from __future__ import annotations -from typing import Any +from typing import Any, Final from pyHS100 import SmartPlug from homeassistant.components.sensor import ( ATTR_LAST_RESET, + STATE_CLASS_MEASUREMENT, SensorEntity, SensorEntityDescription, ) from homeassistant.components.tplink import SmartPlugDataUpdateCoordinator from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_MAC +from homeassistant.const import ( + ATTR_VOLTAGE, + CONF_ALIAS, + CONF_DEVICE_ID, + CONF_MAC, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) from homeassistant.core import HomeAssistant import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import DeviceInfo @@ -29,9 +43,51 @@ from .const import ( CONF_SWITCH, COORDINATORS, DOMAIN as TPLINK_DOMAIN, - ENERGY_SENSORS, ) +ATTR_CURRENT_A = "current_a" +ATTR_CURRENT_POWER_W = "current_power_w" +ATTR_TODAY_ENERGY_KWH = "today_energy_kwh" +ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" + +ENERGY_SENSORS: Final[list[SensorEntityDescription]] = [ + SensorEntityDescription( + key=ATTR_CURRENT_POWER_W, + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + name="Current Consumption", + ), + SensorEntityDescription( + key=ATTR_TOTAL_ENERGY_KWH, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + name="Total Consumption", + ), + SensorEntityDescription( + key=ATTR_TODAY_ENERGY_KWH, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + name="Today's Consumption", + ), + SensorEntityDescription( + key=ATTR_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + name="Voltage", + ), + SensorEntityDescription( + key=ATTR_CURRENT_A, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + name="Current", + ), +] + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 688091991c3..f5319de999a 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -88,10 +88,10 @@ class SmartPlugSwitch(CoordinatorEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.hass.async_add_job(self.smartplug.turn_on) + await self.hass.async_add_executor_job(self.smartplug.turn_on) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.hass.async_add_job(self.smartplug.turn_off) + await self.hass.async_add_executor_job(self.smartplug.turn_off) await self.coordinator.async_refresh() diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 0cfb4d3d233..fb3f44709fc 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1,7 +1,6 @@ """Tests for the TP-Link component.""" from __future__ import annotations -from datetime import datetime import time from typing import Any from unittest.mock import MagicMock, patch @@ -12,31 +11,21 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import tplink -from homeassistant.components.sensor import ATTR_LAST_RESET -from homeassistant.components.switch import ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH from homeassistant.components.tplink.common import SmartDevices from homeassistant.components.tplink.const import ( - ATTR_CURRENT_A, - ATTR_TOTAL_ENERGY_KWH, CONF_DIMMER, CONF_DISCOVERY, - CONF_EMETER_PARAMS, CONF_LIGHT, - CONF_MODEL, CONF_SW_VERSION, CONF_SWITCH, COORDINATORS, ) -from homeassistant.const import ( - ATTR_VOLTAGE, - CONF_ALIAS, - CONF_DEVICE_ID, - CONF_HOST, - CONF_MAC, -) +from homeassistant.components.tplink.sensor import ENERGY_SENSORS +from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utc_from_timestamp +from homeassistant.util import slugify from tests.common import MockConfigEntry, mock_coro from tests.components.tplink.consts import SMARTPLUGSWITCH_DATA, SMARTSTRIPWITCH_DATA @@ -217,20 +206,16 @@ async def test_platforms_are_initialized(hass: HomeAssistant): } } - with patch( - "homeassistant.components.tplink.common.Discover.discover" - ) as discover, patch( + with patch("homeassistant.components.tplink.common.Discover.discover"), patch( "homeassistant.components.tplink.get_static_devices" ) as get_static_devices, patch( "homeassistant.components.tplink.common.SmartDevice._query_helper" ), patch( "homeassistant.components.tplink.light.async_setup_entry", return_value=mock_coro(True), - ) as light_setup, patch( - "homeassistant.components.tplink.switch.async_setup_entry", - return_value=mock_coro(True), - ) as switch_setup, patch( - "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False + ), patch( + "homeassistant.components.tplink.common.SmartPlug.is_dimmable", + False, ): light = SmartBulb("123.123.123.123") @@ -248,47 +233,25 @@ async def test_platforms_are_initialized(hass: HomeAssistant): await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() - assert hass.data.get(tplink.DOMAIN) - assert hass.data[tplink.DOMAIN].get(COORDINATORS) - assert hass.data[tplink.DOMAIN][COORDINATORS].get(switch.mac) - assert isinstance( - hass.data[tplink.DOMAIN][COORDINATORS][switch.mac], - tplink.SmartPlugDataUpdateCoordinator, - ) - data = hass.data[tplink.DOMAIN][COORDINATORS][switch.mac].data - assert data[CONF_HOST] == switch.host - assert data[CONF_MAC] == switch.sys_info["mac"] - assert data[CONF_MODEL] == switch.sys_info["model"] - assert data[CONF_SW_VERSION] == switch.sys_info["sw_ver"] - assert data[CONF_ALIAS] == switch.sys_info["alias"] - assert data[CONF_DEVICE_ID] == switch.sys_info["mac"] + state = hass.states.get(f"switch.{switch.alias}") + assert state + assert state.name == switch.alias - emeter_readings = switch.get_emeter_realtime() - assert data[CONF_EMETER_PARAMS][ATTR_VOLTAGE] == round( - float(emeter_readings["voltage"]), 1 - ) - assert data[CONF_EMETER_PARAMS][ATTR_CURRENT_A] == round( - float(emeter_readings["current"]), 2 - ) - assert data[CONF_EMETER_PARAMS][ATTR_CURRENT_POWER_W] == round( - float(emeter_readings["power"]), 2 - ) - assert data[CONF_EMETER_PARAMS][ATTR_TOTAL_ENERGY_KWH] == round( - float(emeter_readings["total"]), 3 - ) - assert data[CONF_EMETER_PARAMS][ATTR_LAST_RESET][ - ATTR_TOTAL_ENERGY_KWH - ] == utc_from_timestamp(0) + for description in ENERGY_SENSORS: + state = hass.states.get( + f"sensor.{switch.alias}_{slugify(description.name)}" + ) + assert state + assert state.state is not None + assert state.name == f"{switch.alias} {description.name}" - assert data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] == 1.123 - assert data[CONF_EMETER_PARAMS][ATTR_LAST_RESET][ - ATTR_TODAY_ENERGY_KWH - ] == datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - - assert discover.call_count == 0 - assert get_static_devices.call_count == 1 - assert light_setup.call_count == 1 - assert switch_setup.call_count == 1 + device_registry = dr.async_get(hass) + assert len(device_registry.devices) == 1 + device = next(iter(device_registry.devices.values())) + assert device.name == switch.alias + assert device.model == switch.model + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, switch.mac.lower())} + assert device.sw_version == switch.sys_info[CONF_SW_VERSION] async def test_smartplug_without_consumption_sensors(hass: HomeAssistant): @@ -311,8 +274,6 @@ async def test_smartplug_without_consumption_sensors(hass: HomeAssistant): "homeassistant.components.tplink.switch.async_setup_entry", return_value=mock_coro(True), ), patch( - "homeassistant.components.tplink.sensor.SmartPlugSensor.__init__" - ) as SmartPlugSensor, patch( "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False ): @@ -323,7 +284,11 @@ async def test_smartplug_without_consumption_sensors(hass: HomeAssistant): await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() - assert SmartPlugSensor.call_count == 0 + for description in ENERGY_SENSORS: + state = hass.states.get( + f"sensor.{switch.alias}_{slugify(description.name)}" + ) + assert state is None async def test_smartstrip_device(hass: HomeAssistant): From 87dab02ce65a49203a555fd8da69f5ae3623cdec Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Jul 2021 07:12:00 +0200 Subject: [PATCH 050/903] Remove YAML configuration from onewire (#53728) --- .../components/onewire/config_flow.py | 13 -- homeassistant/components/onewire/sensor.py | 46 +----- tests/components/onewire/__init__.py | 3 + tests/components/onewire/test_config_flow.py | 135 +----------------- tests/components/onewire/test_sensor.py | 17 +-- 5 files changed, 11 insertions(+), 203 deletions(-) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 468aa6b9acf..20b76ff236b 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -164,16 +164,3 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=DATA_SCHEMA_MOUNTDIR, errors=errors, ) - - async def async_step_import(self, platform_config: dict[str, Any]) -> FlowResult: - """Handle import configuration from YAML.""" - # OWServer - if platform_config[CONF_TYPE] == CONF_TYPE_OWSERVER: - if CONF_PORT not in platform_config: - platform_config[CONF_PORT] = DEFAULT_OWSERVER_PORT - return await self.async_step_owserver(platform_config) - - # SysBus - if CONF_MOUNT_DIR not in platform_config: - platform_config[CONF_MOUNT_DIR] = DEFAULT_SYSBUS_MOUNT_DIR - return await self.async_step_mount_dir(platform_config) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 3b63f551f98..97291f0bbcc 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -8,24 +8,20 @@ from types import MappingProxyType from typing import Any from pi1wire import InvalidCRCException, OneWireInterface, UnsupportResponseException -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType from .const import ( CONF_MOUNT_DIR, CONF_NAMES, CONF_TYPE_OWSERVER, CONF_TYPE_SYSBUS, - DEFAULT_OWSERVER_PORT, - DEFAULT_SYSBUS_MOUNT_DIR, DOMAIN, SENSOR_TYPE_COUNT, SENSOR_TYPE_CURRENT, @@ -233,16 +229,6 @@ EDS_SENSORS: dict[str, list[DeviceComponentDescription]] = { } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAMES): {cv.string: cv.string}, - vol.Optional(CONF_MOUNT_DIR, default=DEFAULT_SYSBUS_MOUNT_DIR): cv.string, - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_OWSERVER_PORT): cv.port, - } -) - - def get_sensor_types(device_sub_type: str) -> dict[str, Any]: """Return the proper info array for the device type.""" if "HobbyBoard" in device_sub_type: @@ -252,30 +238,6 @@ def get_sensor_types(device_sub_type: str) -> dict[str, Any]: return DEVICE_SENSORS -async def async_setup_platform( - hass: HomeAssistant, - config: dict[str, Any], - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Old way of setting up 1-Wire platform.""" - _LOGGER.warning( - "Loading 1-Wire via platform setup is deprecated. " - "Please remove it from your configuration" - ) - - if config.get(CONF_HOST): - config[CONF_TYPE] = CONF_TYPE_OWSERVER - elif config[CONF_MOUNT_DIR] == DEFAULT_SYSBUS_MOUNT_DIR: - config[CONF_TYPE] = CONF_TYPE_SYSBUS - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 1712a5500dd..0ca9b55c41a 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -30,6 +30,9 @@ async def setup_onewire_sysbus_integration(hass): data={ CONF_TYPE: CONF_TYPE_SYSBUS, CONF_MOUNT_DIR: DEFAULT_SYSBUS_MOUNT_DIR, + "names": { + "10-111111111111": "My DS18B20", + }, }, unique_id=f"{CONF_TYPE_SYSBUS}:{DEFAULT_SYSBUS_MOUNT_DIR}", options={}, diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 66025770f41..d83e9203270 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -7,11 +7,10 @@ from homeassistant.components.onewire.const import ( CONF_MOUNT_DIR, CONF_TYPE_OWSERVER, CONF_TYPE_SYSBUS, - DEFAULT_OWSERVER_PORT, DEFAULT_SYSBUS_MOUNT_DIR, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -201,135 +200,3 @@ async def test_user_sysbus_duplicate(hass): assert result["reason"] == "already_configured" await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_sysbus(hass): - """Test import step.""" - - with patch( - "homeassistant.components.onewire.onewirehub.os.path.isdir", - return_value=True, - ), patch( - "homeassistant.components.onewire.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_TYPE: CONF_TYPE_SYSBUS}, - ) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_SYSBUS_MOUNT_DIR - assert result["data"] == { - CONF_TYPE: CONF_TYPE_SYSBUS, - CONF_MOUNT_DIR: DEFAULT_SYSBUS_MOUNT_DIR, - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_sysbus_with_mount_dir(hass): - """Test import step.""" - - with patch( - "homeassistant.components.onewire.onewirehub.os.path.isdir", - return_value=True, - ), patch( - "homeassistant.components.onewire.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_TYPE: CONF_TYPE_SYSBUS, - CONF_MOUNT_DIR: DEFAULT_SYSBUS_MOUNT_DIR, - }, - ) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_SYSBUS_MOUNT_DIR - assert result["data"] == { - CONF_TYPE: CONF_TYPE_SYSBUS, - CONF_MOUNT_DIR: DEFAULT_SYSBUS_MOUNT_DIR, - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_owserver(hass): - """Test import step.""" - - with patch("homeassistant.components.onewire.onewirehub.protocol.proxy",), patch( - "homeassistant.components.onewire.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_TYPE: CONF_TYPE_OWSERVER, - CONF_HOST: "1.2.3.4", - }, - ) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "1.2.3.4" - assert result["data"] == { - CONF_TYPE: CONF_TYPE_OWSERVER, - CONF_HOST: "1.2.3.4", - CONF_PORT: DEFAULT_OWSERVER_PORT, - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_owserver_with_port(hass): - """Test import step.""" - - with patch("homeassistant.components.onewire.onewirehub.protocol.proxy",), patch( - "homeassistant.components.onewire.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_TYPE: CONF_TYPE_OWSERVER, - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - }, - ) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "1.2.3.4" - assert result["data"] == { - CONF_TYPE: CONF_TYPE_OWSERVER, - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_owserver_duplicate(hass): - """Test OWServer flow.""" - # Initialise with single entry - with patch( - "homeassistant.components.onewire.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - await setup_onewire_owserver_integration(hass) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - # Import duplicate entry - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_TYPE: CONF_TYPE_OWSERVER, - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - }, - ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index f3063dfc128..f3f4b8e7c9d 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -14,6 +14,7 @@ from homeassistant.setup import async_setup_component from . import ( setup_onewire_patched_owserver_integration, + setup_onewire_sysbus_integration, setup_owproxy_mock_devices, setup_sysbus_mock_devices, ) @@ -25,16 +26,6 @@ MOCK_COUPLERS = { key: value for (key, value) in MOCK_OWPROXY_DEVICES.items() if "branches" in value } -MOCK_SYSBUS_CONFIG = { - SENSOR_DOMAIN: { - "platform": DOMAIN, - "mount_dir": DEFAULT_SYSBUS_MOUNT_DIR, - "names": { - "10-111111111111": "My DS18B20", - }, - } -} - async def test_setup_minimum(hass): """Test old platform setup with minimum configuration.""" @@ -200,13 +191,11 @@ async def test_onewiredirect_setup_valid_device(hass, device_id): mock_device = MOCK_SYSBUS_DEVICES[device_id] expected_entities = mock_device.get(SENSOR_DOMAIN, []) - with patch( - "homeassistant.components.onewire.onewirehub.os.path.isdir", return_value=True - ), patch("pi1wire._finder.glob.glob", return_value=glob_result,), patch( + with patch("pi1wire._finder.glob.glob", return_value=glob_result,), patch( "pi1wire.OneWire.get_temperature", side_effect=read_side_effect, ): - assert await async_setup_component(hass, SENSOR_DOMAIN, MOCK_SYSBUS_CONFIG) + assert await setup_onewire_sysbus_integration(hass) await hass.async_block_till_done() assert len(entity_registry.entities) == len(expected_entities) From 05a78537200a5ad01b8c0164ee1fb64c01f5b4b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Jul 2021 23:34:03 -0700 Subject: [PATCH 051/903] Add energy attributes to Fronius (#53741) * Add energy attributes to Fronius * Add solar * Add power * Only add last reset for total meter entities * Only add last reset for total solar entities * Create different entity descriptions per key * only return the entity description for energy total * Use correct key * Meter devices keep it real * keys start with energy_real * Also device key starts with * Lint --- homeassistant/components/fronius/sensor.py | 117 ++++++++++++++++----- 1 file changed, 88 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index ac006638912..6f949334d02 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -8,17 +8,26 @@ import logging from pyfronius import Fronius import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_DEVICE, CONF_MONITORED_CONDITIONS, CONF_RESOURCE, CONF_SCAN_INTERVAL, CONF_SENSOR_TYPE, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, ) +from homeassistant.core import callback 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.util import dt _LOGGER = logging.getLogger(__name__) @@ -152,6 +161,12 @@ class FroniusAdapter: """Whether the fronius device is active.""" return self._available + def entity_description( # pylint: disable=no-self-use + self, key + ) -> SensorEntityDescription | None: + """Create entity description for a key.""" + return None + async def async_update(self): """Retrieve and update latest state.""" try: @@ -198,14 +213,28 @@ class FroniusAdapter: async def _update(self) -> dict: """Return values of interest.""" - async def register(self, sensor): + @callback + def register(self, sensor): """Register child sensor for update subscriptions.""" self._registered_sensors.add(sensor) + return lambda: self._registered_sensors.remove(sensor) class FroniusInverterSystem(FroniusAdapter): """Adapter for the fronius inverter with system scope.""" + def entity_description(self, key): + """Return the entity descriptor.""" + if key != "energy_total": + return None + + return SensorEntityDescription( + key=key, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + ) + async def _update(self): """Get the values for the current state.""" return await self.bridge.current_system_inverter_data() @@ -214,6 +243,18 @@ class FroniusInverterSystem(FroniusAdapter): class FroniusInverterDevice(FroniusAdapter): """Adapter for the fronius inverter with device scope.""" + def entity_description(self, key): + """Return the entity descriptor.""" + if key != "energy_total": + return None + + return SensorEntityDescription( + key=key, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + ) + async def _update(self): """Get the values for the current state.""" return await self.bridge.current_inverter_data(self._device) @@ -230,6 +271,18 @@ class FroniusStorage(FroniusAdapter): class FroniusMeterSystem(FroniusAdapter): """Adapter for the fronius meter with system scope.""" + def entity_description(self, key): + """Return the entity descriptor.""" + if not key.startswith("energy_real_"): + return None + + return SensorEntityDescription( + key=key, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + ) + async def _update(self): """Get the values for the current state.""" return await self.bridge.current_system_meter_data() @@ -238,6 +291,18 @@ class FroniusMeterSystem(FroniusAdapter): class FroniusMeterDevice(FroniusAdapter): """Adapter for the fronius meter with device scope.""" + def entity_description(self, key): + """Return the entity descriptor.""" + if not key.startswith("energy_real_"): + return None + + return SensorEntityDescription( + key=key, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + ) + async def _update(self): """Get the values for the current state.""" return await self.bridge.current_meter_data(self._device) @@ -246,6 +311,14 @@ class FroniusMeterDevice(FroniusAdapter): class FroniusPowerFlow(FroniusAdapter): """Adapter for the fronius power flow.""" + def entity_description(self, key): + """Return the entity descriptor.""" + return SensorEntityDescription( + key=key, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ) + async def _update(self): """Get the values for the current state.""" return await self.bridge.current_power_flow() @@ -254,27 +327,13 @@ class FroniusPowerFlow(FroniusAdapter): class FroniusTemplateSensor(SensorEntity): """Sensor for the single values (e.g. pv power, ac power).""" - def __init__(self, parent: FroniusAdapter, name): + def __init__(self, parent: FroniusAdapter, key): """Initialize a singular value sensor.""" - self._name = name - self.parent = parent - self._state = None - self._unit = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name.replace('_', ' ').capitalize()} {self.parent.name}" - - @property - def state(self): - """Return the current state.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit + self._key = key + self._attr_name = f"{key.replace('_', ' ').capitalize()} {parent.name}" + self._parent = parent + if entity_description := parent.entity_description(key): + self.entity_description = entity_description @property def should_poll(self): @@ -284,19 +343,19 @@ class FroniusTemplateSensor(SensorEntity): @property def available(self): """Whether the fronius device is active.""" - return self.parent.available + return self._parent.available async def async_update(self): """Update the internal state.""" - state = self.parent.data.get(self._name) - self._state = state.get("value") - if isinstance(self._state, float): - self._state = round(self._state, 2) - self._unit = state.get("unit") + state = self._parent.data.get(self._key) + self._attr_state = state.get("value") + if isinstance(self._attr_state, float): + self._attr_state = round(self._attr_state, 2) + self._attr_unit_of_measurement = state.get("unit") async def async_added_to_hass(self): """Register at parent component for updates.""" - await self.parent.register(self) + self.async_on_remove(self._parent.register(self)) def __hash__(self): """Hash sensor by hashing its name.""" From 2cbcd5f2a909d6fb04dc9d03deb6940ba05109f8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Jul 2021 02:00:52 -0700 Subject: [PATCH 052/903] Cost sensor handle consumption sensor in Wh (#53746) --- homeassistant/components/energy/sensor.py | 24 ++++++ tests/components/energy/test_sensor.py | 89 +++++++++++++++++++++-- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 1c42ea5a050..e974035cbd6 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from functools import partial +import logging from typing import Any, Final, Literal, TypeVar, cast from homeassistant.components.sensor import ( @@ -11,6 +12,11 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, SensorEntity, ) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, +) from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -20,6 +26,8 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN from .data import EnergyManager, async_get_manager +_LOGGER = logging.getLogger(__name__) + async def async_setup_platform( hass: HomeAssistant, @@ -188,6 +196,12 @@ class EnergyCostSensor(SensorEntity): energy_price = float(energy_price_state.state) except ValueError: return + + if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith( + f"/{ENERGY_WATT_HOUR}" + ): + energy_price *= 1000.0 + else: energy_price_state = None energy_price = cast(float, self._flow["number_energy_price"]) @@ -197,6 +211,16 @@ class EnergyCostSensor(SensorEntity): self._reset(energy_state) return + energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + if energy_unit == ENERGY_WATT_HOUR: + energy_price /= 1000 + elif energy_unit != ENERGY_KILO_WATT_HOUR: + _LOGGER.warning( + "Found unexpected unit %s for %s", energy_unit, energy_state.entity_id + ) + return + if ( energy_state.attributes[ATTR_LAST_RESET] != self._last_energy_sensor_state.attributes[ATTR_LAST_RESET] diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index f3af93c06c1..978b21e1919 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -16,6 +16,8 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, DEVICE_CLASS_MONETARY, + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, ) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -133,7 +135,9 @@ async def test_cost_sensor_price_entity( # Optionally initialize dependent entities if initial_energy is not None: hass.states.async_set( - usage_sensor_entity_id, initial_energy, {"last_reset": last_reset} + usage_sensor_entity_id, + initial_energy, + {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, ) hass.states.async_set("sensor.energy_price", "1") @@ -152,7 +156,12 @@ async def test_cost_sensor_price_entity( if initial_energy is None: with patch("homeassistant.util.dt.utcnow", return_value=now): hass.states.async_set( - usage_sensor_entity_id, "0", {"last_reset": last_reset} + usage_sensor_entity_id, + "0", + { + "last_reset": last_reset, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + }, ) await hass.async_block_till_done() @@ -169,7 +178,11 @@ async def test_cost_sensor_price_entity( # # assert entry.unique_id == "energy_energy_consumption cost" # Energy use bumped to 10 kWh - hass.states.async_set(usage_sensor_entity_id, "10", {"last_reset": last_reset}) + hass.states.async_set( + usage_sensor_entity_id, + "10", + {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR @@ -189,7 +202,11 @@ async def test_cost_sensor_price_entity( assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR # Additional consumption is using the new price - hass.states.async_set(usage_sensor_entity_id, "14.5", {"last_reset": last_reset}) + hass.states.async_set( + usage_sensor_entity_id, + "14.5", + {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR @@ -202,13 +219,21 @@ async def test_cost_sensor_price_entity( # Energy sensor is reset, with start point at 4kWh last_reset = (now + timedelta(seconds=1)).isoformat() - hass.states.async_set(usage_sensor_entity_id, "4", {"last_reset": last_reset}) + hass.states.async_set( + usage_sensor_entity_id, + "4", + {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" # 0 EUR + (4-4) kWh * 2 EUR/kWh = 0 EUR # Energy use bumped to 10 kWh - hass.states.async_set(usage_sensor_entity_id, "10", {"last_reset": last_reset}) + hass.states.async_set( + usage_sensor_entity_id, + "10", + {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "12.0" # 0 EUR + (10-4) kWh * 2 EUR/kWh = 12 EUR @@ -218,3 +243,55 @@ async def test_cost_sensor_price_entity( statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) assert cost_sensor_entity_id in statistics assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 31.0 + + +async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: + """Test energy cost price from sensor entity.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + last_reset = dt_util.utc_from_timestamp(0).isoformat() + + hass.states.async_set( + "sensor.energy_consumption", + 10000, + {"last_reset": last_reset, "unit_of_measurement": ENERGY_WATT_HOUR}, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Energy use bumped to 10 kWh + hass.states.async_set( + "sensor.energy_consumption", + 20000, + {"last_reset": last_reset, "unit_of_measurement": ENERGY_WATT_HOUR}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "5.0" From bb7236fd048988793501b1c6b95b997ea2b4f963 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Jul 2021 13:35:49 +0200 Subject: [PATCH 053/903] Use constants for device_info in Onewire integration (#53724) --- .../components/onewire/binary_sensor.py | 16 +- homeassistant/components/onewire/sensor.py | 24 ++- homeassistant/components/onewire/switch.py | 16 +- tests/components/onewire/const.py | 190 +++++++++--------- tests/components/onewire/test_sensor.py | 13 +- 5 files changed, 142 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 9671a787c41..5b73a8c8873 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -5,7 +5,13 @@ import os from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + CONF_TYPE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -113,10 +119,10 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: if family not in DEVICE_BINARY_SENSORS: continue device_info: DeviceInfo = { - "identifiers": {(DOMAIN, device_id)}, - "manufacturer": "Maxim Integrated", - "model": device_type, - "name": device_id, + ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, + ATTR_MANUFACTURER: "Maxim Integrated", + ATTR_MODEL: device_type, + ATTR_NAME: device_id, } for entity_specs in DEVICE_BINARY_SENSORS[family]: entity_path = os.path.join( diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 97291f0bbcc..024d540c10a 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -11,7 +11,13 @@ from pi1wire import InvalidCRCException, OneWireInterface, UnsupportResponseExce from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + CONF_TYPE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -288,10 +294,10 @@ def get_entities( ) continue device_info: DeviceInfo = { - "identifiers": {(DOMAIN, device_id)}, - "manufacturer": "Maxim Integrated", - "model": device_type, - "name": device_id, + ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, + ATTR_MANUFACTURER: "Maxim Integrated", + ATTR_MODEL: device_type, + ATTR_NAME: device_id, } for entity_specs in get_sensor_types(device_sub_type)[family]: if entity_specs["type"] == SENSOR_TYPE_MOISTURE: @@ -334,10 +340,10 @@ def get_entities( continue device_info = { - "identifiers": {(DOMAIN, sensor_id)}, - "manufacturer": "Maxim Integrated", - "model": family, - "name": sensor_id, + ATTR_IDENTIFIERS: {(DOMAIN, sensor_id)}, + ATTR_MANUFACTURER: "Maxim Integrated", + ATTR_MODEL: family, + ATTR_NAME: sensor_id, } device_file = f"/sys/bus/w1/devices/{sensor_id}/w1_slave" entities.append( diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 228c8f9d78b..b5177bfca15 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -7,7 +7,13 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + CONF_TYPE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -186,10 +192,10 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: continue device_info: DeviceInfo = { - "identifiers": {(DOMAIN, device_id)}, - "manufacturer": "Maxim Integrated", - "model": device_type, - "name": device_id, + ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, + ATTR_MANUFACTURER: "Maxim Integrated", + ATTR_MODEL: device_type, + ATTR_NAME: device_id, } for entity_specs in DEVICE_SWITCHES[family]: entity_path = os.path.join( diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 5c12571fc1e..8a20d4fb0aa 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -8,6 +8,10 @@ from homeassistant.components.onewire.const import DOMAIN, PRESSURE_CBAR from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, DEVICE_CLASS_CURRENT, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -24,6 +28,8 @@ from homeassistant.const import ( TEMP_CELSIUS, ) +MANUFACTURER = "Maxim Integrated" + MOCK_OWPROXY_DEVICES = { "00.111111111111": { "inject_reads": [ @@ -36,10 +42,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2405", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "05.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2405", - "name": "05.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "05.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2405", + ATTR_NAME: "05.111111111111", }, SWITCH_DOMAIN: [ { @@ -58,10 +64,10 @@ MOCK_OWPROXY_DEVICES = { b"DS18S20", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "10.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS18S20", - "name": "10.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "10.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS18S20", + ATTR_NAME: "10.111111111111", }, SENSOR_DOMAIN: [ { @@ -79,10 +85,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2406", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "12.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2406", - "name": "12.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "12.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2406", + ATTR_NAME: "12.111111111111", }, BINARY_SENSOR_DOMAIN: [ { @@ -168,10 +174,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2423", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "1D.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2423", - "name": "1D.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "1D.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2423", + ATTR_NAME: "1D.111111111111", }, SENSOR_DOMAIN: [ { @@ -197,10 +203,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2409", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "1F.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2409", - "name": "1F.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "1F.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2409", + ATTR_NAME: "1F.111111111111", }, "branches": { "aux": {}, @@ -210,10 +216,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2423", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "1D.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2423", - "name": "1D.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "1D.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2423", + ATTR_NAME: "1D.111111111111", }, SENSOR_DOMAIN: [ { @@ -244,10 +250,10 @@ MOCK_OWPROXY_DEVICES = { b"DS1822", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "22.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS1822", - "name": "22.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "22.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS1822", + ATTR_NAME: "22.111111111111", }, SENSOR_DOMAIN: [ { @@ -265,10 +271,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2438", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "26.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2438", - "name": "26.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "26.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2438", + ATTR_NAME: "26.111111111111", }, SENSOR_DOMAIN: [ { @@ -376,10 +382,10 @@ MOCK_OWPROXY_DEVICES = { b"DS18B20", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "28.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS18B20", - "name": "28.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "28.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS18B20", + ATTR_NAME: "28.111111111111", }, SENSOR_DOMAIN: [ { @@ -397,10 +403,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2408", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "29.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2408", - "name": "29.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "29.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2408", + ATTR_NAME: "29.111111111111", }, BINARY_SENSOR_DOMAIN: [ { @@ -628,10 +634,10 @@ MOCK_OWPROXY_DEVICES = { b"DS1825", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "3B.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS1825", - "name": "3B.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "3B.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS1825", + ATTR_NAME: "3B.111111111111", }, SENSOR_DOMAIN: [ { @@ -649,10 +655,10 @@ MOCK_OWPROXY_DEVICES = { b"DS28EA00", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "42.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS28EA00", - "name": "42.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "42.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS28EA00", + ATTR_NAME: "42.111111111111", }, SENSOR_DOMAIN: [ { @@ -670,10 +676,10 @@ MOCK_OWPROXY_DEVICES = { b"HobbyBoards_EF", # read type ], "device_info": { - "identifiers": {(DOMAIN, "EF.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "HobbyBoards_EF", - "name": "EF.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "HobbyBoards_EF", + ATTR_NAME: "EF.111111111111", }, SENSOR_DOMAIN: [ { @@ -711,10 +717,10 @@ MOCK_OWPROXY_DEVICES = { b" 0", # read is_leaf_3 ], "device_info": { - "identifiers": {(DOMAIN, "EF.111111111112")}, - "manufacturer": "Maxim Integrated", - "model": "HB_MOISTURE_METER", - "name": "EF.111111111112", + ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111112")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "HB_MOISTURE_METER", + ATTR_NAME: "EF.111111111112", }, SENSOR_DOMAIN: [ { @@ -757,10 +763,10 @@ MOCK_OWPROXY_DEVICES = { b"EDS0068", # read device_type - note EDS specific ], "device_info": { - "identifiers": {(DOMAIN, "7E.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "EDS", - "name": "7E.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "7E.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "EDS", + ATTR_NAME: "7E.111111111111", }, SENSOR_DOMAIN: [ { @@ -803,10 +809,10 @@ MOCK_OWPROXY_DEVICES = { b"EDS0066", # read device_type - note EDS specific ], "device_info": { - "identifiers": {(DOMAIN, "7E.222222222222")}, - "manufacturer": "Maxim Integrated", - "model": "EDS", - "name": "7E.222222222222", + ATTR_IDENTIFIERS: {(DOMAIN, "7E.222222222222")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "EDS", + ATTR_NAME: "7E.222222222222", }, SENSOR_DOMAIN: [ { @@ -833,10 +839,10 @@ MOCK_SYSBUS_DEVICES = { "00-111111111111": {SENSOR_DOMAIN: []}, "10-111111111111": { "device_info": { - "identifiers": {(DOMAIN, "10-111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "10", - "name": "10-111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "10-111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "10", + ATTR_NAME: "10-111111111111", }, SENSOR_DOMAIN: [ { @@ -853,10 +859,10 @@ MOCK_SYSBUS_DEVICES = { "1D-111111111111": {SENSOR_DOMAIN: []}, "22-111111111111": { "device_info": { - "identifiers": {(DOMAIN, "22-111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "22", - "name": "22-111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "22-111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "22", + ATTR_NAME: "22-111111111111", }, "sensor": [ { @@ -872,10 +878,10 @@ MOCK_SYSBUS_DEVICES = { "26-111111111111": {SENSOR_DOMAIN: []}, "28-111111111111": { "device_info": { - "identifiers": {(DOMAIN, "28-111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "28", - "name": "28-111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "28-111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "28", + ATTR_NAME: "28-111111111111", }, SENSOR_DOMAIN: [ { @@ -891,10 +897,10 @@ MOCK_SYSBUS_DEVICES = { "29-111111111111": {SENSOR_DOMAIN: []}, "3B-111111111111": { "device_info": { - "identifiers": {(DOMAIN, "3B-111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "3B", - "name": "3B-111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "3B-111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "3B", + ATTR_NAME: "3B-111111111111", }, SENSOR_DOMAIN: [ { @@ -909,10 +915,10 @@ MOCK_SYSBUS_DEVICES = { }, "42-111111111111": { "device_info": { - "identifiers": {(DOMAIN, "42-111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "42", - "name": "42-111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "42-111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "42", + ATTR_NAME: "42-111111111111", }, SENSOR_DOMAIN: [ { @@ -927,10 +933,10 @@ MOCK_SYSBUS_DEVICES = { }, "42-111111111112": { "device_info": { - "identifiers": {(DOMAIN, "42-111111111112")}, - "manufacturer": "Maxim Integrated", - "model": "42", - "name": "42-111111111112", + ATTR_IDENTIFIERS: {(DOMAIN, "42-111111111112")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "42", + ATTR_NAME: "42-111111111112", }, SENSOR_DOMAIN: [ { @@ -945,10 +951,10 @@ MOCK_SYSBUS_DEVICES = { }, "42-111111111113": { "device_info": { - "identifiers": {(DOMAIN, "42-111111111113")}, - "manufacturer": "Maxim Integrated", - "model": "42", - "name": "42-111111111113", + ATTR_IDENTIFIERS: {(DOMAIN, "42-111111111113")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "42", + ATTR_NAME: "42-111111111113", }, SENSOR_DOMAIN: [ { diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index f3f4b8e7c9d..77570f055b4 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.onewire.const import ( PLATFORMS, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME from homeassistant.setup import async_setup_component from . import ( @@ -155,9 +156,9 @@ async def test_owserver_setup_valid_device(owproxy, hass, device_id, platform): registry_entry = device_registry.async_get_device({(DOMAIN, device_id)}) assert registry_entry is not None assert registry_entry.identifiers == {(DOMAIN, device_id)} - assert registry_entry.manufacturer == device_info["manufacturer"] - assert registry_entry.name == device_info["name"] - assert registry_entry.model == device_info["model"] + assert registry_entry.manufacturer == device_info[ATTR_MANUFACTURER] + assert registry_entry.name == device_info[ATTR_NAME] + assert registry_entry.model == device_info[ATTR_MODEL] for expected_entity in expected_entities: entity_id = expected_entity["entity_id"] @@ -206,9 +207,9 @@ async def test_onewiredirect_setup_valid_device(hass, device_id): registry_entry = device_registry.async_get_device({(DOMAIN, device_id)}) assert registry_entry is not None assert registry_entry.identifiers == {(DOMAIN, device_id)} - assert registry_entry.manufacturer == device_info["manufacturer"] - assert registry_entry.name == device_info["name"] - assert registry_entry.model == device_info["model"] + assert registry_entry.manufacturer == device_info[ATTR_MANUFACTURER] + assert registry_entry.name == device_info[ATTR_NAME] + assert registry_entry.model == device_info[ATTR_MODEL] for expected_sensor in expected_entities: entity_id = expected_sensor["entity_id"] From b3f0d6840ce686e3ddaad5a2c29cc6f1cb35c6d6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Jul 2021 14:06:55 +0200 Subject: [PATCH 054/903] Use constants for device_info in Renault integration (#53714) * Use constants for device_info * Fix isort --- .../components/renault/renault_vehicle.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 09e3de9adab..d3f6b6e48be 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -9,6 +9,13 @@ from typing import cast from renault_api.kamereon import models from renault_api.renault_vehicle import RenaultVehicle +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo @@ -33,11 +40,11 @@ class RenaultVehicleProxy: self._vehicle = vehicle self._details = details self._device_info: DeviceInfo = { - "identifiers": {(DOMAIN, cast(str, details.vin))}, - "manufacturer": (details.get_brand_label() or "").capitalize(), - "model": (details.get_model_label() or "").capitalize(), - "name": details.registrationNumber or "", - "sw_version": details.get_model_code() or "", + ATTR_IDENTIFIERS: {(DOMAIN, cast(str, details.vin))}, + ATTR_MANUFACTURER: (details.get_brand_label() or "").capitalize(), + ATTR_MODEL: (details.get_model_label() or "").capitalize(), + ATTR_NAME: details.registrationNumber or "", + ATTR_SW_VERSION: details.get_model_code() or "", } self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} self.hvac_target_temperature = 21 From 028f6c4cac3371612c5fd5dd19d8fa16ca6eed43 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 30 Jul 2021 15:11:58 +0100 Subject: [PATCH 055/903] fix flakky test (#53750) --- tests/components/prosegur/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/prosegur/test_config_flow.py b/tests/components/prosegur/test_config_flow.py index bece0bae621..f345d4c7fa3 100644 --- a/tests/components/prosegur/test_config_flow.py +++ b/tests/components/prosegur/test_config_flow.py @@ -60,7 +60,7 @@ async def test_form_invalid_auth(hass): ) with patch( - "pyprosegur.auth.Auth", + "pyprosegur.installation.Installation", side_effect=ConnectionRefusedError, ): result2 = await hass.config_entries.flow.async_configure( From 7a200a5d3b138abf147c241195245a0d822f4d68 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Fri, 30 Jul 2021 23:02:33 +0800 Subject: [PATCH 056/903] Fix non monotonic dts error in stream (#53712) * Use defaultdict for TimestampValidator._last_dts * Combine filters * Allow PeekIterator to be updated while preserving buffer * Fix peek edge case * Readd is_valid filter to video only iterator --- homeassistant/components/stream/worker.py | 26 +++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 72625fa0f5a..69def43b2a2 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -1,7 +1,7 @@ """Provides the worker thread needed for processing streams.""" from __future__ import annotations -from collections import deque +from collections import defaultdict, deque from collections.abc import Generator, Iterator, Mapping from io import BytesIO import logging @@ -222,6 +222,12 @@ class PeekIterator(Iterator): """Return and consume the next item available.""" return self._next() + def replace_underlying_iterator(self, new_iterator: Iterator) -> None: + """Replace the underlying iterator while preserving the buffer.""" + self._iterator = new_iterator + if self._next is not self._pop_buffer: + self._next = self._iterator.__next__ + def _pop_buffer(self) -> av.Packet: """Consume items from the buffer until exhausted.""" if self._buffer: @@ -248,7 +254,9 @@ class TimestampValidator: def __init__(self) -> None: """Initialize the TimestampValidator.""" # Decompression timestamp of last packet in each stream - self._last_dts: dict[av.stream.Stream, float] = {} + self._last_dts: dict[av.stream.Stream, int | float] = defaultdict( + lambda: float("-inf") + ) # Number of consecutive missing decompression timestamps self._missing_dts = 0 @@ -264,7 +272,7 @@ class TimestampValidator: return False self._missing_dts = 0 # Discard when dts is not monotonic. Terminate if gap is too wide. - prev_dts = self._last_dts.get(packet.stream, float("-inf")) + prev_dts = self._last_dts[packet.stream] if packet.dts <= prev_dts: gap = packet.time_base * (prev_dts - packet.dts) if gap > MAX_TIMESTAMP_GAP: @@ -350,19 +358,25 @@ def stream_worker( try: if audio_stream and unsupported_audio(container_packets.peek(), audio_stream): audio_stream = None - container_packets = PeekIterator( + container_packets.replace_underlying_iterator( filter(dts_validator.is_valid, container.demux(video_stream)) ) # Advance to the first keyframe for muxing, then rewind so the muxing # loop below can consume. - first_keyframe = next(filter(is_keyframe, filter(is_video, container_packets))) + first_keyframe = next( + filter(lambda pkt: is_keyframe(pkt) and is_video(pkt), container_packets) + ) # Deal with problem #1 above (bad first packet pts/dts) by recalculating # using pts/dts from second packet. Use the peek iterator to advance # without consuming from container_packets. Skip over the first keyframe # then use the duration from the second video packet to adjust dts. next_video_packet = next(filter(is_video, container_packets.peek())) - start_dts = next_video_packet.dts - next_video_packet.duration + # Since the is_valid filter has already been applied before the following + # adjustment, it does not filter out the case where the duration below is + # 0 and both the first_keyframe and next_video_packet end up with the same + # dts. Use "or 1" to deal with this. + start_dts = next_video_packet.dts - (next_video_packet.duration or 1) first_keyframe.dts = first_keyframe.pts = start_dts except (av.AVError, StopIteration) as ex: _LOGGER.error("Error demuxing stream while finding first packet: %s", str(ex)) From b0c650e088de4065875285031f2e47e4e6db1ac1 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 30 Jul 2021 19:23:16 +0200 Subject: [PATCH 057/903] Update integration Fints with activate mypy, use attr_variables (#53706) * Please mypy. * Convert property to _attr_variables. --- homeassistant/components/fints/sensor.py | 79 +++++------------------- mypy.ini | 3 - script/hassfest/mypy_config.py | 1 - 3 files changed, 17 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 3487444c735..9159e0df49a 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -1,8 +1,10 @@ """Read the balance of your bank accounts via FinTS.""" +from __future__ import annotations from collections import namedtuple from datetime import timedelta import logging +from typing import Any from fints.client import FinTS3PinTanClient from fints.dialog import FinTSDialogError @@ -164,46 +166,23 @@ class FinTsAccount(SensorEntity): """Initialize a FinTs balance account.""" self._client = client self._account = account - self._name = name - self._balance: float = None - self._currency: str = None + self._attr_name = name + self._attr_icon = ICON + self._attr_extra_state_attributes = { + ATTR_ACCOUNT: self._account.iban, + ATTR_ACCOUNT_TYPE: "balance", + } + if self._client.name: + self._attr_extra_state_attributes[ATTR_BANK] = self._client.name def update(self) -> None: """Get the current balance and currency for the account.""" bank = self._client.client balance = bank.get_balance(self._account) - self._balance = balance.amount.amount - self._currency = balance.amount.currency + self._attr_state = balance.amount.amount + self._attr_unit_of_measurement = balance.amount.currency _LOGGER.debug("updated balance of account %s", self.name) - @property - def name(self) -> str: - """Friendly name of the sensor.""" - return self._name - - @property - def state(self) -> float: - """Return the balance of the account as state.""" - return self._balance - - @property - def unit_of_measurement(self) -> str: - """Use the currency as unit of measurement.""" - return self._currency - - @property - def extra_state_attributes(self) -> dict: - """Additional attributes of the sensor.""" - attributes = {ATTR_ACCOUNT: self._account.iban, ATTR_ACCOUNT_TYPE: "balance"} - if self._client.name: - attributes[ATTR_BANK] = self._client.name - return attributes - - @property - def icon(self) -> str: - """Set the icon for the sensor.""" - return ICON - class FinTsHoldingsAccount(SensorEntity): """Sensor for a FinTS holdings account. @@ -215,26 +194,17 @@ class FinTsHoldingsAccount(SensorEntity): def __init__(self, client: FinTsClient, account, name: str) -> None: """Initialize a FinTs holdings account.""" self._client = client - self._name = name + self._attr_name = name self._account = account - self._holdings = [] - self._total: float = None + self._holdings: list[Any] = [] + self._attr_icon = ICON + self._attr_unit_of_measurement = "EUR" def update(self) -> None: """Get the current holdings for the account.""" bank = self._client.client self._holdings = bank.get_holdings(self._account) - self._total = sum(h.total_value for h in self._holdings) - - @property - def state(self) -> float: - """Return total market value as state.""" - return self._total - - @property - def icon(self) -> str: - """Set the icon for the sensor.""" - return ICON + self._attr_state = sum(h.total_value for h in self._holdings) @property def extra_state_attributes(self) -> dict: @@ -257,18 +227,3 @@ class FinTsHoldingsAccount(SensorEntity): attributes[price_name] = holding.market_value return attributes - - @property - def name(self) -> str: - """Friendly name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self) -> str: - """Get the unit of measurement. - - Hardcoded to EUR, as the library does not provide the currency for the - holdings. And as FinTS is only used in Germany, most accounts will be - in EUR anyways. - """ - return "EUR" diff --git a/mypy.ini b/mypy.ini index cb10dee585b..8c5882b720d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1333,9 +1333,6 @@ ignore_errors = true [mypy-homeassistant.components.filter.*] ignore_errors = true -[mypy-homeassistant.components.fints.*] -ignore_errors = true - [mypy-homeassistant.components.fireservicerota.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index e507e906964..62da8b4fb5f 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -44,7 +44,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.entur_public_transport.*", "homeassistant.components.evohome.*", "homeassistant.components.filter.*", - "homeassistant.components.fints.*", "homeassistant.components.fireservicerota.*", "homeassistant.components.firmata.*", "homeassistant.components.flo.*", From 8e61ed39fde4a9edc27813baac8a18d1e5e23e76 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Fri, 30 Jul 2021 20:07:23 +0200 Subject: [PATCH 058/903] Fix flaky netatmo tests (#53644) --- homeassistant/components/netatmo/climate.py | 30 +++++++-------------- homeassistant/components/netatmo/helper.py | 26 ++++++++++++++++++ homeassistant/components/netatmo/select.py | 16 ++++++----- tests/components/netatmo/test_select.py | 9 ++++--- 4 files changed, 50 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 1eaf47f7162..6622c891df1 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -58,6 +58,7 @@ from .data_handler import ( HOMESTATUS_DATA_CLASS_NAME, NetatmoDataHandler, ) +from .helper import get_all_home_ids, update_climate_schedules from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -131,9 +132,6 @@ async def async_setup_entry( if not home_data or home_data.raw_data == {}: raise PlatformNotReady - if HOMEDATA_DATA_CLASS_NAME not in data_handler.data: - raise PlatformNotReady - entities = [] for home_id in get_all_home_ids(home_data): for room_id in home_data.rooms[home_id]: @@ -145,12 +143,12 @@ async def async_setup_entry( if home_status and room_id in home_status.rooms: entities.append(NetatmoThermostat(data_handler, home_id, room_id)) - hass.data[DOMAIN][DATA_SCHEDULES][home_id] = { - schedule_id: schedule_data.get("name") - for schedule_id, schedule_data in ( - data_handler.data[HOMEDATA_DATA_CLASS_NAME].schedules[home_id].items() - ) - } + hass.data[DOMAIN][DATA_SCHEDULES].update( + update_climate_schedules( + home_ids=get_all_home_ids(home_data), + schedules=data_handler.data[HOMEDATA_DATA_CLASS_NAME].schedules, + ) + ) hass.data[DOMAIN][DATA_HOMES] = { home_id: home_data.get("name") @@ -257,7 +255,8 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): assert device self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._home_id] = device.id - async def handle_event(self, event: dict) -> None: + @callback + def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] @@ -617,14 +616,3 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): device_info: DeviceInfo = super().device_info device_info["suggested_area"] = self._room_data["name"] return device_info - - -def get_all_home_ids(home_data: pyatmo.HomeData | None) -> list[str]: - """Get all the home ids returned by NetAtmo API.""" - if home_data is None: - return [] - return [ - home_data.homes[home_id]["id"] - for home_id in home_data.homes - if "modules" in home_data.homes[home_id] - ] diff --git a/homeassistant/components/netatmo/helper.py b/homeassistant/components/netatmo/helper.py index 7e8f32817dd..d824013ed27 100644 --- a/homeassistant/components/netatmo/helper.py +++ b/homeassistant/components/netatmo/helper.py @@ -1,7 +1,11 @@ """Helper for Netatmo integration.""" +from __future__ import annotations + from dataclasses import dataclass from uuid import UUID, uuid4 +import pyatmo + @dataclass class NetatmoArea: @@ -15,3 +19,25 @@ class NetatmoArea: mode: str show_on_map: bool uuid: UUID = uuid4() + + +def get_all_home_ids(home_data: pyatmo.HomeData | None) -> list[str]: + """Get all the home ids returned by NetAtmo API.""" + if home_data is None: + return [] + return [ + home_data.homes[home_id]["id"] + for home_id in home_data.homes + if "modules" in home_data.homes[home_id] + ] + + +def update_climate_schedules(home_ids: list[str], schedules: dict) -> dict: + """Get updated list of all climate schedules.""" + return { + home_id: { + schedule_id: schedule_data.get("name") + for schedule_id, schedule_data in schedules[home_id].items() + } + for home_id in home_ids + } diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 718d7e440b9..387fb8f0acc 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -13,7 +13,6 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .climate import get_all_home_ids from .const import ( DATA_HANDLER, DATA_SCHEDULES, @@ -23,6 +22,7 @@ from .const import ( SIGNAL_NAME, ) from .data_handler import HOMEDATA_DATA_CLASS_NAME, NetatmoDataHandler +from .helper import get_all_home_ids, update_climate_schedules from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -42,8 +42,12 @@ async def async_setup_entry( if not home_data or home_data.raw_data == {}: raise PlatformNotReady - if HOMEDATA_DATA_CLASS_NAME not in data_handler.data: - raise PlatformNotReady + hass.data[DOMAIN][DATA_SCHEDULES].update( + update_climate_schedules( + home_ids=get_all_home_ids(home_data), + schedules=data_handler.data[HOMEDATA_DATA_CLASS_NAME].schedules, + ) + ) entities = [ NetatmoScheduleSelect( @@ -51,8 +55,7 @@ async def async_setup_entry( home_id, list(hass.data[DOMAIN][DATA_SCHEDULES][home_id].values()), ) - for home_id in get_all_home_ids(home_data) - if home_id in hass.data[DOMAIN][DATA_SCHEDULES] + for home_id in hass.data[DOMAIN][DATA_SCHEDULES] ] _LOGGER.debug("Adding climate schedule select entities %s", entities) @@ -105,7 +108,8 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity): ) ) - async def handle_event(self, event: dict) -> None: + @callback + def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index 8be010cc802..f0e7cde7359 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -19,10 +19,6 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a select_entity = "select.netatmo_myhome" assert hass.states.get(select_entity).state == "Default" - assert hass.states.get(select_entity).attributes[ATTR_OPTIONS] == [ - "Default", - "Winter", - ] # Fake backend response changing schedule response = { @@ -32,8 +28,13 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a "push_type": "home_event_changed", } await simulate_webhook(hass, webhook_id, response) + await hass.async_block_till_done() assert hass.states.get(select_entity).state == "Winter" + assert hass.states.get(select_entity).attributes[ATTR_OPTIONS] == [ + "Default", + "Winter", + ] # Test setting a different schedule with patch( From a9722c90e92a1dacf09e7fbe5c92218022d573ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 30 Jul 2021 21:44:10 +0200 Subject: [PATCH 059/903] Revert "Rename snapshot -> backup" (#53751) This reverts commit 9806bda272bd855f27a2ebdda97e1725474ad03e. --- homeassistant/components/hassio/__init__.py | 57 +++++------------- homeassistant/components/hassio/http.py | 9 +-- homeassistant/components/hassio/services.yaml | 58 ++----------------- homeassistant/components/zwave_js/__init__.py | 2 +- homeassistant/components/zwave_js/addon.py | 14 ++--- tests/components/hassio/test_http.py | 20 +++---- tests/components/hassio/test_init.py | 43 ++++---------- tests/components/hassio/test_websocket_api.py | 4 +- tests/components/zwave_js/conftest.py | 12 ++-- tests/components/zwave_js/test_init.py | 44 +++++++------- 10 files changed, 82 insertions(+), 181 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4541685061a..6c71f2eb042 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -89,8 +89,6 @@ SERVICE_HOST_SHUTDOWN = "host_shutdown" SERVICE_HOST_REBOOT = "host_reboot" SERVICE_SNAPSHOT_FULL = "snapshot_full" SERVICE_SNAPSHOT_PARTIAL = "snapshot_partial" -SERVICE_BACKUP_FULL = "backup_full" -SERVICE_BACKUP_PARTIAL = "backup_partial" SERVICE_RESTORE_FULL = "restore_full" SERVICE_RESTORE_PARTIAL = "restore_partial" @@ -103,11 +101,11 @@ SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} ) -SCHEMA_BACKUP_FULL = vol.Schema( +SCHEMA_SNAPSHOT_FULL = vol.Schema( {vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_PASSWORD): cv.string} ) -SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( +SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend( { vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), @@ -115,12 +113,7 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( ) SCHEMA_RESTORE_FULL = vol.Schema( - { - vol.Exclusive(ATTR_SLUG, ATTR_SLUG): cv.slug, - vol.Exclusive(ATTR_SNAPSHOT, ATTR_SLUG): cv.slug, - vol.Optional(ATTR_PASSWORD): cv.string, - }, - cv.has_at_least_one_key(ATTR_SLUG, ATTR_SNAPSHOT), + {vol.Required(ATTR_SNAPSHOT): cv.slug, vol.Optional(ATTR_PASSWORD): cv.string} ) SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( @@ -140,32 +133,25 @@ MAP_SERVICE_API = { SERVICE_ADDON_STDIN: ("/addons/{addon}/stdin", SCHEMA_ADDON_STDIN, 60, False), SERVICE_HOST_SHUTDOWN: ("/host/shutdown", SCHEMA_NO_DATA, 60, False), SERVICE_HOST_REBOOT: ("/host/reboot", SCHEMA_NO_DATA, 60, False), - SERVICE_BACKUP_FULL: ("/backups/new/full", SCHEMA_BACKUP_FULL, 300, True), - SERVICE_BACKUP_PARTIAL: ( - "/backups/new/partial", - SCHEMA_BACKUP_PARTIAL, + SERVICE_SNAPSHOT_FULL: ("/snapshots/new/full", SCHEMA_SNAPSHOT_FULL, 300, True), + SERVICE_SNAPSHOT_PARTIAL: ( + "/snapshots/new/partial", + SCHEMA_SNAPSHOT_PARTIAL, 300, True, ), SERVICE_RESTORE_FULL: ( - "/backups/{slug}/restore/full", + "/snapshots/{snapshot}/restore/full", SCHEMA_RESTORE_FULL, 300, True, ), SERVICE_RESTORE_PARTIAL: ( - "/backups/{slug}/restore/partial", + "/snapshots/{snapshot}/restore/partial", SCHEMA_RESTORE_PARTIAL, 300, True, ), - SERVICE_SNAPSHOT_FULL: ("/backups/new/full", SCHEMA_BACKUP_FULL, 300, True), - SERVICE_SNAPSHOT_PARTIAL: ( - "/backups/new/partial", - SCHEMA_BACKUP_PARTIAL, - 300, - True, - ), } @@ -286,16 +272,16 @@ async def async_get_addon_discovery_info(hass: HomeAssistant, slug: str) -> dict @bind_hass @api_data -async def async_create_backup( +async def async_create_snapshot( hass: HomeAssistant, payload: dict, partial: bool = False ) -> dict: - """Create a full or partial backup. + """Create a full or partial snapshot. The caller of the function should handle HassioAPIError. """ hassio = hass.data[DOMAIN] - backup_type = "partial" if partial else "full" - command = f"/backups/new/{backup_type}" + snapshot_type = "partial" if partial else "full" + command = f"/snapshots/new/{snapshot_type}" return await hassio.send_command(command, payload=payload, timeout=None) @@ -467,22 +453,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_service_handler(service): """Handle service calls for Hass.io.""" api_command = MAP_SERVICE_API[service.service][0] - if "snapshot" in service.service: - _LOGGER.warning( - "The service '%s' is deprecated and will be removed in Home Assistant 2021.10, use '%s' instead", - service.service, - service.service.replace("snapshot", "backup"), - ) data = service.data.copy() addon = data.pop(ATTR_ADDON, None) - slug = data.pop(ATTR_SLUG, None) snapshot = data.pop(ATTR_SNAPSHOT, None) - if snapshot is not None: - _LOGGER.warning( - "Using 'snapshot' is deprecated and will be removed in Home Assistant 2021.10, use 'slug' instead" - ) - slug = snapshot - payload = None # Pass data to Hass.io API @@ -494,12 +467,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # Call API try: await hassio.send_command( - api_command.format(addon=addon, slug=slug), + api_command.format(addon=addon, snapshot=snapshot), payload=payload, timeout=MAP_SERVICE_API[service.service][2], ) except HassioAPIError as err: - _LOGGER.error("Error on Supervisor API: %s", err) + _LOGGER.error("Error on Hass.io API: %s", err) for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 302cc00bb9f..47131b80de3 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -29,9 +29,6 @@ NO_TIMEOUT = re.compile( r"|hassos/update/cli" r"|supervisor/update" r"|addons/[^/]+/(?:update|install|rebuild)" - r"|backups/.+/full" - r"|backups/.+/partial" - r"|backups/[^/]+/(?:upload|download)" r"|snapshots/.+/full" r"|snapshots/.+/partial" r"|snapshots/[^/]+/(?:upload|download)" @@ -39,7 +36,7 @@ NO_TIMEOUT = re.compile( ) NO_AUTH_ONBOARDING = re.compile( - r"^(?:" r"|supervisor/logs" r"|backups/[^/]+/.+" r"|snapshots/[^/]+/.+" r")$" + r"^(?:" r"|supervisor/logs" r"|snapshots/[^/]+/.+" r")$" ) NO_AUTH = re.compile( @@ -84,13 +81,13 @@ class HassIOView(HomeAssistantView): client_timeout = 10 data = None headers = _init_header(request) - if path in ("snapshots/new/upload", "backups/new/upload"): + if path == "snapshots/new/upload": # We need to reuse the full content type that includes the boundary headers[ "Content-Type" ] = request._stored_content_type # pylint: disable=protected-access - # Backups are big, so we need to adjust the allowed size + # Snapshots are big, so we need to adjust the allowed size request._client_max_size = ( # pylint: disable=protected-access MAX_UPLOAD_SIZE ) diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 38d78984ddc..0652b65d6e2 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -67,13 +67,13 @@ host_shutdown: description: Poweroff the host system. snapshot_full: - name: Create a full backup. - description: Create a full backup (deprecated, use backup_full instead). + name: Create a full snapshot. + description: Create a full snapshot. fields: name: name: Name description: Optional or it will be the current date and time. - example: "backup 1" + example: "Snapshot 1" selector: text: password: @@ -84,8 +84,8 @@ snapshot_full: text: snapshot_partial: - name: Create a partial backup. - description: Create a partial backup (deprecated, use backup_partial instead). + name: Create a partial snapshot. + description: Create a partial snapshot. fields: addons: name: Add-ons @@ -102,53 +102,7 @@ snapshot_partial: name: name: Name description: Optional or it will be the current date and time. - example: "Partial backup 1" - selector: - text: - password: - name: Password - description: Optional password. - example: "password" - selector: - text: - -backup_full: - name: Create a full backup. - description: Create a full backup. - fields: - name: - name: Name - description: Optional or it will be the current date and time. - example: "backup 1" - selector: - text: - password: - name: Password - description: Optional password. - example: "password" - selector: - text: - -backup_partial: - name: Create a partial backup. - description: Create a partial backup. - fields: - addons: - name: Add-ons - description: Optional list of addon slugs. - example: ["core_ssh", "core_samba", "core_mosquitto"] - selector: - object: - folders: - name: Folders - description: Optional list of directories. - example: ["homeassistant", "share"] - selector: - object: - name: - name: Name - description: Optional or it will be the current date and time. - example: "Partial backup 1" + example: "Partial Snapshot 1" selector: text: password: diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 6320efddb60..6cd0104e298 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -547,7 +547,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: LOGGER.error(err) return try: - await addon_manager.async_create_backup() + await addon_manager.async_create_snapshot() except AddonError as err: LOGGER.error(err) return diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 29ae887b4bc..a0caaa15488 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -8,7 +8,7 @@ from functools import partial from typing import Any, Callable, TypeVar, cast from homeassistant.components.hassio import ( - async_create_backup, + async_create_snapshot, async_get_addon_discovery_info, async_get_addon_info, async_install_addon, @@ -202,7 +202,7 @@ class AddonManager: if not addon_info.update_available: return - await self.async_create_backup() + await self.async_create_snapshot() await async_update_addon(self._hass, ADDON_SLUG) @callback @@ -289,14 +289,14 @@ class AddonManager: ) return self._start_task - @api_error("Failed to create a backup of the Z-Wave JS add-on.") - async def async_create_backup(self) -> None: - """Create a partial backup of the Z-Wave JS add-on.""" + @api_error("Failed to create a snapshot of the Z-Wave JS add-on.") + async def async_create_snapshot(self) -> None: + """Create a partial snapshot of the Z-Wave JS add-on.""" addon_info = await self.async_get_addon_info() name = f"addon_{ADDON_SLUG}_{addon_info.version}" - LOGGER.debug("Creating backup: %s", name) - await async_create_backup( + LOGGER.debug("Creating snapshot: %s", name) + await async_create_snapshot( self._hass, {"name": name, "addons": [ADDON_SLUG]}, partial=True, diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index fc4bb3e6a0d..ff1c348a37b 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -132,13 +132,13 @@ async def test_forwarding_user_info(hassio_client, hass_admin_user, aioclient_mo assert req_headers["X-Hass-Is-Admin"] == "1" -async def test_backup_upload_headers(hassio_client, aioclient_mock, caplog): - """Test that we forward the full header for backup upload.""" +async def test_snapshot_upload_headers(hassio_client, aioclient_mock): + """Test that we forward the full header for snapshot upload.""" content_type = "multipart/form-data; boundary='--webkit'" - aioclient_mock.get("http://127.0.0.1/backups/new/upload") + aioclient_mock.get("http://127.0.0.1/snapshots/new/upload") resp = await hassio_client.get( - "/api/hassio/backups/new/upload", headers={"Content-Type": content_type} + "/api/hassio/snapshots/new/upload", headers={"Content-Type": content_type} ) # Check we got right response @@ -150,18 +150,18 @@ async def test_backup_upload_headers(hassio_client, aioclient_mock, caplog): assert req_headers["Content-Type"] == content_type -async def test_backup_download_headers(hassio_client, aioclient_mock): - """Test that we forward the full header for backup download.""" +async def test_snapshot_download_headers(hassio_client, aioclient_mock): + """Test that we forward the full header for snapshot download.""" content_disposition = "attachment; filename=test.tar" aioclient_mock.get( - "http://127.0.0.1/backups/slug/download", + "http://127.0.0.1/snapshots/slug/download", headers={ "Content-Length": "50000000", "Content-Disposition": content_disposition, }, ) - resp = await hassio_client.get("/api/hassio/backups/slug/download") + resp = await hassio_client.get("/api/hassio/snapshots/slug/download") # Check we got right response assert resp.status == 200 @@ -174,9 +174,9 @@ async def test_backup_download_headers(hassio_client, aioclient_mock): def test_need_auth(hass): """Test if the requested path needs authentication.""" assert not _need_auth(hass, "addons/test/logo") - assert _need_auth(hass, "backups/new/upload") + assert _need_auth(hass, "snapshots/new/upload") assert _need_auth(hass, "supervisor/logs") hass.data["onboarding"] = False - assert not _need_auth(hass, "backups/new/upload") + assert not _need_auth(hass, "snapshots/new/upload") assert not _need_auth(hass, "supervisor/logs") diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 910ed12cb52..8377e5287d0 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -303,13 +303,11 @@ async def test_service_register(hassio_env, hass): assert hass.services.has_service("hassio", "host_reboot") assert hass.services.has_service("hassio", "snapshot_full") assert hass.services.has_service("hassio", "snapshot_partial") - assert hass.services.has_service("hassio", "backup_full") - assert hass.services.has_service("hassio", "backup_partial") assert hass.services.has_service("hassio", "restore_full") assert hass.services.has_service("hassio", "restore_partial") -async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): +async def test_service_calls(hassio_env, hass, aioclient_mock): """Call service and check the API calls behind that.""" assert await async_setup_component(hass, "hassio", {}) @@ -320,13 +318,13 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): aioclient_mock.post("http://127.0.0.1/addons/test/stdin", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/host/shutdown", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/host/reboot", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/backups/new/full", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/backups/new/partial", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/snapshots/new/full", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/snapshots/new/partial", json={"result": "ok"}) aioclient_mock.post( - "http://127.0.0.1/backups/test/restore/full", json={"result": "ok"} + "http://127.0.0.1/snapshots/test/restore/full", json={"result": "ok"} ) aioclient_mock.post( - "http://127.0.0.1/backups/test/restore/partial", json={"result": "ok"} + "http://127.0.0.1/snapshots/test/restore/partial", json={"result": "ok"} ) await hass.services.async_call("hassio", "addon_start", {"addon": "test"}) @@ -347,48 +345,27 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): assert aioclient_mock.call_count == 10 - await hass.services.async_call("hassio", "backup_full", {}) - await hass.services.async_call( - "hassio", - "backup_partial", - {"addons": ["test"], "folders": ["ssl"], "password": "123456"}, - ) await hass.services.async_call("hassio", "snapshot_full", {}) await hass.services.async_call( "hassio", "snapshot_partial", - {"addons": ["test"], "folders": ["ssl"]}, + {"addons": ["test"], "folders": ["ssl"], "password": "123456"}, ) await hass.async_block_till_done() - assert ( - "The service 'snapshot_full' is deprecated and will be removed in Home Assistant 2021.10, use 'backup_full' instead" - in caplog.text - ) - assert ( - "The service 'snapshot_partial' is deprecated and will be removed in Home Assistant 2021.10, use 'backup_partial' instead" - in caplog.text - ) - assert aioclient_mock.call_count == 14 - assert aioclient_mock.mock_calls[-3][2] == { + assert aioclient_mock.call_count == 12 + assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], "password": "123456", } - await hass.services.async_call("hassio", "restore_full", {"slug": "test"}) await hass.services.async_call("hassio", "restore_full", {"snapshot": "test"}) - await hass.async_block_till_done() - assert ( - "Using 'snapshot' is deprecated and will be removed in Home Assistant 2021.10, use 'slug' instead" - in caplog.text - ) - await hass.services.async_call( "hassio", "restore_partial", { - "slug": "test", + "snapshot": "test", "homeassistant": False, "addons": ["test"], "folders": ["ssl"], @@ -397,7 +374,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 17 + assert aioclient_mock.call_count == 14 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 5578194b87c..5278d2cbb91 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -61,7 +61,7 @@ async def test_websocket_supervisor_api( assert await async_setup_component(hass, "hassio", {}) websocket_client = await hass_ws_client(hass) aioclient_mock.post( - "http://127.0.0.1/backups/new/partial", + "http://127.0.0.1/snapshots/new/partial", json={"result": "ok", "data": {"slug": "sn_slug"}}, ) @@ -69,7 +69,7 @@ async def test_websocket_supervisor_api( { WS_ID: 1, WS_TYPE: WS_TYPE_API, - ATTR_ENDPOINT: "/backups/new/partial", + ATTR_ENDPOINT: "/snapshots/new/partial", ATTR_METHOD: "post", } ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 75b5ab65d38..0f336e396fe 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -171,13 +171,13 @@ def uninstall_addon_fixture(): yield uninstall_addon -@pytest.fixture(name="create_backup") -def create_backup_fixture(): - """Mock create backup.""" +@pytest.fixture(name="create_shapshot") +def create_snapshot_fixture(): + """Mock create snapshot.""" with patch( - "homeassistant.components.zwave_js.addon.async_create_backup" - ) as create_backup: - yield create_backup + "homeassistant.components.zwave_js.addon.async_create_snapshot" + ) as create_shapshot: + yield create_shapshot @pytest.fixture(name="controller_state", scope="session") diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 447b052b8c0..0b9009cd1d7 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -365,8 +365,8 @@ async def test_addon_options_changed( @pytest.mark.parametrize( - "addon_version, update_available, update_calls, backup_calls, " - "update_addon_side_effect, create_backup_side_effect", + "addon_version, update_available, update_calls, snapshot_calls, " + "update_addon_side_effect, create_shapshot_side_effect", [ ("1.0", True, 1, 1, None, None), ("1.0", False, 0, 0, None, None), @@ -380,15 +380,15 @@ async def test_update_addon( addon_info, addon_installed, addon_running, - create_backup, + create_shapshot, update_addon, addon_options, addon_version, update_available, update_calls, - backup_calls, + snapshot_calls, update_addon_side_effect, - create_backup_side_effect, + create_shapshot_side_effect, ): """Test update the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -397,7 +397,7 @@ async def test_update_addon( addon_options["network_key"] = network_key addon_info.return_value["version"] = addon_version addon_info.return_value["update_available"] = update_available - create_backup.side_effect = create_backup_side_effect + create_shapshot.side_effect = create_shapshot_side_effect update_addon.side_effect = update_addon_side_effect client.connect.side_effect = InvalidServerVersion("Invalid version") entry = MockConfigEntry( @@ -416,7 +416,7 @@ async def test_update_addon( await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY - assert create_backup.call_count == backup_calls + assert create_shapshot.call_count == snapshot_calls assert update_addon.call_count == update_calls @@ -469,7 +469,7 @@ async def test_stop_addon( async def test_remove_entry( - hass, addon_installed, stop_addon, create_backup, uninstall_addon, caplog + hass, addon_installed, stop_addon, create_shapshot, uninstall_addon, caplog ): """Test remove the config entry.""" # test successful remove without created add-on @@ -500,8 +500,8 @@ async def test_remove_entry( assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_backup.call_count == 1 - assert create_backup.call_args == call( + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( hass, {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, partial=True, @@ -511,7 +511,7 @@ async def test_remove_entry( assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 stop_addon.reset_mock() - create_backup.reset_mock() + create_shapshot.reset_mock() uninstall_addon.reset_mock() # test add-on stop failure @@ -523,27 +523,27 @@ async def test_remove_entry( assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_backup.call_count == 0 + assert create_shapshot.call_count == 0 assert uninstall_addon.call_count == 0 assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to stop the Z-Wave JS add-on" in caplog.text stop_addon.side_effect = None stop_addon.reset_mock() - create_backup.reset_mock() + create_shapshot.reset_mock() uninstall_addon.reset_mock() - # test create backup failure + # test create snapshot failure entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - create_backup.side_effect = HassioAPIError() + create_shapshot.side_effect = HassioAPIError() await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_backup.call_count == 1 - assert create_backup.call_args == call( + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( hass, {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, partial=True, @@ -551,10 +551,10 @@ async def test_remove_entry( assert uninstall_addon.call_count == 0 assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - assert "Failed to create a backup of the Z-Wave JS add-on" in caplog.text - create_backup.side_effect = None + assert "Failed to create a snapshot of the Z-Wave JS add-on" in caplog.text + create_shapshot.side_effect = None stop_addon.reset_mock() - create_backup.reset_mock() + create_shapshot.reset_mock() uninstall_addon.reset_mock() # test add-on uninstall failure @@ -566,8 +566,8 @@ async def test_remove_entry( assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_backup.call_count == 1 - assert create_backup.call_args == call( + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( hass, {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, partial=True, From 72bd3f71288fb5d387042fa68233e10941b88a96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jul 2021 14:44:28 -0500 Subject: [PATCH 060/903] Bump HAP-python to 3.6.0 (#53754) --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homekit/test_type_cameras.py | 16 ++++++++-------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 5ec935611f7..887dfc3ee37 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==3.5.2", + "HAP-python==3.6.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1", diff --git a/requirements_all.txt b/requirements_all.txt index 75313a53947..1e469e39839 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -14,7 +14,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==3.5.2 +HAP-python==3.6.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a49e3faae2..b0c2223e7d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.homekit -HAP-python==3.5.2 +HAP-python==3.6.0 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 354db900470..b9df572a699 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -696,21 +696,21 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): char = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) assert char - assert char.value == 0 + assert char.value is None service2 = acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) assert service2 char2 = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) assert char2 - assert char2.value == 0 + assert char2.value is None hass.states.async_set( doorbell_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} ) await hass.async_block_till_done() - assert char.value == 0 - assert char2.value == 0 + assert char.value is None + assert char2.value is None char.set_value(True) char2.set_value(True) @@ -718,8 +718,8 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} ) await hass.async_block_till_done() - assert char.value == 0 - assert char2.value == 0 + assert char.value is None + assert char2.value is None # Ensure we do not throw when the linked # doorbell sensor is removed @@ -727,8 +727,8 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): await hass.async_block_till_done() await acc.run() await hass.async_block_till_done() - assert char.value == 0 - assert char2.value == 0 + assert char.value is None + assert char2.value is None async def test_camera_with_a_missing_linked_doorbell_sensor(hass, run_driver, events): From f2e7543f5405560739b4e753aefa5a04ba4e93d4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 30 Jul 2021 21:44:52 +0200 Subject: [PATCH 061/903] Fix Xiaomi Miio humidifier mode change (#53757) --- homeassistant/components/xiaomi_miio/humidifier.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index eb45a716254..64248f900e0 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -232,7 +232,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): """Return the target humidity.""" return ( self._target_humidity - if self._mode == AirhumidifierOperationMode.Auto.name + if self._mode == AirhumidifierOperationMode.Auto.value or AirhumidifierOperationMode.Auto.name not in self.available_modes else None ) @@ -264,7 +264,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): self._device.set_mode, AirhumidifierOperationMode.Auto, ): - self._mode = AirhumidifierOperationMode.Auto.name + self._mode = AirhumidifierOperationMode.Auto.value self.async_write_ha_state() async def async_set_mode(self, mode: str) -> None: @@ -280,9 +280,9 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - AirhumidifierOperationMode[mode.title()], + AirhumidifierOperationMode[mode], ): - self._mode = mode.title() + self._mode = mode.lower() self.async_write_ha_state() From 6ffe0f6405e5f9f338c517d735809479ccc55a95 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 30 Jul 2021 21:47:26 +0200 Subject: [PATCH 062/903] Improve light scene support for white mode (#53768) --- .../components/light/reproduce_state.py | 6 ++++ .../components/light/test_reproduce_state.py | 28 +++++++++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 77e5742bbab..7fb19eb71d5 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -132,6 +132,12 @@ async def _async_reproduce_state( if deprecated_attrs: _LOGGER.warning(DEPRECATION_WARNING, deprecated_attrs) + if ATTR_WHITE in state.attributes and ATTR_COLOR_MODE not in state.attributes: + state_dict = state.as_dict() + state_dict["attributes"][ATTR_BRIGHTNESS] = state.attributes[ATTR_WHITE] + state_dict["attributes"][ATTR_COLOR_MODE] = COLOR_MODE_WHITE + state = State.from_dict(state_dict) + # Return if we are already at the right state. if ( cur_state.state == state.state diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index 97d969acdd9..9873c27b8e8 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -20,6 +20,8 @@ VALID_PROFILE = {"profile": "relax"} VALID_RGB_COLOR = {"rgb_color": (255, 63, 111)} VALID_RGBW_COLOR = {"rgbw_color": (255, 63, 111, 10)} VALID_RGBWW_COLOR = {"rgbww_color": (255, 63, 111, 10, 20)} +VALID_WHITE = {"white": 220} # Specified in a scene +VALID_WHITE_STATE = {"color_mode": "white", "brightness": 220} # The light's state VALID_XY_COLOR = {"xy_color": (0.59, 0.274)} @@ -27,7 +29,7 @@ async def test_reproducing_states(hass, caplog): """Test reproducing Light states.""" hass.states.async_set("light.entity_off", "off", {}) hass.states.async_set("light.entity_bright", "on", VALID_BRIGHTNESS) - hass.states.async_set("light.entity_white", "on", VALID_WHITE_VALUE) + hass.states.async_set("light.entity_white_value", "on", VALID_WHITE_VALUE) hass.states.async_set("light.entity_flash", "on", VALID_FLASH) hass.states.async_set("light.entity_effect", "on", VALID_EFFECT) hass.states.async_set("light.entity_trans", "on", VALID_TRANSITION) @@ -37,6 +39,7 @@ async def test_reproducing_states(hass, caplog): hass.states.async_set("light.entity_kelvin", "on", VALID_KELVIN) hass.states.async_set("light.entity_profile", "on", VALID_PROFILE) hass.states.async_set("light.entity_rgb", "on", VALID_RGB_COLOR) + hass.states.async_set("light.entity_white", "on", VALID_WHITE_STATE) hass.states.async_set("light.entity_xy", "on", VALID_XY_COLOR) turn_on_calls = async_mock_service(hass, "light", "turn_on") @@ -47,7 +50,7 @@ async def test_reproducing_states(hass, caplog): [ State("light.entity_off", "off"), State("light.entity_bright", "on", VALID_BRIGHTNESS), - State("light.entity_white", "on", VALID_WHITE_VALUE), + State("light.entity_white_value", "on", VALID_WHITE_VALUE), State("light.entity_flash", "on", VALID_FLASH), State("light.entity_effect", "on", VALID_EFFECT), State("light.entity_trans", "on", VALID_TRANSITION), @@ -57,6 +60,7 @@ async def test_reproducing_states(hass, caplog): State("light.entity_kelvin", "on", VALID_KELVIN), State("light.entity_profile", "on", VALID_PROFILE), State("light.entity_rgb", "on", VALID_RGB_COLOR), + State("light.entity_white", "on", VALID_WHITE), State("light.entity_xy", "on", VALID_XY_COLOR), ] ) @@ -79,7 +83,7 @@ async def test_reproducing_states(hass, caplog): State("light.entity_xy", "off"), State("light.entity_off", "on", VALID_BRIGHTNESS), State("light.entity_bright", "on", VALID_WHITE_VALUE), - State("light.entity_white", "on", VALID_FLASH), + State("light.entity_white_value", "on", VALID_FLASH), State("light.entity_flash", "on", VALID_EFFECT), State("light.entity_effect", "on", VALID_TRANSITION), State("light.entity_trans", "on", VALID_COLOR_NAME), @@ -88,11 +92,12 @@ async def test_reproducing_states(hass, caplog): State("light.entity_hs", "on", VALID_KELVIN), State("light.entity_kelvin", "on", VALID_PROFILE), State("light.entity_profile", "on", VALID_RGB_COLOR), - State("light.entity_rgb", "on", VALID_XY_COLOR), + State("light.entity_rgb", "on", VALID_WHITE), + State("light.entity_white", "on", VALID_XY_COLOR), ], ) - assert len(turn_on_calls) == 12 + assert len(turn_on_calls) == 13 expected_calls = [] @@ -104,9 +109,9 @@ async def test_reproducing_states(hass, caplog): expected_bright["entity_id"] = "light.entity_bright" expected_calls.append(expected_bright) - expected_white = dict(VALID_FLASH) - expected_white["entity_id"] = "light.entity_white" - expected_calls.append(expected_white) + expected_white_value = dict(VALID_FLASH) + expected_white_value["entity_id"] = "light.entity_white_value" + expected_calls.append(expected_white_value) expected_flash = dict(VALID_EFFECT) expected_flash["entity_id"] = "light.entity_flash" @@ -140,10 +145,15 @@ async def test_reproducing_states(hass, caplog): expected_profile["entity_id"] = "light.entity_profile" expected_calls.append(expected_profile) - expected_rgb = dict(VALID_XY_COLOR) + expected_rgb = dict(VALID_WHITE) expected_rgb["entity_id"] = "light.entity_rgb" + expected_rgb["brightness"] = expected_rgb["white"] expected_calls.append(expected_rgb) + expected_white = dict(VALID_XY_COLOR) + expected_white["entity_id"] = "light.entity_white" + expected_calls.append(expected_white) + for call in turn_on_calls: assert call.domain == "light" found = False From a5824c3259a29426082d5e81097854c13fed0276 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 30 Jul 2021 21:47:55 +0200 Subject: [PATCH 063/903] Fix Xiaomi-miio humidifier write the state back when turning on or off (#53771) --- homeassistant/components/xiaomi_miio/humidifier.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 64248f900e0..aee2c237066 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -139,6 +139,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): ) if result: self._state = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs) -> None: """Turn the device off.""" @@ -148,6 +149,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): if result: self._state = False + self.async_write_ha_state() def translate_humidity(self, humidity): """Translate the target humidity to the first valid step.""" From c72fc0c08c7afcdb909c7dbab712bd8be1b896de Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 30 Jul 2021 23:11:47 +0200 Subject: [PATCH 064/903] Update frontend to 20210730.0 (#53778) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ac791977038..02ee134523e 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210729.0" + "home-assistant-frontend==20210730.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e1acf1dba15..1418f59fedf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210729.0 +home-assistant-frontend==20210730.0 httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 1e469e39839..517e71c9b73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -782,7 +782,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210729.0 +home-assistant-frontend==20210730.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0c2223e7d2..03412d8e729 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -449,7 +449,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210729.0 +home-assistant-frontend==20210730.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From befe2cbefeeeda739b6fd2efb79d7bfaf18db63f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 30 Jul 2021 15:13:53 -0600 Subject: [PATCH 065/903] Fix parsing of non-string values in Slack data (#53775) --- homeassistant/components/slack/notify.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 0eadc26075e..4dfacda266c 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -146,20 +146,6 @@ def _async_sanitize_channel_names(channel_list: list[str]) -> list[str]: return [channel.lstrip("#") for channel in channel_list] -@callback -def _async_templatize_blocks(hass: HomeAssistant, value: Any) -> Any: - """Recursive template creator helper function.""" - if isinstance(value, list): - return [_async_templatize_blocks(hass, item) for item in value] - if isinstance(value, dict): - return { - key: _async_templatize_blocks(hass, item) for key, item in value.items() - } - - tmpl = template.Template(value, hass=hass) # type: ignore # no-untyped-call - return tmpl.async_render(parse_result=False) - - class SlackNotificationService(BaseNotificationService): """Define the Slack notification logic.""" @@ -314,9 +300,9 @@ class SlackNotificationService(BaseNotificationService): # Message Type 1: A text-only message if ATTR_FILE not in data: if ATTR_BLOCKS_TEMPLATE in data: - blocks = _async_templatize_blocks( - self._hass, data[ATTR_BLOCKS_TEMPLATE] - ) + value = cv.template_complex(data[ATTR_BLOCKS_TEMPLATE]) + template.attach(self._hass, value) + blocks = template.render_complex(value) elif ATTR_BLOCKS in data: blocks = data[ATTR_BLOCKS] else: From 90cf94bb300a0802c286270b5b0396b4507024f4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Jul 2021 14:14:58 -0700 Subject: [PATCH 066/903] Bump Hue and only fire events for button presses (#53781) * Bump Hue and only fire events for button presses * Fix tests --- homeassistant/components/hue/hue_event.py | 12 +++++++++++- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hue/test_sensor_base.py | 3 +++ 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hue/hue_event.py b/homeassistant/components/hue/hue_event.py index 7c0163f8a16..6bd68b106bb 100644 --- a/homeassistant/components/hue/hue_event.py +++ b/homeassistant/components/hue/hue_event.py @@ -1,7 +1,12 @@ """Representation of a Hue remote firing events for button presses.""" import logging -from aiohue.sensors import TYPE_ZGP_SWITCH, TYPE_ZLL_ROTARY, TYPE_ZLL_SWITCH +from aiohue.sensors import ( + EVENT_BUTTON, + TYPE_ZGP_SWITCH, + TYPE_ZLL_ROTARY, + TYPE_ZLL_SWITCH, +) from homeassistant.const import CONF_EVENT, CONF_ID, CONF_UNIQUE_ID from homeassistant.core import callback @@ -50,6 +55,11 @@ class HueEvent(GenericHueDevice): """Fire the event if reason is that state is updated.""" if ( self.sensor.state == self._last_state + # Filter out non-button events if last event type is available + or ( + self.sensor.last_event is not None + and self.sensor.last_event["type"] != EVENT_BUTTON + ) or # Filter out old states. Can happen when events fire while refreshing dt_util.parse_datetime(self.sensor.state["lastupdated"]) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 3c8078364ab..05e69948218 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==2.5.1"], + "requirements": ["aiohue==2.6.0"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 517e71c9b73..a2a24953b71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aiohomekit==0.6.0 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.5.1 +aiohue==2.6.0 # homeassistant.components.imap aioimaplib==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03412d8e729..4c934e07675 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -122,7 +122,7 @@ aiohomekit==0.6.0 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.5.1 +aiohue==2.6.0 # homeassistant.components.apache_kafka aiokafka==0.6.0 diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py index bc11c013555..b8e9c83e47d 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_base.py @@ -446,6 +446,9 @@ async def test_hue_events(hass, mock_bridge): assert len(hass.states.async_all()) == 7 assert len(events) == 0 + mock_bridge.api.sensors["7"].last_event = {"type": "button"} + mock_bridge.api.sensors["8"].last_event = {"type": "button"} + new_sensor_response = dict(SENSOR_RESPONSE) new_sensor_response["7"]["state"] = { "buttonevent": 18, From 82abae1f7dfe5f72d0c9cc6ca63744f083314157 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Fri, 30 Jul 2021 22:45:18 +0100 Subject: [PATCH 067/903] Bump vallox-websocket-api to 2.8.1 (#53463) --- homeassistant/components/vallox/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index 14845f97c1c..b536270c336 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -2,7 +2,7 @@ "domain": "vallox", "name": "Vallox", "documentation": "https://www.home-assistant.io/integrations/vallox", - "requirements": ["vallox-websocket-api==2.4.0"], + "requirements": ["vallox-websocket-api==2.8.1"], "codeowners": [], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index a2a24953b71..9ff6d796e4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2333,7 +2333,7 @@ uscisstatus==0.1.1 uvcclient==0.11.0 # homeassistant.components.vallox -vallox-websocket-api==2.4.0 +vallox-websocket-api==2.8.1 # homeassistant.components.venstar venstarcolortouch==0.14 From 370799bd229c36bbb3428af4a20dcf4d8031faa2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Jul 2021 21:10:32 -0700 Subject: [PATCH 068/903] Revert "Improve light scene support for white mode (#53768)" (#53782) This reverts commit 6ffe0f6405e5f9f338c517d735809479ccc55a95. --- .../components/light/reproduce_state.py | 6 ---- .../components/light/test_reproduce_state.py | 28 ++++++------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 7fb19eb71d5..77e5742bbab 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -132,12 +132,6 @@ async def _async_reproduce_state( if deprecated_attrs: _LOGGER.warning(DEPRECATION_WARNING, deprecated_attrs) - if ATTR_WHITE in state.attributes and ATTR_COLOR_MODE not in state.attributes: - state_dict = state.as_dict() - state_dict["attributes"][ATTR_BRIGHTNESS] = state.attributes[ATTR_WHITE] - state_dict["attributes"][ATTR_COLOR_MODE] = COLOR_MODE_WHITE - state = State.from_dict(state_dict) - # Return if we are already at the right state. if ( cur_state.state == state.state diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index 9873c27b8e8..97d969acdd9 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -20,8 +20,6 @@ VALID_PROFILE = {"profile": "relax"} VALID_RGB_COLOR = {"rgb_color": (255, 63, 111)} VALID_RGBW_COLOR = {"rgbw_color": (255, 63, 111, 10)} VALID_RGBWW_COLOR = {"rgbww_color": (255, 63, 111, 10, 20)} -VALID_WHITE = {"white": 220} # Specified in a scene -VALID_WHITE_STATE = {"color_mode": "white", "brightness": 220} # The light's state VALID_XY_COLOR = {"xy_color": (0.59, 0.274)} @@ -29,7 +27,7 @@ async def test_reproducing_states(hass, caplog): """Test reproducing Light states.""" hass.states.async_set("light.entity_off", "off", {}) hass.states.async_set("light.entity_bright", "on", VALID_BRIGHTNESS) - hass.states.async_set("light.entity_white_value", "on", VALID_WHITE_VALUE) + hass.states.async_set("light.entity_white", "on", VALID_WHITE_VALUE) hass.states.async_set("light.entity_flash", "on", VALID_FLASH) hass.states.async_set("light.entity_effect", "on", VALID_EFFECT) hass.states.async_set("light.entity_trans", "on", VALID_TRANSITION) @@ -39,7 +37,6 @@ async def test_reproducing_states(hass, caplog): hass.states.async_set("light.entity_kelvin", "on", VALID_KELVIN) hass.states.async_set("light.entity_profile", "on", VALID_PROFILE) hass.states.async_set("light.entity_rgb", "on", VALID_RGB_COLOR) - hass.states.async_set("light.entity_white", "on", VALID_WHITE_STATE) hass.states.async_set("light.entity_xy", "on", VALID_XY_COLOR) turn_on_calls = async_mock_service(hass, "light", "turn_on") @@ -50,7 +47,7 @@ async def test_reproducing_states(hass, caplog): [ State("light.entity_off", "off"), State("light.entity_bright", "on", VALID_BRIGHTNESS), - State("light.entity_white_value", "on", VALID_WHITE_VALUE), + State("light.entity_white", "on", VALID_WHITE_VALUE), State("light.entity_flash", "on", VALID_FLASH), State("light.entity_effect", "on", VALID_EFFECT), State("light.entity_trans", "on", VALID_TRANSITION), @@ -60,7 +57,6 @@ async def test_reproducing_states(hass, caplog): State("light.entity_kelvin", "on", VALID_KELVIN), State("light.entity_profile", "on", VALID_PROFILE), State("light.entity_rgb", "on", VALID_RGB_COLOR), - State("light.entity_white", "on", VALID_WHITE), State("light.entity_xy", "on", VALID_XY_COLOR), ] ) @@ -83,7 +79,7 @@ async def test_reproducing_states(hass, caplog): State("light.entity_xy", "off"), State("light.entity_off", "on", VALID_BRIGHTNESS), State("light.entity_bright", "on", VALID_WHITE_VALUE), - State("light.entity_white_value", "on", VALID_FLASH), + State("light.entity_white", "on", VALID_FLASH), State("light.entity_flash", "on", VALID_EFFECT), State("light.entity_effect", "on", VALID_TRANSITION), State("light.entity_trans", "on", VALID_COLOR_NAME), @@ -92,12 +88,11 @@ async def test_reproducing_states(hass, caplog): State("light.entity_hs", "on", VALID_KELVIN), State("light.entity_kelvin", "on", VALID_PROFILE), State("light.entity_profile", "on", VALID_RGB_COLOR), - State("light.entity_rgb", "on", VALID_WHITE), - State("light.entity_white", "on", VALID_XY_COLOR), + State("light.entity_rgb", "on", VALID_XY_COLOR), ], ) - assert len(turn_on_calls) == 13 + assert len(turn_on_calls) == 12 expected_calls = [] @@ -109,9 +104,9 @@ async def test_reproducing_states(hass, caplog): expected_bright["entity_id"] = "light.entity_bright" expected_calls.append(expected_bright) - expected_white_value = dict(VALID_FLASH) - expected_white_value["entity_id"] = "light.entity_white_value" - expected_calls.append(expected_white_value) + expected_white = dict(VALID_FLASH) + expected_white["entity_id"] = "light.entity_white" + expected_calls.append(expected_white) expected_flash = dict(VALID_EFFECT) expected_flash["entity_id"] = "light.entity_flash" @@ -145,15 +140,10 @@ async def test_reproducing_states(hass, caplog): expected_profile["entity_id"] = "light.entity_profile" expected_calls.append(expected_profile) - expected_rgb = dict(VALID_WHITE) + expected_rgb = dict(VALID_XY_COLOR) expected_rgb["entity_id"] = "light.entity_rgb" - expected_rgb["brightness"] = expected_rgb["white"] expected_calls.append(expected_rgb) - expected_white = dict(VALID_XY_COLOR) - expected_white["entity_id"] = "light.entity_white" - expected_calls.append(expected_white) - for call in turn_on_calls: assert call.domain == "light" found = False From c6a2e247fee794024158c7f1c9cb0576ca0a9fcf Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 31 Jul 2021 12:32:16 +0200 Subject: [PATCH 069/903] Fix name migration of the Xiaomi Miio humidifier (#53790) --- homeassistant/components/xiaomi_miio/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index e858c7ee797..36ee89ba7a0 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -99,7 +99,6 @@ async def async_create_miio_device_and_coordinator( token = entry.data[CONF_TOKEN] name = entry.title device = None - migrate_entity_name = None if model not in MODELS_HUMIDIFIER: return @@ -116,8 +115,8 @@ async def async_create_miio_device_and_coordinator( entity_id = entity_registry.async_get_entity_id("fan", DOMAIN, entry.unique_id) if entity_id: # This check is entities that have a platform migration only and should be removed in the future - migrate_entity_name = entity_registry.async_get(entity_id).name - hass.config_entries.async_update_entry(entry, title=migrate_entity_name) + if migrate_entity_name := entity_registry.async_get(entity_id).name: + hass.config_entries.async_update_entry(entry, title=migrate_entity_name) entity_registry.async_remove(entity_id) async def async_update_data(): From 172769d19caf1c34480d1a31899f968ab0a5bfa0 Mon Sep 17 00:00:00 2001 From: Andreas Brett Date: Sat, 31 Jul 2021 14:47:51 +0200 Subject: [PATCH 070/903] Fix onkyo UnboundLocalError (#53793) audio_information_raw and video_information_raw were in some cases used before being assigned error: UnboundLocalError: local variable 'video_information_raw' referenced before assignment --- homeassistant/components/onkyo/media_player.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 2e4b6eff6da..ef20c1054f3 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -319,8 +319,10 @@ class OnkyoDevice(MediaPlayerEntity): preset_raw = self.command("preset query") if self._audio_info_supported: audio_information_raw = self.command("audio-information query") + self._parse_audio_information(audio_information_raw) if self._video_info_supported: video_information_raw = self.command("video-information query") + self._parse_video_information(video_information_raw) if not (volume_raw and mute_raw and current_source_raw): return @@ -343,9 +345,6 @@ class OnkyoDevice(MediaPlayerEntity): self._receiver_max_volume * self._max_volume / 100 ) - self._parse_audio_information(audio_information_raw) - self._parse_video_information(video_information_raw) - if not hdmi_out_raw: return self._attributes[ATTR_VIDEO_OUT] = ",".join(hdmi_out_raw[1]) From f1f293de028711e98cbc42514b92f3f9000a1343 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 31 Jul 2021 21:19:00 +0200 Subject: [PATCH 071/903] Clean Xiaomi Miio humidifier services (#53806) --- homeassistant/components/xiaomi_miio/const.py | 5 --- .../components/xiaomi_miio/services.yaml | 43 ------------------- 2 files changed, 48 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 0d8d5bc0014..e01b3f20f0b 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -147,8 +147,6 @@ MODELS_ALL = MODELS_ALL_DEVICES + MODELS_GATEWAY SERVICE_SET_BUZZER_ON = "fan_set_buzzer_on" SERVICE_SET_BUZZER_OFF = "fan_set_buzzer_off" SERVICE_SET_BUZZER = "set_buzzer" -SERVICE_SET_CLEAN_ON = "set_clean_on" -SERVICE_SET_CLEAN_OFF = "set_clean_off" SERVICE_SET_CLEAN = "set_clean" SERVICE_SET_FAN_LED_ON = "fan_set_led_on" SERVICE_SET_FAN_LED_OFF = "fan_set_led_off" @@ -167,9 +165,6 @@ SERVICE_SET_LEARN_MODE_OFF = "fan_set_learn_mode_off" SERVICE_SET_VOLUME = "fan_set_volume" SERVICE_RESET_FILTER = "fan_reset_filter" SERVICE_SET_EXTRA_FEATURES = "fan_set_extra_features" -SERVICE_SET_TARGET_HUMIDITY = "fan_set_target_humidity" -SERVICE_SET_DRY_ON = "fan_set_dry_on" -SERVICE_SET_DRY_OFF = "fan_set_dry_off" SERVICE_SET_DRY = "set_dry" SERVICE_SET_MOTOR_SPEED = "fan_set_motor_speed" diff --git a/homeassistant/components/xiaomi_miio/services.yaml b/homeassistant/components/xiaomi_miio/services.yaml index 90d31765307..4c153292d7e 100644 --- a/homeassistant/components/xiaomi_miio/services.yaml +++ b/homeassistant/components/xiaomi_miio/services.yaml @@ -211,49 +211,6 @@ fan_set_extra_features: min: 0 max: 1 -fan_set_target_humidity: - name: Fan set target humidity - description: Set the target humidity. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - humidity: - name: Humidity - description: Target humidity. - required: true - selector: - number: - min: 30 - max: 80 - step: 10 - unit_of_measurement: '%' - -fan_set_dry_on: - name: Fan set dry on - description: Turn the dry mode on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_dry_off: - name: Fan set dry off - description: Turn the dry mode off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - fan_set_motor_speed: name: Fan set motor speed description: Set the target motor speed. From 3d52bfc8f6afbec10ee3df46bd54b41077677e83 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 31 Jul 2021 23:17:23 +0200 Subject: [PATCH 072/903] Simplify DATA_TYPE -> struct conversion. (#53805) --- homeassistant/components/modbus/const.py | 15 +-- homeassistant/components/modbus/modbus.py | 104 +++++++++--------- homeassistant/components/modbus/validators.py | 20 +++- 3 files changed, 71 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 49b7683435e..10eb07f801e 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -1,5 +1,4 @@ """Constants used in modbus integration.""" - from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate.const import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN @@ -110,18 +109,8 @@ DEFAULT_HUB = "modbus_hub" DEFAULT_SCAN_INTERVAL = 15 # seconds DEFAULT_SLAVE = 1 DEFAULT_STRUCTURE_PREFIX = ">f" -DEFAULT_STRUCT_FORMAT = { - DATA_TYPE_INT16: ["h", 1], - DATA_TYPE_INT32: ["i", 2], - DATA_TYPE_INT64: ["q", 4], - DATA_TYPE_UINT16: ["H", 1], - DATA_TYPE_UINT32: ["I", 2], - DATA_TYPE_UINT64: ["Q", 4], - DATA_TYPE_FLOAT16: ["e", 1], - DATA_TYPE_FLOAT32: ["f", 2], - DATA_TYPE_FLOAT64: ["d", 4], - DATA_TYPE_STRING: ["s", 1], -} + + DEFAULT_TEMP_UNIT = "C" MODBUS_DOMAIN = "modbus" diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 8d2ea46e293..77d8b669c24 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -1,6 +1,6 @@ """Support for Modbus.""" import asyncio -from copy import deepcopy +from collections import namedtuple import logging from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient @@ -54,54 +54,52 @@ from .const import ( SERVICE_WRITE_REGISTER, ) -ENTRY_FUNC = "func" -ENTRY_ATTR = "attr" -ENTRY_NAME = "name" - _LOGGER = logging.getLogger(__name__) -PYMODBUS_CALL = { - CALL_TYPE_COIL: { - ENTRY_ATTR: "bits", - ENTRY_NAME: "read_coils", - ENTRY_FUNC: None, - }, - CALL_TYPE_DISCRETE: { - ENTRY_ATTR: "bits", - ENTRY_NAME: "read_discrete_inputs", - ENTRY_FUNC: None, - }, - CALL_TYPE_REGISTER_HOLDING: { - ENTRY_ATTR: "registers", - ENTRY_NAME: "read_holding_registers", - ENTRY_FUNC: None, - }, - CALL_TYPE_REGISTER_INPUT: { - ENTRY_ATTR: "registers", - ENTRY_NAME: "read_input_registers", - ENTRY_FUNC: None, - }, - CALL_TYPE_WRITE_COIL: { - ENTRY_ATTR: "value", - ENTRY_NAME: "write_coil", - ENTRY_FUNC: None, - }, - CALL_TYPE_WRITE_COILS: { - ENTRY_ATTR: "count", - ENTRY_NAME: "write_coils", - ENTRY_FUNC: None, - }, - CALL_TYPE_WRITE_REGISTER: { - ENTRY_ATTR: "value", - ENTRY_NAME: "write_register", - ENTRY_FUNC: None, - }, - CALL_TYPE_WRITE_REGISTERS: { - ENTRY_ATTR: "count", - ENTRY_NAME: "write_registers", - ENTRY_FUNC: None, - }, -} +ConfEntry = namedtuple("ConfEntry", "call_type attr func_name") +RunEntry = namedtuple("RunEntry", "attr func") +PYMODBUS_CALL = [ + ConfEntry( + CALL_TYPE_COIL, + "bits", + "read_coils", + ), + ConfEntry( + CALL_TYPE_DISCRETE, + "bits", + "read_discrete_inputs", + ), + ConfEntry( + CALL_TYPE_REGISTER_HOLDING, + "registers", + "read_holding_registers", + ), + ConfEntry( + CALL_TYPE_REGISTER_INPUT, + "registers", + "read_input_registers", + ), + ConfEntry( + CALL_TYPE_WRITE_COIL, + "value", + "write_coil", + ), + ConfEntry( + CALL_TYPE_WRITE_COILS, + "count", + "write_coils", + ), + ConfEntry( + CALL_TYPE_WRITE_REGISTER, + "value", + "write_register", + ), + ConfEntry( + CALL_TYPE_WRITE_REGISTERS, + "count", + "write_registers", + ), +] async def async_modbus_setup( @@ -197,7 +195,7 @@ class ModbusHub: self._config_name = client_config[CONF_NAME] self._config_type = client_config[CONF_TYPE] self._config_delay = client_config[CONF_DELAY] - self._pb_call = deepcopy(PYMODBUS_CALL) + self._pb_call = {} self._pb_class = { CONF_SERIAL: ModbusSerialClient, CONF_TCP: ModbusTcpClient, @@ -246,8 +244,9 @@ class ModbusHub: self._log_error(str(exception_error), error_state=False) return False - for entry in self._pb_call.values(): - entry[ENTRY_FUNC] = getattr(self._client, entry[ENTRY_NAME]) + for entry in PYMODBUS_CALL: + func = getattr(self._client, entry.func_name) + self._pb_call[entry.call_type] = RunEntry(entry.attr, func) await self.async_connect_task() return True @@ -301,12 +300,13 @@ class ModbusHub: def _pymodbus_call(self, unit, address, value, use_call): """Call sync. pymodbus.""" kwargs = {"unit": unit} if unit else {} + entry = self._pb_call[use_call] try: - result = self._pb_call[use_call][ENTRY_FUNC](address, value, **kwargs) + result = entry.func(address, value, **kwargs) except ModbusException as exception_error: self._log_error(str(exception_error)) return None - if not hasattr(result, self._pb_call[use_call][ENTRY_ATTR]): + if not hasattr(result, entry.attr): self._log_error(str(result)) return None self._in_error = False diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 8b94151e233..3efb61f8027 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -1,6 +1,7 @@ """Validate Modbus configuration.""" from __future__ import annotations +from collections import namedtuple import logging import struct from typing import Any @@ -29,12 +30,12 @@ from .const import ( DATA_TYPE_INT16, DATA_TYPE_INT32, DATA_TYPE_INT64, + DATA_TYPE_STRING, DATA_TYPE_UINT, DATA_TYPE_UINT16, DATA_TYPE_UINT32, DATA_TYPE_UINT64, DEFAULT_SCAN_INTERVAL, - DEFAULT_STRUCT_FORMAT, PLATFORMS, ) @@ -57,6 +58,19 @@ OLD_DATA_TYPES = { 4: DATA_TYPE_FLOAT64, }, } +ENTRY = namedtuple("ENTRY", ["struct_id", "register_count"]) +DEFAULT_STRUCT_FORMAT = { + DATA_TYPE_INT16: ENTRY("h", 1), + DATA_TYPE_INT32: ENTRY("i", 2), + DATA_TYPE_INT64: ENTRY("q", 4), + DATA_TYPE_UINT16: ENTRY("H", 1), + DATA_TYPE_UINT32: ENTRY("I", 2), + DATA_TYPE_UINT64: ENTRY("Q", 4), + DATA_TYPE_FLOAT16: ENTRY("e", 1), + DATA_TYPE_FLOAT32: ENTRY("f", 2), + DATA_TYPE_FLOAT64: ENTRY("d", 4), + DATA_TYPE_STRING: ENTRY("s", 1), +} def struct_validator(config): @@ -79,9 +93,9 @@ def struct_validator(config): if structure: error = f"{name} structure: cannot be mixed with {data_type}" raise vol.Invalid(error) - structure = f">{DEFAULT_STRUCT_FORMAT[data_type][0]}" + structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" if CONF_COUNT not in config: - config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type][1] + config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count else: if not structure: error = ( From e41bc1a0daea84a5ed6744db861d205dd8faabcd Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 31 Jul 2021 23:18:37 +0200 Subject: [PATCH 073/903] Activate mypy for hdmi_cec (#53763) * Please mypy. * Remove CEC_DEVICES. --- homeassistant/components/hdmi_cec/__init__.py | 9 ++++----- homeassistant/components/hdmi_cec/media_player.py | 4 +++- homeassistant/components/hdmi_cec/switch.py | 6 ++++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 71826429040..471542824de 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -1,5 +1,6 @@ """Support for HDMI CEC.""" -from collections import defaultdict +from __future__ import annotations + from functools import partial, reduce import logging import multiprocessing @@ -66,8 +67,6 @@ ICONS_BY_TYPE = { 5: ICON_AUDIO, } -CEC_DEVICES = defaultdict(list) - CMD_UP = "up" CMD_DOWN = "down" CMD_MUTE = "mute" @@ -134,7 +133,7 @@ SERVICE_POWER_ON = "power_on" SERVICE_STANDBY = "standby" # pylint: disable=unnecessary-lambda -DEVICE_SCHEMA = vol.Schema( +DEVICE_SCHEMA: vol.Schema = vol.Schema( { vol.All(cv.positive_int): vol.Any( lambda devices: DEVICE_SCHEMA(devices), cv.string @@ -376,7 +375,7 @@ class CecEntity(Entity): """Initialize the device.""" self._device = device self._icon = None - self._state = None + self._state: str | None = None self._logical_address = logical self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index c3cab6a8f98..1a5b3a6fc51 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -1,4 +1,6 @@ """Support for HDMI CEC devices as media players.""" +from __future__ import annotations + import logging from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand @@ -149,7 +151,7 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity): self.send_keypress(KEY_VOLUME_DOWN) @property - def state(self) -> str: + def state(self) -> str | None: """Cache state of device.""" return self._state diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py index 5de38675fca..2ba606c9245 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -1,4 +1,6 @@ """Support for HDMI CEC devices as switches.""" +from __future__ import annotations + import logging from homeassistant.components.switch import DOMAIN, SwitchEntity @@ -56,7 +58,7 @@ class CecSwitchEntity(CecEntity, SwitchEntity): """Return True if entity is on.""" return self._state == STATE_ON - @property - def state(self) -> str: + @property # type: ignore + def state(self) -> str | None: """Return the cached state of device.""" return self._state diff --git a/mypy.ini b/mypy.ini index 8c5882b720d..a26926b4012 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1387,9 +1387,6 @@ ignore_errors = true [mypy-homeassistant.components.hassio.*] ignore_errors = true -[mypy-homeassistant.components.hdmi_cec.*] -ignore_errors = true - [mypy-homeassistant.components.here_travel_time.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 62da8b4fb5f..1b47ff8a16c 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -62,7 +62,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.habitica.*", "homeassistant.components.harmony.*", "homeassistant.components.hassio.*", - "homeassistant.components.hdmi_cec.*", "homeassistant.components.here_travel_time.*", "homeassistant.components.hisense_aehw4a1.*", "homeassistant.components.home_connect.*", From 673be4f7ddb0ad498948eb7fd37886d9a43e39aa Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Sat, 31 Jul 2021 18:22:19 -0400 Subject: [PATCH 074/903] Bump Amcrest version to 1.8.0 (#53784) * Bump Amcrest version to 1.8.0 * Add codeowner to Amcrest. Co-authored-by: jan Iversen --- CODEOWNERS | 1 + homeassistant/components/amcrest/__init__.py | 8 ++++---- homeassistant/components/amcrest/camera.py | 2 +- homeassistant/components/amcrest/manifest.json | 4 ++-- requirements_all.txt | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 29906631254..371a03a0d91 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -37,6 +37,7 @@ homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/ambee/* @frenck homeassistant/components/ambiclimate/* @danielhiversen homeassistant/components/ambient_station/* @bachya +homeassistant/components/amcrest/* @flacjacket homeassistant/components/analytics/* @home-assistant/core @ludeeus homeassistant/components/androidtv/* @JeffLIrion homeassistant/components/apache_kafka/* @bachya diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 8d274f12044..20775688b1b 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -5,7 +5,7 @@ import logging import threading import aiohttp -from amcrest import AmcrestError, Http, LoginError +from amcrest import AmcrestError, ApiWrapper, LoginError import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_CONTROL @@ -114,8 +114,8 @@ CONFIG_SCHEMA = vol.Schema( ) -class AmcrestChecker(Http): - """amcrest.Http wrapper for catching errors.""" +class AmcrestChecker(ApiWrapper): + """amcrest.ApiWrapper wrapper for catching errors.""" def __init__(self, hass, name, host, port, user, password): """Initialize.""" @@ -154,7 +154,7 @@ class AmcrestChecker(Http): ) def command(self, *args, **kwargs): - """amcrest.Http.command wrapper to catch errors.""" + """amcrest.ApiWrapper.command wrapper to catch errors.""" try: ret = super().command(*args, **kwargs) except LoginError as ex: diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 92453d24144..5c7f8acf94a 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -571,7 +571,7 @@ class AmcrestCam(Camera): def _goto_preset(self, preset): """Move camera position and zoom to preset.""" try: - self._api.go_to_preset(action="start", preset_point_number=preset) + self._api.go_to_preset(preset_point_number=preset) except AmcrestError as error: log_update_error( _LOGGER, "move", self.name, f"camera to preset {preset}", error diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 702e6a61487..acd93c4e2ed 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -2,8 +2,8 @@ "domain": "amcrest", "name": "Amcrest", "documentation": "https://www.home-assistant.io/integrations/amcrest", - "requirements": ["amcrest==1.7.2"], + "requirements": ["amcrest==1.8.0"], "dependencies": ["ffmpeg"], - "codeowners": [], + "codeowners": ["@flacjacket"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 9ff6d796e4f..61239fbbe36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -266,7 +266,7 @@ ambee==0.3.0 ambiclimate==0.2.1 # homeassistant.components.amcrest -amcrest==1.7.2 +amcrest==1.8.0 # homeassistant.components.androidtv androidtv[async]==0.0.60 From 822e8645ebf7884ddd9563f29925cf25a4b2c2f7 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 1 Aug 2021 04:08:39 -0400 Subject: [PATCH 075/903] Fix file path error in nfandroidtv (#53814) --- homeassistant/components/nfandroidtv/notify.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index c2a42760aec..8cc1b0031f7 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -201,8 +201,7 @@ class NFAndroidTVNotificationService(BaseNotificationService): if local_path is not None: # Check whether path is whitelisted in configuration.yaml if self.is_allowed_path(local_path): - with open(local_path, "rb") as path_handle: - return path_handle + return open(local_path, "rb") # pylint: disable=consider-using-with _LOGGER.warning("'%s' is not secure to load data from!", local_path) else: _LOGGER.warning("Neither URL nor local path found in params!") From 7d1324d66d3b0ec5126ca7070986c4dcda6c974a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 1 Aug 2021 23:58:31 +0200 Subject: [PATCH 076/903] Remove `led` from Xiaomi Miio humidifier features (#53796) --- homeassistant/components/xiaomi_miio/const.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index e01b3f20f0b..a2f7679bf1b 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -264,7 +264,6 @@ FEATURE_FLAGS_AIRPURIFIER_V3 = ( FEATURE_FLAGS_AIRHUMIDIFIER = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED | FEATURE_SET_LED_BRIGHTNESS | FEATURE_SET_TARGET_HUMIDITY ) From da1a9bcbf04efd4a902477c85f164a46a64a9df2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 1 Aug 2021 23:58:55 +0200 Subject: [PATCH 077/903] Do not block setup of TP-Link when device unreachable (#53770) --- homeassistant/components/tplink/__init__.py | 70 ++++++++++++++++---- homeassistant/components/tplink/const.py | 2 + tests/components/tplink/consts.py | 29 +++++++- tests/components/tplink/test_init.py | 73 ++++++++++++++------- 4 files changed, 137 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index e309d2c5082..88160722669 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging import time +from typing import Any from pyHS100.smartdevice import SmartDevice, SmartDeviceException from pyHS100.smartplug import SmartPlug @@ -22,9 +23,9 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.dt import utc_from_timestamp @@ -44,6 +45,8 @@ from .const import ( CONF_SWITCH, COORDINATORS, PLATFORMS, + UNAVAILABLE_DEVICES, + UNAVAILABLE_RETRY_DELAY, ) _LOGGER = logging.getLogger(__name__) @@ -96,16 +99,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up TPLink from a config entry.""" config_data = hass.data[DOMAIN].get(ATTR_CONFIG) + if config_data is None and entry.data: + config_data = entry.data + elif config_data is not None: + hass.config_entries.async_update_entry(entry, data=config_data) device_registry = dr.async_get(hass) tplink_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) device_count = len(tplink_devices) + hass_data: dict[str, Any] = hass.data[DOMAIN] # These will contain the initialized devices - hass.data[DOMAIN][CONF_LIGHT] = [] - hass.data[DOMAIN][CONF_SWITCH] = [] - lights: list[SmartDevice] = hass.data[DOMAIN][CONF_LIGHT] - switches: list[SmartPlug] = hass.data[DOMAIN][CONF_SWITCH] + hass_data[CONF_LIGHT] = [] + hass_data[CONF_SWITCH] = [] + hass_data[UNAVAILABLE_DEVICES] = [] + lights: list[SmartDevice] = hass_data[CONF_LIGHT] + switches: list[SmartPlug] = hass_data[CONF_SWITCH] + unavailable_devices: list[SmartDevice] = hass_data[UNAVAILABLE_DEVICES] # Add static devices static_devices = SmartDevices() @@ -136,22 +146,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ", ".join(d.host for d in switches), ) + async def async_retry_devices(self) -> None: + """Retry unavailable devices.""" + unavailable_devices: list[SmartDevice] = hass_data[UNAVAILABLE_DEVICES] + _LOGGER.debug( + "retry during setup unavailable devices: %s", + [d.host for d in unavailable_devices], + ) + + for device in unavailable_devices: + try: + device.get_sysinfo() + except SmartDeviceException: + continue + _LOGGER.debug( + "at least one device is available again, so reload integration" + ) + await hass.config_entries.async_reload(entry.entry_id) + break + # prepare DataUpdateCoordinators - hass.data[DOMAIN][COORDINATORS] = {} + hass_data[COORDINATORS] = {} for switch in switches: try: await hass.async_add_executor_job(switch.get_sysinfo) - except SmartDeviceException as ex: - _LOGGER.debug(ex) - raise ConfigEntryNotReady from ex + except SmartDeviceException: + _LOGGER.warning( + "Device at '%s' not reachable during setup, will retry later", + switch.host, + ) + unavailable_devices.append(switch) + continue - hass.data[DOMAIN][COORDINATORS][ + hass_data[COORDINATORS][ switch.mac ] = coordinator = SmartPlugDataUpdateCoordinator(hass, switch) await coordinator.async_config_entry_first_refresh() + if unavailable_devices: + entry.async_on_unload( + async_track_time_interval( + hass, async_retry_devices, UNAVAILABLE_RETRY_DELAY + ) + ) + unavailable_devices_hosts = [d.host for d in unavailable_devices] + hass_data[CONF_SWITCH] = [ + s for s in switches if s.host not in unavailable_devices_hosts + ] + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -159,10 +203,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - platforms = [platform for platform in PLATFORMS if hass.data[DOMAIN].get(platform)] - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + hass_data: dict[str, Any] = hass.data[DOMAIN] if unload_ok: - hass.data[DOMAIN].clear() + hass_data.clear() return unload_ok diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 888d671096d..60e06fd1ffe 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -5,6 +5,8 @@ import datetime DOMAIN = "tplink" COORDINATORS = "coordinators" +UNAVAILABLE_DEVICES = "unavailable_devices" +UNAVAILABLE_RETRY_DELAY = datetime.timedelta(seconds=300) MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=8) MAX_DISCOVERY_RETRIES = 4 diff --git a/tests/components/tplink/consts.py b/tests/components/tplink/consts.py index de134ddbe07..95177a12a9c 100644 --- a/tests/components/tplink/consts.py +++ b/tests/components/tplink/consts.py @@ -1,6 +1,6 @@ """Constants for the TP-Link component tests.""" -SMARTPLUGSWITCH_DATA = { +SMARTPLUG_HS110_DATA = { "sysinfo": { "sw_ver": "1.0.4 Build 191111 Rel.143500", "hw_ver": "4.0", @@ -34,6 +34,33 @@ SMARTPLUGSWITCH_DATA = { "err_code": 0, }, } +SMARTPLUG_HS100_DATA = { + "sysinfo": { + "sw_ver": "1.0.4 Build 191111 Rel.143500", + "hw_ver": "4.0", + "model": "HS100(EU)", + "deviceId": "4C56447B395BB7A2FAC68C9DFEE2E84163222581", + "oemId": "40F54B43071E9436B6395611E9D91CEA", + "hwId": "A6C77E4FDD238B53D824AC8DA361F043", + "rssi": -24, + "longitude_i": 130793, + "latitude_i": 480582, + "alias": "SmartPlug", + "status": "new", + "mic_type": "IOT.SMARTPLUGSWITCH", + "feature": "TIM:", + "mac": "A9:F4:3D:A4:E3:47", + "updating": 0, + "led_off": 0, + "relay_state": 0, + "on_time": 0, + "active_mode": "none", + "icon_hash": "", + "dev_name": "Smart Wi-Fi Plug", + "next_action": {"type": -1}, + "err_code": 0, + } +} SMARTSTRIPWITCH_DATA = { "sysinfo": { "sw_ver": "1.0.4 Build 191111 Rel.143500", diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index fb3f44709fc..a201788f35b 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -11,6 +11,8 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import tplink +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.tplink.common import SmartDevices from homeassistant.components.tplink.const import ( CONF_DIMMER, @@ -19,16 +21,21 @@ from homeassistant.components.tplink.const import ( CONF_SW_VERSION, CONF_SWITCH, COORDINATORS, + UNAVAILABLE_RETRY_DELAY, ) from homeassistant.components.tplink.sensor import ENERGY_SENSORS from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import slugify +from homeassistant.util import dt, slugify -from tests.common import MockConfigEntry, mock_coro -from tests.components.tplink.consts import SMARTPLUGSWITCH_DATA, SMARTSTRIPWITCH_DATA +from tests.common import MockConfigEntry, async_fire_time_changed, mock_coro +from tests.components.tplink.consts import ( + SMARTPLUG_HS100_DATA, + SMARTPLUG_HS110_DATA, + SMARTSTRIPWITCH_DATA, +) async def test_creating_entry_tries_discover(hass): @@ -220,9 +227,9 @@ async def test_platforms_are_initialized(hass: HomeAssistant): light = SmartBulb("123.123.123.123") switch = SmartPlug("321.321.321.321") - switch.get_sysinfo = MagicMock(return_value=SMARTPLUGSWITCH_DATA["sysinfo"]) + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS110_DATA["sysinfo"]) switch.get_emeter_realtime = MagicMock( - return_value=EmeterStatus(SMARTPLUGSWITCH_DATA["realtime"]) + return_value=EmeterStatus(SMARTPLUG_HS110_DATA["realtime"]) ) switch.get_emeter_daily = MagicMock( return_value={int(time.strftime("%e")): 1.123} @@ -270,25 +277,22 @@ async def test_smartplug_without_consumption_sensors(hass: HomeAssistant): ), patch( "homeassistant.components.tplink.light.async_setup_entry", return_value=mock_coro(True), - ), patch( - "homeassistant.components.tplink.switch.async_setup_entry", - return_value=mock_coro(True), ), patch( "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False ): switch = SmartPlug("321.321.321.321") - switch.get_sysinfo = MagicMock(return_value=SMARTPLUGSWITCH_DATA["sysinfo"]) + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS100_DATA["sysinfo"]) get_static_devices.return_value = SmartDevices([], [switch]) await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() - for description in ENERGY_SENSORS: - state = hass.states.get( - f"sensor.{switch.alias}_{slugify(description.name)}" - ) - assert state is None + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 1 + + entities = hass.states.async_entity_ids(SENSOR_DOMAIN) + assert len(entities) == 0 async def test_smartstrip_device(hass: HomeAssistant): @@ -346,8 +350,8 @@ async def test_no_config_creates_no_entry(hass): assert mock_setup.call_count == 0 -async def test_not_ready(hass: HomeAssistant): - """Test for not ready when configured devices are not available.""" +async def test_not_available_at_startup(hass: HomeAssistant): + """Test when configured devices are not available.""" config = { tplink.DOMAIN: { CONF_DISCOVERY: False, @@ -362,9 +366,6 @@ async def test_not_ready(hass: HomeAssistant): ), patch( "homeassistant.components.tplink.light.async_setup_entry", return_value=mock_coro(True), - ), patch( - "homeassistant.components.tplink.switch.async_setup_entry", - return_value=mock_coro(True), ), patch( "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False ): @@ -373,13 +374,39 @@ async def test_not_ready(hass: HomeAssistant): switch.get_sysinfo = MagicMock(side_effect=SmartDeviceException()) get_static_devices.return_value = SmartDevices([], [switch]) + # run setup while device unreachable await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() entries = hass.config_entries.async_entries(tplink.DOMAIN) - assert len(entries) == 1 - assert entries[0].state is config_entries.ConfigEntryState.SETUP_RETRY + assert entries[0].state is config_entries.ConfigEntryState.LOADED + + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 0 + + # retrying with still unreachable device + async_fire_time_changed(hass, dt.utcnow() + UNAVAILABLE_RETRY_DELAY) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(tplink.DOMAIN) + assert len(entries) == 1 + assert entries[0].state is config_entries.ConfigEntryState.LOADED + + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 0 + + # retrying with now reachable device + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS100_DATA["sysinfo"]) + async_fire_time_changed(hass, dt.utcnow() + UNAVAILABLE_RETRY_DELAY) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(tplink.DOMAIN) + assert len(entries) == 1 + assert entries[0].state is config_entries.ConfigEntryState.LOADED + + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 1 @pytest.mark.parametrize("platform", ["switch", "light"]) @@ -406,9 +433,9 @@ async def test_unload(hass, platform): light = SmartBulb("123.123.123.123") switch = SmartPlug("321.321.321.321") - switch.get_sysinfo = MagicMock(return_value=SMARTPLUGSWITCH_DATA["sysinfo"]) + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS110_DATA["sysinfo"]) switch.get_emeter_realtime = MagicMock( - return_value=EmeterStatus(SMARTPLUGSWITCH_DATA["realtime"]) + return_value=EmeterStatus(SMARTPLUG_HS110_DATA["realtime"]) ) if platform == "light": get_static_devices.return_value = SmartDevices([light], []) From 736fb2e90dab9ff702503000150d2283f8d28757 Mon Sep 17 00:00:00 2001 From: B-Hartley Date: Sun, 1 Aug 2021 23:01:34 +0100 Subject: [PATCH 078/903] ForecastSolar - power production now w not k w (#53797) --- homeassistant/components/forecast_solar/const.py | 11 ++++------- tests/components/forecast_solar/test_sensor.py | 8 ++++---- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index 7f426d2847c..7ae6fe01d42 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -53,7 +53,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( key="power_production_now", name="Estimated Power Production - Now", device_class=DEVICE_CLASS_POWER, - state=lambda estimate: estimate.power_production_now / 1000, + state=lambda estimate: estimate.power_production_now, state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=POWER_WATT, ), @@ -61,8 +61,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( key="power_production_next_hour", state=lambda estimate: estimate.power_production_at_time( estimate.now() + timedelta(hours=1) - ) - / 1000, + ), name="Estimated Power Production - Next Hour", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, @@ -72,8 +71,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( key="power_production_next_12hours", state=lambda estimate: estimate.power_production_at_time( estimate.now() + timedelta(hours=12) - ) - / 1000, + ), name="Estimated Power Production - Next 12 Hours", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, @@ -83,8 +81,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( key="power_production_next_24hours", state=lambda estimate: estimate.power_production_at_time( estimate.now() + timedelta(hours=24) - ) - / 1000, + ), name="Estimated Power Production - Next 24 Hours", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index 8b8c1cc933e..a2b105ccbd1 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -96,7 +96,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_power_production_now" - assert state.state == "300.0" + assert state.state == "300000" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Power Production - Now" ) @@ -175,17 +175,17 @@ async def test_disabled_by_default( ( "power_production_next_12hours", "Estimated Power Production - Next 12 Hours", - "600.0", + "600000", ), ( "power_production_next_24hours", "Estimated Power Production - Next 24 Hours", - "700.0", + "700000", ), ( "power_production_next_hour", "Estimated Power Production - Next Hour", - "400.0", + "400000", ), ], ) From 32c2d4286326a34e4a22625d70887df5e5e0543c Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 2 Aug 2021 00:06:28 +0200 Subject: [PATCH 079/903] Update frontend to 20210801.0 (#53841) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 02ee134523e..84de9b92c97 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210730.0" + "home-assistant-frontend==20210801.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1418f59fedf..db6ffd1d071 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210730.0 +home-assistant-frontend==20210801.0 httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 61239fbbe36..d8e4523fab6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -782,7 +782,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210730.0 +home-assistant-frontend==20210801.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c934e07675..ea0e298a96b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -449,7 +449,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210730.0 +home-assistant-frontend==20210801.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 8ac1f5d28a05c781f22c20dbc4a841a147e891f2 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 1 Aug 2021 20:35:03 -0700 Subject: [PATCH 080/903] Add energy consumption sensors to smartthings devices (#53759) --- .../components/smartthings/sensor.py | 62 +++++++++++++++++++ tests/components/smartthings/test_sensor.py | 49 +++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 7a7f9a51855..5059bcc4403 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -220,6 +220,7 @@ CAPABILITY_TO_SENSORS = { Capability.oven_setpoint: [ Map(Attribute.oven_setpoint, "Oven Set Point", None, None, None) ], + Capability.power_consumption_report: [], Capability.power_meter: [ Map( Attribute.power, @@ -388,6 +389,13 @@ CAPABILITY_TO_SENSORS = { UNITS = {"C": TEMP_CELSIUS, "F": TEMP_FAHRENHEIT} THREE_AXIS_NAMES = ["X Coordinate", "Y Coordinate", "Z Coordinate"] +POWER_CONSUMPTION_REPORT_NAMES = [ + "energy", + "power", + "deltaEnergy", + "powerEnergy", + "energySaved", +] async def async_setup_entry(hass, config_entry, async_add_entities): @@ -403,6 +411,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for index in range(len(THREE_AXIS_NAMES)) ] ) + elif capability == Capability.power_consumption_report: + sensors.extend( + [ + SmartThingsPowerConsumptionSensor(device, report_name) + for report_name in POWER_CONSUMPTION_REPORT_NAMES + ] + ) else: maps = CAPABILITY_TO_SENSORS[capability] sensors.extend( @@ -526,3 +541,50 @@ class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): 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 + + @property + def name(self) -> str: + """Return the name of the binary sensor.""" + return f"{self._device.label} {self.report_name}" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._device.device_id}.{self.report_name}" + + @property + def state(self): + """Return the state of the sensor.""" + value = self._device.status.attributes[Attribute.power_consumption].value + if 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 device_class(self): + """Return the device class of the sensor.""" + if self.report_name == "power": + return DEVICE_CLASS_POWER + return DEVICE_CLASS_ENERGY + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + if self.report_name == "power": + return POWER_WATT + return ENERGY_KILO_WATT_HOUR diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index ffb577c903a..fa849a3cc67 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -139,6 +139,55 @@ async def test_energy_sensors_for_switch_device(hass, device_factory): assert entry.manufacturer == "Unavailable" +async def test_power_consumption_sensor(hass, device_factory): + """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", + } + }, + ) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + # 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" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + state = hass.states.get("sensor.refrigerator_power") + assert state + assert state.state == "109" + entry = entity_registry.async_get("sensor.refrigerator_power") + assert entry + assert entry.unique_id == f"{device.device_id}.power" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + async def test_update_from_signal(hass, device_factory): """Test the binary_sensor updates when receiving a signal.""" # Arrange From 8c5620e74b43dbffec4de4d1e5d21731f20f0762 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 2 Aug 2021 03:40:04 +0000 Subject: [PATCH 081/903] [ci skip] Translation update --- .../components/abode/translations/es-419.json | 6 ++- .../accuweather/translations/es-419.json | 5 ++ .../components/adax/translations/cs.json | 19 +++++++ .../components/adax/translations/fr.json | 6 +++ .../components/adax/translations/it.json | 20 ++++++++ .../components/aemet/translations/de.json | 2 +- .../components/aemet/translations/fr.json | 9 ++++ .../airvisual/translations/sensor.fr.json | 13 ++++- .../airvisual/translations/sensor.he.json | 8 +++ .../airvisual/translations/sensor.it.json | 20 ++++++++ .../airvisual/translations/sensor.no.json | 8 +++ .../alarm_control_panel/translations/fr.json | 2 + .../components/ambee/translations/fr.json | 28 +++++++++++ .../ambee/translations/sensor.fr.json | 10 ++++ .../ambee/translations/sensor.he.json | 9 ++++ .../components/arcam_fmj/translations/he.json | 8 ++- .../components/asuswrt/translations/de.json | 2 +- .../components/august/translations/he.json | 2 +- .../components/auth/translations/he.json | 6 +-- .../binary_sensor/translations/he.json | 24 ++++----- .../components/bosch_shc/translations/fr.json | 38 ++++++++++++++ .../components/brother/translations/de.json | 2 +- .../buienradar/translations/fr.json | 6 +++ .../components/calendar/translations/he.json | 2 +- .../components/cast/translations/he.json | 2 +- .../components/climacell/translations/de.json | 2 +- .../cloudflare/translations/cs.json | 6 +++ .../cloudflare/translations/fr.json | 7 +++ .../components/co2signal/translations/cs.json | 30 +++++++++++ .../components/co2signal/translations/fr.json | 8 ++- .../components/co2signal/translations/it.json | 34 +++++++++++++ .../components/coinbase/translations/cs.json | 19 +++++++ .../components/coinbase/translations/de.json | 2 +- .../components/coinbase/translations/fr.json | 41 +++++++++++++++ .../components/coinbase/translations/nl.json | 2 +- .../configurator/translations/he.json | 6 +-- .../conversation/translations/he.json | 2 +- .../coronavirus/translations/fr.json | 3 +- .../components/cover/translations/he.json | 4 +- .../demo/translations/select.fr.json | 9 ++++ .../device_tracker/translations/he.json | 2 +- .../devolo_home_control/translations/cs.json | 3 +- .../devolo_home_control/translations/fr.json | 6 ++- .../devolo_home_control/translations/he.json | 2 +- .../components/directv/translations/he.json | 8 +++ .../components/dsmr/translations/fr.json | 25 +++++++++- .../components/emonitor/translations/he.json | 2 +- .../components/energy/translations/cs.json | 3 ++ .../components/energy/translations/de.json | 3 ++ .../components/energy/translations/fr.json | 3 ++ .../components/energy/translations/he.json | 3 ++ .../components/energy/translations/it.json | 3 ++ .../components/energy/translations/nl.json | 3 ++ .../components/energy/translations/pl.json | 3 ++ .../components/energy/translations/ru.json | 3 ++ .../energy/translations/zh-Hant.json | 3 ++ .../enphase_envoy/translations/fr.json | 3 +- .../components/flipr/translations/cs.json | 20 ++++++++ .../components/flipr/translations/fr.json | 1 + .../components/flipr/translations/he.json | 5 ++ .../components/flipr/translations/it.json | 30 +++++++++++ .../components/flipr/translations/pl.json | 4 +- .../components/flume/translations/fr.json | 3 +- .../forecast_solar/translations/fr.json | 22 +++++++- .../freedompro/translations/fr.json | 11 ++++ .../components/fritz/translations/fr.json | 25 +++++++++- .../garages_amsterdam/translations/fr.json | 18 +++++++ .../components/goalzero/translations/fr.json | 8 ++- .../components/gogogate2/translations/fr.json | 1 + .../components/group/translations/he.json | 4 +- .../growatt_server/translations/cs.json | 11 ++++ .../growatt_server/translations/fr.json | 28 +++++++++++ .../growatt_server/translations/he.json | 5 ++ .../growatt_server/translations/it.json | 1 + .../components/guardian/translations/fr.json | 3 ++ .../components/harmony/translations/he.json | 2 +- .../home_plus_control/translations/de.json | 2 +- .../components/homekit/translations/it.json | 2 +- .../homekit_controller/translations/fr.json | 2 + .../components/honeywell/translations/cs.json | 15 ++++++ .../components/honeywell/translations/fr.json | 10 +++- .../components/honeywell/translations/it.json | 17 +++++++ .../huawei_lte/translations/fr.json | 3 +- .../components/hue/translations/fr.json | 4 +- .../translations/fr.json | 1 + .../translations/he.json | 2 +- .../input_boolean/translations/he.json | 2 +- .../components/isy994/translations/fr.json | 8 +++ .../keenetic_ndms2/translations/fr.json | 5 +- .../components/kodi/translations/he.json | 2 +- .../components/konnected/translations/he.json | 16 ++++++ .../components/kraken/translations/fr.json | 30 +++++++++++ .../components/kraken/translations/he.json | 21 ++++++++ .../components/litejet/translations/fr.json | 10 ++++ .../components/litejet/translations/it.json | 10 ++++ .../components/lyric/translations/fr.json | 6 ++- .../meteoclimatic/translations/fr.json | 20 ++++++++ .../components/mikrotik/translations/de.json | 6 +-- .../modern_forms/translations/fr.json | 28 +++++++++++ .../components/motioneye/translations/fr.json | 15 ++++-- .../components/myq/translations/fr.json | 3 +- .../components/mysensors/translations/de.json | 2 +- .../components/nam/translations/fr.json | 4 ++ .../components/nest/translations/he.json | 2 +- .../components/nexia/translations/fr.json | 1 + .../nfandroidtv/translations/cs.json | 19 +++++++ .../nfandroidtv/translations/fr.json | 21 ++++++++ .../nfandroidtv/translations/it.json | 21 ++++++++ .../nfandroidtv/translations/nl.json | 1 + .../nfandroidtv/translations/pl.json | 2 +- .../nmap_tracker/translations/cs.json | 7 +++ .../nmap_tracker/translations/fr.json | 32 +++++++++++- .../components/onvif/translations/fr.json | 13 +++++ .../components/onvif/translations/he.json | 2 +- .../ovo_energy/translations/he.json | 3 +- .../philips_js/translations/fr.json | 9 ++++ .../components/picnic/translations/fr.json | 5 ++ .../components/plugwise/translations/he.json | 3 ++ .../components/poolsense/translations/de.json | 2 +- .../components/prosegur/translations/ca.json | 29 +++++++++++ .../components/prosegur/translations/cs.json | 27 ++++++++++ .../components/prosegur/translations/de.json | 29 +++++++++++ .../components/prosegur/translations/et.json | 29 +++++++++++ .../components/prosegur/translations/fr.json | 29 +++++++++++ .../components/prosegur/translations/he.json | 28 +++++++++++ .../components/prosegur/translations/it.json | 29 +++++++++++ .../components/prosegur/translations/nl.json | 29 +++++++++++ .../components/prosegur/translations/no.json | 18 +++++++ .../components/prosegur/translations/pl.json | 29 +++++++++++ .../components/prosegur/translations/ru.json | 29 +++++++++++ .../prosegur/translations/zh-Hant.json | 29 +++++++++++ .../components/ps4/translations/he.json | 4 +- .../pvpc_hourly_pricing/translations/de.json | 2 +- .../pvpc_hourly_pricing/translations/fr.json | 15 ++++++ .../pvpc_hourly_pricing/translations/he.json | 7 +++ .../components/renault/translations/ca.json | 27 ++++++++++ .../components/renault/translations/cs.json | 18 +++++++ .../components/renault/translations/de.json | 27 ++++++++++ .../components/renault/translations/en.json | 44 ++++++++-------- .../components/renault/translations/et.json | 27 ++++++++++ .../components/renault/translations/fr.json | 27 ++++++++++ .../components/renault/translations/he.json | 18 +++++++ .../components/renault/translations/it.json | 27 ++++++++++ .../components/renault/translations/nl.json | 27 ++++++++++ .../components/renault/translations/no.json | 12 +++++ .../components/renault/translations/pl.json | 27 ++++++++++ .../components/renault/translations/ru.json | 27 ++++++++++ .../renault/translations/zh-Hant.json | 27 ++++++++++ .../components/roku/translations/he.json | 8 +++ .../components/roomba/translations/he.json | 2 +- .../components/roon/translations/nl.json | 2 +- .../components/samsungtv/translations/fr.json | 11 +++- .../screenlogic/translations/de.json | 2 +- .../components/select/translations/fr.json | 11 ++++ .../components/sia/translations/fr.json | 50 +++++++++++++++++++ .../simplisafe/translations/de.json | 2 +- .../components/sma/translations/fr.json | 12 +++-- .../smartthings/translations/he.json | 2 +- .../components/smarttub/translations/fr.json | 3 +- .../somfy_mylink/translations/de.json | 2 +- .../components/sonos/translations/fr.json | 1 + .../components/sonos/translations/he.json | 4 +- .../switcher_kis/translations/fr.json | 3 +- .../switcher_kis/translations/it.json | 13 +++++ .../components/syncthing/translations/fr.json | 22 ++++++++ .../synology_dsm/translations/cs.json | 10 +++- .../synology_dsm/translations/fr.json | 6 ++- .../synology_dsm/translations/he.json | 11 +++- .../synology_dsm/translations/it.json | 11 +++- .../system_bridge/translations/fr.json | 9 +++- .../system_health/translations/he.json | 2 +- .../components/tesla/translations/ca.json | 1 + .../components/tesla/translations/de.json | 1 + .../components/tesla/translations/et.json | 1 + .../components/tesla/translations/fr.json | 1 + .../components/tesla/translations/it.json | 1 + .../components/tesla/translations/nl.json | 1 + .../components/tesla/translations/pl.json | 1 + .../components/tesla/translations/ru.json | 1 + .../tesla/translations/zh-Hant.json | 1 + .../totalconnect/translations/fr.json | 3 +- .../components/tplink/translations/he.json | 4 +- .../components/traccar/translations/de.json | 2 +- .../components/unifi/translations/he.json | 8 +++ .../components/upb/translations/de.json | 2 +- .../components/upnp/translations/fr.json | 9 ++++ .../components/upnp/translations/he.json | 6 +++ .../components/wallbox/translations/fr.json | 22 ++++++++ .../components/wemo/translations/fr.json | 5 ++ .../components/wled/translations/fr.json | 9 ++++ .../wolflink/translations/sensor.de.json | 2 +- .../wolflink/translations/sensor.he.json | 1 + .../xiaomi_miio/translations/de.json | 2 +- .../xiaomi_miio/translations/fr.json | 36 +++++++++++-- .../xiaomi_miio/translations/he.json | 2 +- .../yale_smart_alarm/translations/ca.json | 8 +-- .../yale_smart_alarm/translations/cs.json | 28 +++++++++++ .../yale_smart_alarm/translations/en.json | 8 +-- .../yale_smart_alarm/translations/fr.json | 28 +++++++++++ .../yale_smart_alarm/translations/he.json | 28 +++++++++++ .../yale_smart_alarm/translations/it.json | 28 +++++++++++ .../yale_smart_alarm/translations/nl.json | 28 +++++++++++ .../yale_smart_alarm/translations/no.json | 20 ++++++++ .../yale_smart_alarm/translations/pl.json | 28 +++++++++++ .../yale_smart_alarm/translations/ru.json | 8 +-- .../translations/zh-Hant.json | 8 +-- .../yamaha_musiccast/translations/fr.json | 23 +++++++++ .../components/yeelight/translations/fr.json | 4 ++ .../components/youless/translations/ca.json | 15 ++++++ .../components/youless/translations/cs.json | 15 ++++++ .../components/youless/translations/de.json | 15 ++++++ .../components/youless/translations/en.json | 12 ++--- .../components/youless/translations/et.json | 15 ++++++ .../components/youless/translations/fr.json | 15 ++++++ .../components/youless/translations/he.json | 15 ++++++ .../components/youless/translations/it.json | 15 ++++++ .../components/youless/translations/nl.json | 15 ++++++ .../components/youless/translations/no.json | 11 ++++ .../components/youless/translations/pl.json | 15 ++++++ .../components/youless/translations/ru.json | 15 ++++++ .../youless/translations/zh-Hant.json | 15 ++++++ .../components/zha/translations/fr.json | 2 + .../components/zwave/translations/de.json | 2 +- .../components/zwave/translations/he.json | 6 +-- .../components/zwave_js/translations/de.json | 2 +- .../components/zwave_js/translations/fr.json | 48 +++++++++++++++++- 226 files changed, 2440 insertions(+), 174 deletions(-) create mode 100644 homeassistant/components/adax/translations/cs.json create mode 100644 homeassistant/components/adax/translations/it.json create mode 100644 homeassistant/components/airvisual/translations/sensor.he.json create mode 100644 homeassistant/components/airvisual/translations/sensor.it.json create mode 100644 homeassistant/components/airvisual/translations/sensor.no.json create mode 100644 homeassistant/components/ambee/translations/fr.json create mode 100644 homeassistant/components/ambee/translations/sensor.fr.json create mode 100644 homeassistant/components/ambee/translations/sensor.he.json create mode 100644 homeassistant/components/bosch_shc/translations/fr.json create mode 100644 homeassistant/components/co2signal/translations/cs.json create mode 100644 homeassistant/components/co2signal/translations/it.json create mode 100644 homeassistant/components/coinbase/translations/cs.json create mode 100644 homeassistant/components/coinbase/translations/fr.json create mode 100644 homeassistant/components/demo/translations/select.fr.json create mode 100644 homeassistant/components/energy/translations/cs.json create mode 100644 homeassistant/components/energy/translations/de.json create mode 100644 homeassistant/components/energy/translations/fr.json create mode 100644 homeassistant/components/energy/translations/he.json create mode 100644 homeassistant/components/energy/translations/it.json create mode 100644 homeassistant/components/energy/translations/nl.json create mode 100644 homeassistant/components/energy/translations/pl.json create mode 100644 homeassistant/components/energy/translations/ru.json create mode 100644 homeassistant/components/energy/translations/zh-Hant.json create mode 100644 homeassistant/components/flipr/translations/cs.json create mode 100644 homeassistant/components/flipr/translations/it.json create mode 100644 homeassistant/components/garages_amsterdam/translations/fr.json create mode 100644 homeassistant/components/growatt_server/translations/cs.json create mode 100644 homeassistant/components/growatt_server/translations/fr.json create mode 100644 homeassistant/components/honeywell/translations/cs.json create mode 100644 homeassistant/components/honeywell/translations/it.json create mode 100644 homeassistant/components/kraken/translations/fr.json create mode 100644 homeassistant/components/meteoclimatic/translations/fr.json create mode 100644 homeassistant/components/modern_forms/translations/fr.json create mode 100644 homeassistant/components/nfandroidtv/translations/cs.json create mode 100644 homeassistant/components/nfandroidtv/translations/fr.json create mode 100644 homeassistant/components/nfandroidtv/translations/it.json create mode 100644 homeassistant/components/nmap_tracker/translations/cs.json create mode 100644 homeassistant/components/prosegur/translations/ca.json create mode 100644 homeassistant/components/prosegur/translations/cs.json create mode 100644 homeassistant/components/prosegur/translations/de.json create mode 100644 homeassistant/components/prosegur/translations/et.json create mode 100644 homeassistant/components/prosegur/translations/fr.json create mode 100644 homeassistant/components/prosegur/translations/he.json create mode 100644 homeassistant/components/prosegur/translations/it.json create mode 100644 homeassistant/components/prosegur/translations/nl.json create mode 100644 homeassistant/components/prosegur/translations/no.json create mode 100644 homeassistant/components/prosegur/translations/pl.json create mode 100644 homeassistant/components/prosegur/translations/ru.json create mode 100644 homeassistant/components/prosegur/translations/zh-Hant.json create mode 100644 homeassistant/components/renault/translations/ca.json create mode 100644 homeassistant/components/renault/translations/cs.json create mode 100644 homeassistant/components/renault/translations/de.json create mode 100644 homeassistant/components/renault/translations/et.json create mode 100644 homeassistant/components/renault/translations/fr.json create mode 100644 homeassistant/components/renault/translations/he.json create mode 100644 homeassistant/components/renault/translations/it.json create mode 100644 homeassistant/components/renault/translations/nl.json create mode 100644 homeassistant/components/renault/translations/no.json create mode 100644 homeassistant/components/renault/translations/pl.json create mode 100644 homeassistant/components/renault/translations/ru.json create mode 100644 homeassistant/components/renault/translations/zh-Hant.json create mode 100644 homeassistant/components/sia/translations/fr.json create mode 100644 homeassistant/components/switcher_kis/translations/it.json create mode 100644 homeassistant/components/syncthing/translations/fr.json create mode 100644 homeassistant/components/wallbox/translations/fr.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/cs.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/fr.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/he.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/it.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/nl.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/no.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/pl.json create mode 100644 homeassistant/components/yamaha_musiccast/translations/fr.json create mode 100644 homeassistant/components/youless/translations/ca.json create mode 100644 homeassistant/components/youless/translations/cs.json create mode 100644 homeassistant/components/youless/translations/de.json create mode 100644 homeassistant/components/youless/translations/et.json create mode 100644 homeassistant/components/youless/translations/fr.json create mode 100644 homeassistant/components/youless/translations/he.json create mode 100644 homeassistant/components/youless/translations/it.json create mode 100644 homeassistant/components/youless/translations/nl.json create mode 100644 homeassistant/components/youless/translations/no.json create mode 100644 homeassistant/components/youless/translations/pl.json create mode 100644 homeassistant/components/youless/translations/ru.json create mode 100644 homeassistant/components/youless/translations/zh-Hant.json diff --git a/homeassistant/components/abode/translations/es-419.json b/homeassistant/components/abode/translations/es-419.json index 9de6d9d185a..6d380e5bb43 100644 --- a/homeassistant/components/abode/translations/es-419.json +++ b/homeassistant/components/abode/translations/es-419.json @@ -1,9 +1,12 @@ { "config": { "abort": { + "reauth_successful": "La reautenticaci\u00f3n fue exitosa", "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de Abode." }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", "invalid_mfa_code": "C\u00f3digo MFA no v\u00e1lido" }, "step": { @@ -15,7 +18,8 @@ }, "reauth_confirm": { "data": { - "password": "Contrase\u00f1a" + "password": "Contrase\u00f1a", + "username": "Correo electr\u00f3nico" }, "title": "Complete su informaci\u00f3n de inicio de sesi\u00f3n de Abode" }, diff --git a/homeassistant/components/accuweather/translations/es-419.json b/homeassistant/components/accuweather/translations/es-419.json index 92d5d5ef2c2..72d295da073 100644 --- a/homeassistant/components/accuweather/translations/es-419.json +++ b/homeassistant/components/accuweather/translations/es-419.json @@ -1,6 +1,11 @@ { "config": { + "abort": { + "single_instance_allowed": "Ya configurado. Solo es posible una \u00fanica configuraci\u00f3n." + }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_api_key": "Clave de API no v\u00e1lida", "requests_exceeded": "Se super\u00f3 el n\u00famero permitido de solicitudes a la API de Accuweather. Tiene que esperar o cambiar la clave de API." }, "step": { diff --git a/homeassistant/components/adax/translations/cs.json b/homeassistant/components/adax/translations/cs.json new file mode 100644 index 00000000000..ce5fa77543f --- /dev/null +++ b/homeassistant/components/adax/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/fr.json b/homeassistant/components/adax/translations/fr.json index a8f036f6ed5..80164e30b54 100644 --- a/homeassistant/components/adax/translations/fr.json +++ b/homeassistant/components/adax/translations/fr.json @@ -1,11 +1,17 @@ { "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, "error": { + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, "step": { "user": { "data": { + "account_id": "identifiant de compte", + "host": "H\u00f4te", "password": "Mot de passe" } } diff --git a/homeassistant/components/adax/translations/it.json b/homeassistant/components/adax/translations/it.json new file mode 100644 index 00000000000..c0ccf6aff05 --- /dev/null +++ b/homeassistant/components/adax/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida" + }, + "step": { + "user": { + "data": { + "account_id": "ID account", + "host": "Host", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/de.json b/homeassistant/components/aemet/translations/de.json index 2a4a927b90a..0704e7d71ba 100644 --- a/homeassistant/components/aemet/translations/de.json +++ b/homeassistant/components/aemet/translations/de.json @@ -15,7 +15,7 @@ "name": "Name der Integration" }, "description": "Richte die AEMET OpenData Integration ein. Um den API-Schl\u00fcssel zu generieren, besuche https://opendata.aemet.es/centrodedescargas/altaUsuario", - "title": "[void]" + "title": "AEMET OpenData" } } }, diff --git a/homeassistant/components/aemet/translations/fr.json b/homeassistant/components/aemet/translations/fr.json index bb1e792aa5e..4ad76320f03 100644 --- a/homeassistant/components/aemet/translations/fr.json +++ b/homeassistant/components/aemet/translations/fr.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Recueillir les donn\u00e9es des stations m\u00e9t\u00e9orologiques AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.fr.json b/homeassistant/components/airvisual/translations/sensor.fr.json index b3018d53bc2..3050d6fb158 100644 --- a/homeassistant/components/airvisual/translations/sensor.fr.json +++ b/homeassistant/components/airvisual/translations/sensor.fr.json @@ -1,11 +1,20 @@ { "state": { "airvisual__pollutant_label": { - "co": "Monoxyde de carbone" + "co": "Monoxyde de carbone", + "n2": "Dioxyde d'azote", + "o3": "Ozone", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Dioxyde de soufre" }, "airvisual__pollutant_level": { "good": "Bon", - "hazardous": "Hasardeux" + "hazardous": "Hasardeux", + "moderate": "Mod\u00e9rer", + "unhealthy": "Malsain", + "unhealthy_sensitive": "Malsain pour les groupes sensibles", + "very_unhealthy": "Tr\u00e8s malsain" } } } \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.he.json b/homeassistant/components/airvisual/translations/sensor.he.json new file mode 100644 index 00000000000..28ac8c5c3e4 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.he.json @@ -0,0 +1,8 @@ +{ + "state": { + "airvisual__pollutant_level": { + "good": "\u05d8\u05d5\u05d1", + "unhealthy": "\u05dc\u05d0 \u05d1\u05e8\u05d9\u05d0" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.it.json b/homeassistant/components/airvisual/translations/sensor.it.json new file mode 100644 index 00000000000..7fb8b98215c --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.it.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Monossido di carbonio", + "n2": "Anidride nitrosa", + "o3": "Ozono", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Anidride solforosa" + }, + "airvisual__pollutant_level": { + "good": "Buono", + "hazardous": "Pericoloso", + "moderate": "Moderato", + "unhealthy": "Malsano", + "unhealthy_sensitive": "Malsano per gruppi sensibili", + "very_unhealthy": "Molto malsano" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.no.json b/homeassistant/components/airvisual/translations/sensor.no.json new file mode 100644 index 00000000000..86c95f8e8f2 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.no.json @@ -0,0 +1,8 @@ +{ + "state": { + "airvisual__pollutant_label": { + "p1": "PM10", + "p2": "PM2.5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/fr.json b/homeassistant/components/alarm_control_panel/translations/fr.json index c7e010e805e..6d8ee9c08c3 100644 --- a/homeassistant/components/alarm_control_panel/translations/fr.json +++ b/homeassistant/components/alarm_control_panel/translations/fr.json @@ -4,6 +4,7 @@ "arm_away": "Armer {entity_name} en mode \"sortie\"", "arm_home": "Armer {entity_name} en mode \"maison\"", "arm_night": "Armer {entity_name} en mode \"nuit\"", + "arm_vacation": "Armer {entity_name} vacances", "disarm": "D\u00e9sarmer {entity_name}", "trigger": "D\u00e9clencheur {entity_name}" }, @@ -29,6 +30,7 @@ "armed_custom_bypass": "Arm\u00e9 avec exception personnalis\u00e9e", "armed_home": "Enclench\u00e9e (pr\u00e9sent)", "armed_night": "Enclench\u00e9 (nuit)", + "armed_vacation": "Arm\u00e9es vacances", "arming": "Activation", "disarmed": "D\u00e9sactiv\u00e9e", "disarming": "D\u00e9sactivation", diff --git a/homeassistant/components/ambee/translations/fr.json b/homeassistant/components/ambee/translations/fr.json new file mode 100644 index 00000000000..bbb09edf763 --- /dev/null +++ b/homeassistant/components/ambee/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_api_key": "Cl\u00e9 API non valide" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "cl\u00e9 API", + "description": "R\u00e9-authentifiez-vous avec votre compte Ambee." + } + }, + "user": { + "data": { + "api_key": "cl\u00e9 API", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nom" + }, + "description": "Configurer Ambee pour l'int\u00e9grer \u00e0 Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.fr.json b/homeassistant/components/ambee/translations/sensor.fr.json new file mode 100644 index 00000000000..76dc3fe6301 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.fr.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Haute", + "low": "Faible", + "moderate": "Mod\u00e9rer", + "very high": "Tr\u00e8s \u00e9lev\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.he.json b/homeassistant/components/ambee/translations/sensor.he.json new file mode 100644 index 00000000000..14ae06f2bc9 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.he.json @@ -0,0 +1,9 @@ +{ + "state": { + "ambee__risk": { + "high": "\u05d2\u05d1\u05d5\u05d4", + "low": "\u05e0\u05de\u05d5\u05da", + "very high": "\u05d2\u05d1\u05d5\u05d4 \u05de\u05d0\u05d5\u05d3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/he.json b/homeassistant/components/arcam_fmj/translations/he.json index 0a4bd9ca12a..c07b9af0c67 100644 --- a/homeassistant/components/arcam_fmj/translations/he.json +++ b/homeassistant/components/arcam_fmj/translations/he.json @@ -5,10 +5,16 @@ "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, + "error": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + }, "flow_title": "{host}", "step": { "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea Arcam FMJ \u05d1- '{host}' \u05dc-Home Assistant?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea Arcam FMJ \u05d1-`{host}` \u05dc-Home Assistant?" }, "user": { "data": { diff --git a/homeassistant/components/asuswrt/translations/de.json b/homeassistant/components/asuswrt/translations/de.json index fcd2157d321..6a860311a32 100644 --- a/homeassistant/components/asuswrt/translations/de.json +++ b/homeassistant/components/asuswrt/translations/de.json @@ -24,7 +24,7 @@ "username": "Benutzername" }, "description": "Einstellen der erforderlichen Parameter f\u00fcr die Verbindung mit deinem Router.", - "title": "" + "title": "AsusWRT" } } }, diff --git a/homeassistant/components/august/translations/he.json b/homeassistant/components/august/translations/he.json index 5fb689b2562..aeb3c6a9f14 100644 --- a/homeassistant/components/august/translations/he.json +++ b/homeassistant/components/august/translations/he.json @@ -25,7 +25,7 @@ } }, "validation": { - "description": "\u05d0\u05e0\u05d0 \u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea {login_method} \u05e9\u05dc\u05da ({\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9}) \u05d5\u05d4\u05d6\u05df \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05dc\u05d4\u05dc\u05df" + "description": "\u05e0\u05d0 \u05dc\u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea {login_method} ( {username} ) \u05d5\u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05dc\u05d4\u05dc\u05df" } } } diff --git a/homeassistant/components/auth/translations/he.json b/homeassistant/components/auth/translations/he.json index bc1826d4d79..6bbf472a14b 100644 --- a/homeassistant/components/auth/translations/he.json +++ b/homeassistant/components/auth/translations/he.json @@ -2,18 +2,18 @@ "mfa_setup": { "notify": { "abort": { - "no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 notify \u05d6\u05de\u05d9\u05e0\u05d9\u05dd." + "no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 \u05d4\u05ea\u05e8\u05d0\u05d5\u05ea \u05d6\u05de\u05d9\u05e0\u05d9\u05dd." }, "error": { "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1." }, "step": { "init": { - "description": "\u05d1\u05d7\u05e8 \u05d0\u05ea \u05d0\u05d7\u05d3 \u05de\u05e9\u05e8\u05d5\u05ea\u05d9 notify", + "description": "\u05e0\u05d0 \u05dc\u05d1\u05d7\u05d5\u05e8 \u05d0\u05d7\u05d3 \u05de\u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 \u05d4\u05d4\u05d5\u05d3\u05e2\u05d5\u05ea:", "title": "\u05d4\u05d2\u05d3\u05e8 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05d4\u05e0\u05e9\u05dc\u05d7\u05ea \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e8\u05db\u05d9\u05d1 notify" }, "setup": { - "description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 **{notify_service}**. \u05d4\u05d6\u05df \u05d0\u05d5\u05ea\u05d4 \u05dc\u05de\u05d8\u05d4:", + "description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea ** Notify. {notify_service} **. \u05e0\u05d0 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05d5\u05ea\u05d5 \u05dc\u05de\u05d8\u05d4:", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4" } }, diff --git a/homeassistant/components/binary_sensor/translations/he.json b/homeassistant/components/binary_sensor/translations/he.json index 31a75c3acdf..c345b1a94ce 100644 --- a/homeassistant/components/binary_sensor/translations/he.json +++ b/homeassistant/components/binary_sensor/translations/he.json @@ -35,19 +35,19 @@ "on": "\u05de\u05d7\u05d5\u05d1\u05e8" }, "door": { - "off": "\u05e1\u05d2\u05d5\u05e8\u05d4", + "off": "\u05e1\u05d2\u05d5\u05e8", "on": "\u05e4\u05ea\u05d5\u05d7" }, "garage_door": { - "off": "\u05e1\u05d2\u05d5\u05e8\u05d4", - "on": "\u05e4\u05ea\u05d5\u05d7\u05d4" + "off": "\u05e1\u05d2\u05d5\u05e8", + "on": "\u05e4\u05ea\u05d5\u05d7" }, "gas": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "heat": { - "off": "\u05e8\u05d2\u05d9\u05dc", + "off": "\u05e0\u05d5\u05e8\u05de\u05dc\u05d9", "on": "\u05d7\u05dd" }, "light": { @@ -64,7 +64,7 @@ }, "motion": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "moving": { "off": "\u05dc\u05d0 \u05d6\u05d6", @@ -72,10 +72,10 @@ }, "occupancy": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "opening": { - "off": "\u05e0\u05e1\u05d2\u05e8", + "off": "\u05e1\u05d2\u05d5\u05e8", "on": "\u05e4\u05ea\u05d5\u05d7" }, "plug": { @@ -95,18 +95,18 @@ }, "smoke": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "sound": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "vibration": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "window": { - "off": "\u05e0\u05e1\u05d2\u05e8", + "off": "\u05e1\u05d2\u05d5\u05e8", "on": "\u05e4\u05ea\u05d5\u05d7" } }, diff --git a/homeassistant/components/bosch_shc/translations/fr.json b/homeassistant/components/bosch_shc/translations/fr.json new file mode 100644 index 00000000000..38a48b269b4 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/fr.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "pairing_failed": "L'appairage a \u00e9chou\u00e9\u00a0; veuillez v\u00e9rifier que le Bosch Smart Home Controller est en mode d'appairage (voyant clignotant) et que votre mot de passe est correct.", + "session_error": "Erreur de session\u00a0: l'API renvoie un r\u00e9sultat non-OK.", + "unknown": "Erreur inattendue" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Veuillez appuyer sur le bouton situ\u00e9 \u00e0 l'avant du Bosch Smart Home Controller jusqu'\u00e0 ce que le voyant commence \u00e0 clignoter.\n Pr\u00eat \u00e0 continuer \u00e0 configurer {model} @ {host} avec Home Assistant\u00a0?" + }, + "credentials": { + "data": { + "password": "Mot de passe du contr\u00f4leur Smart Home" + } + }, + "reauth_confirm": { + "description": "L'int\u00e9gration bosch_shc doit r\u00e9-authentifier votre compte", + "title": "R\u00e9authentification de l'int\u00e9gration" + }, + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Configurez votre Bosch Smart Home Controller pour permettre la surveillance et le contr\u00f4le avec Home Assistant.", + "title": "Param\u00e8tres d'authentification SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/de.json b/homeassistant/components/brother/translations/de.json index b79ff0a7619..8126a04f21d 100644 --- a/homeassistant/components/brother/translations/de.json +++ b/homeassistant/components/brother/translations/de.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "snmp_error": "SNMP-Server deaktiviert oder Drucker nicht unterst\u00fctzt.", - "wrong_host": " Ung\u00fcltiger Hostname oder IP-Adresse" + "wrong_host": "Ung\u00fcltiger Hostname oder IP-Adresse" }, "flow_title": "{model} {serial_number}", "step": { diff --git a/homeassistant/components/buienradar/translations/fr.json b/homeassistant/components/buienradar/translations/fr.json index d9c2fadcbf7..19b7737ae11 100644 --- a/homeassistant/components/buienradar/translations/fr.json +++ b/homeassistant/components/buienradar/translations/fr.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/calendar/translations/he.json b/homeassistant/components/calendar/translations/he.json index c80aaab5c1e..9a633670698 100644 --- a/homeassistant/components/calendar/translations/he.json +++ b/homeassistant/components/calendar/translations/he.json @@ -5,5 +5,5 @@ "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, - "title": "\u05dc\u05d5\u05bc\u05d7\u05b7 \u05e9\u05c1\u05b8\u05e0\u05b8\u05d4" + "title": "\u05dc\u05d5\u05d7 \u05e9\u05e0\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/cast/translations/he.json b/homeassistant/components/cast/translations/he.json index 3a9552980e6..d50e5b20684 100644 --- a/homeassistant/components/cast/translations/he.json +++ b/homeassistant/components/cast/translations/he.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "\u05e8\u05e7 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Google Cast \u05e0\u05d7\u05d5\u05e6\u05d4." + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, "error": { "invalid_known_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd \u05d7\u05d9\u05d9\u05d1\u05d9\u05dd \u05dc\u05d4\u05d9\u05d5\u05ea \u05e8\u05e9\u05d9\u05de\u05ea \u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd." diff --git a/homeassistant/components/climacell/translations/de.json b/homeassistant/components/climacell/translations/de.json index 8e269db785b..123a1257d99 100644 --- a/homeassistant/components/climacell/translations/de.json +++ b/homeassistant/components/climacell/translations/de.json @@ -25,7 +25,7 @@ "data": { "timestep": "Minuten zwischen den Kurzvorhersagen" }, - "description": "Wenn du die Vorhersage-Entitit\u00e4t \"Kurzvorhersage\" aktivierst, kannst du die Anzahl der Minuten zwischen den einzelnen Vorhersagen konfigurieren. Die Anzahl der bereitgestellten Vorhersagen h\u00e4ngt von der Anzahl der zwischen den Vorhersagen gew\u00e4hlten Minuten ab.", + "description": "Wenn du die Vorhersage-Entitit\u00e4t \"Kurzvorhersage\" aktivierst, kannst du die Anzahl der Minuten zwischen den einzelnen Vorhersagen konfigurieren. Die Anzahl der bereitgestellten Vorhersagen h\u00e4ngt von der Anzahl der zwischen den Vorhersagen gew\u00e4hlten Minuten ab.", "title": "Aktualisiere ClimaCell-Optionen" } } diff --git a/homeassistant/components/cloudflare/translations/cs.json b/homeassistant/components/cloudflare/translations/cs.json index e20f26236be..8f88377860b 100644 --- a/homeassistant/components/cloudflare/translations/cs.json +++ b/homeassistant/components/cloudflare/translations/cs.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9", "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace.", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, @@ -11,6 +12,11 @@ }, "flow_title": "Cloudflare: {name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API token" + } + }, "records": { "data": { "records": "Z\u00e1znamy" diff --git a/homeassistant/components/cloudflare/translations/fr.json b/homeassistant/components/cloudflare/translations/fr.json index be6d4c3e2b3..677dc8552fb 100644 --- a/homeassistant/components/cloudflare/translations/fr.json +++ b/homeassistant/components/cloudflare/translations/fr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "R\u00e9-authentification r\u00e9ussie", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", "unknown": "Erreur inattendue" }, @@ -11,6 +12,12 @@ }, "flow_title": "Cloudflare: {name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Jeton API", + "description": "R\u00e9-authentifiez-vous avec votre compte Cloudflare." + } + }, "records": { "data": { "records": "Enregistrements" diff --git a/homeassistant/components/co2signal/translations/cs.json b/homeassistant/components/co2signal/translations/cs.json new file mode 100644 index 00000000000..954168d1ee2 --- /dev/null +++ b/homeassistant/components/co2signal/translations/cs.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka" + } + }, + "country": { + "data": { + "country_code": "K\u00f3d zem\u011b" + } + }, + "user": { + "data": { + "api_key": "P\u0159\u00edstupov\u00fd token" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/fr.json b/homeassistant/components/co2signal/translations/fr.json index 549124674dd..4b36bd3bd74 100644 --- a/homeassistant/components/co2signal/translations/fr.json +++ b/homeassistant/components/co2signal/translations/fr.json @@ -1,6 +1,8 @@ { "config": { "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "api_ratelimit": "Limite de d\u00e9bit de l\u2019API d\u00e9pass\u00e9e", "unknown": "Erreur inattendue" }, "error": { @@ -22,8 +24,10 @@ }, "user": { "data": { - "api_key": "Token d'acc\u00e8s" - } + "api_key": "Token d'acc\u00e8s", + "location": "Obtenir des donn\u00e9es pour" + }, + "description": "Visitez https://co2signal.com/ pour demander un jeton." } } } diff --git a/homeassistant/components/co2signal/translations/it.json b/homeassistant/components/co2signal/translations/it.json new file mode 100644 index 00000000000..0db63a1e912 --- /dev/null +++ b/homeassistant/components/co2signal/translations/it.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "api_ratelimit": "Limite di frequenza API superato", + "unknown": "Errore imprevisto" + }, + "error": { + "api_ratelimit": "Limite di frequenza API superato", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitudine", + "longitude": "Logitudine" + } + }, + "country": { + "data": { + "country_code": "Prefisso internazionale" + } + }, + "user": { + "data": { + "api_key": "Token di accesso", + "location": "Ottieni dati per" + }, + "description": "Visita https://co2signal.com/ per richiedere un token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/cs.json b/homeassistant/components/coinbase/translations/cs.json new file mode 100644 index 00000000000..24dc9ec4e14 --- /dev/null +++ b/homeassistant/components/coinbase/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/de.json b/homeassistant/components/coinbase/translations/de.json index 25d20fe8cf2..45360acd288 100644 --- a/homeassistant/components/coinbase/translations/de.json +++ b/homeassistant/components/coinbase/translations/de.json @@ -16,7 +16,7 @@ "currencies": "Kontostand W\u00e4hrungen", "exchange_rates": "Wechselkurse" }, - "description": "Bitte gib die Details deines API-Schl\u00fcssels ein, wie von Coinbase bereitgestellt. Trenne mehrere W\u00e4hrungen mit einem Komma (z. B. \"BTC, EUR\")", + "description": "Bitte gib die Details deines API-Schl\u00fcssels ein, wie von Coinbase bereitgestellt.", "title": "Coinbase API Schl\u00fcssel Details" } } diff --git a/homeassistant/components/coinbase/translations/fr.json b/homeassistant/components/coinbase/translations/fr.json new file mode 100644 index 00000000000..e0ec1ae200d --- /dev/null +++ b/homeassistant/components/coinbase/translations/fr.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "api_key": "cl\u00e9 API", + "api_token": "API secr\u00e8te", + "currencies": "Devises du solde du compte", + "exchange_rates": "Taux d'\u00e9change" + }, + "description": "Veuillez saisir les d\u00e9tails de votre cl\u00e9 API tels que fournis par Coinbase.", + "title": "D\u00e9tails de la cl\u00e9 de l'API Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Un ou plusieurs des soldes de devises demand\u00e9s ne sont pas fournis par votre API Coinbase.", + "exchange_rate_unavaliable": "Un ou plusieurs des taux de change demand\u00e9s ne sont pas fournis par Coinbase.", + "unknown": "Erreur inattendue" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Soldes du portefeuille \u00e0 d\u00e9clarer.", + "exchange_base": "Devise de base pour les capteurs de taux de change.", + "exchange_rate_currencies": "Taux de change \u00e0 d\u00e9clarer." + }, + "description": "Ajuster les options de Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/nl.json b/homeassistant/components/coinbase/translations/nl.json index b13caef1976..e277eaf67db 100644 --- a/homeassistant/components/coinbase/translations/nl.json +++ b/homeassistant/components/coinbase/translations/nl.json @@ -16,7 +16,7 @@ "currencies": "Valuta's van rekeningsaldo", "exchange_rates": "Wisselkoersen" }, - "description": "Voer de gegevens van uw API-sleutel in zoals verstrekt door Coinbase. Scheidt meerdere valuta's met een komma (bijv. \"BTC, EUR\")", + "description": "Voer de gegevens van uw API-sleutel in zoals verstrekt door Coinbase.", "title": "Coinbase API Sleutel Details" } } diff --git a/homeassistant/components/configurator/translations/he.json b/homeassistant/components/configurator/translations/he.json index 7cc7aad41d7..aeff95ca5ce 100644 --- a/homeassistant/components/configurator/translations/he.json +++ b/homeassistant/components/configurator/translations/he.json @@ -1,9 +1,9 @@ { "state": { "_": { - "configure": "\u05d4\u05d2\u05d3\u05e8", - "configured": "\u05d4\u05d5\u05d2\u05d3\u05e8" + "configure": "\u05d4\u05d2\u05d3\u05e8\u05d4", + "configured": "\u05de\u05d5\u05d2\u05d3\u05e8" } }, - "title": "\u05e7\u05d5\u05e0\u05e4\u05d9\u05d2\u05d5\u05e8\u05d8\u05d5\u05e8" + "title": "\u05e7\u05d5\u05d1\u05e2 \u05ea\u05e6\u05d5\u05e8\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/he.json b/homeassistant/components/conversation/translations/he.json index eeccec319af..63cfb10abe8 100644 --- a/homeassistant/components/conversation/translations/he.json +++ b/homeassistant/components/conversation/translations/he.json @@ -1,3 +1,3 @@ { - "title": "\u05e9\u05c2\u05b4\u05d9\u05d7\u05b8\u05d4" + "title": "\u05e9\u05d9\u05d7\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/fr.json b/homeassistant/components/coronavirus/translations/fr.json index 21a72d80f61..9a9a960cf31 100644 --- a/homeassistant/components/coronavirus/translations/fr.json +++ b/homeassistant/components/coronavirus/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Ce pays est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Ce pays est d\u00e9j\u00e0 configur\u00e9.", + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/cover/translations/he.json b/homeassistant/components/cover/translations/he.json index fce73cc1698..5dad593467c 100644 --- a/homeassistant/components/cover/translations/he.json +++ b/homeassistant/components/cover/translations/he.json @@ -6,11 +6,11 @@ }, "state": { "_": { - "closed": "\u05e0\u05e1\u05d2\u05e8", + "closed": "\u05e1\u05d2\u05d5\u05e8", "closing": "\u05e1\u05d5\u05d2\u05e8", "open": "\u05e4\u05ea\u05d5\u05d7", "opening": "\u05e4\u05d5\u05ea\u05d7", - "stopped": "\u05e2\u05e6\u05d5\u05e8" + "stopped": "\u05e2\u05e6\u05e8" } }, "title": "\u05d5\u05d9\u05dc\u05d5\u05df" diff --git a/homeassistant/components/demo/translations/select.fr.json b/homeassistant/components/demo/translations/select.fr.json new file mode 100644 index 00000000000..d2b214e4078 --- /dev/null +++ b/homeassistant/components/demo/translations/select.fr.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Vitesse de la lumi\u00e8re", + "ludicrous_speed": "Vitesse ridicule", + "ridiculous_speed": "Vitesse ridicule" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/he.json b/homeassistant/components/device_tracker/translations/he.json index 5db22ed4071..2f3ccc1ec1e 100644 --- a/homeassistant/components/device_tracker/translations/he.json +++ b/homeassistant/components/device_tracker/translations/he.json @@ -5,5 +5,5 @@ "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" } }, - "title": "\u05de\u05e2\u05e7\u05d1 \u05de\u05db\u05e9\u05d9\u05e8" + "title": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd" } \ No newline at end of file diff --git a/homeassistant/components/devolo_home_control/translations/cs.json b/homeassistant/components/devolo_home_control/translations/cs.json index 44906c51207..54169346968 100644 --- a/homeassistant/components/devolo_home_control/translations/cs.json +++ b/homeassistant/components/devolo_home_control/translations/cs.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u00da\u010det je ji\u017e nastaven" + "already_configured": "\u00da\u010det je ji\u017e nastaven", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" diff --git a/homeassistant/components/devolo_home_control/translations/fr.json b/homeassistant/components/devolo_home_control/translations/fr.json index d0f806c042e..bc9a5715238 100644 --- a/homeassistant/components/devolo_home_control/translations/fr.json +++ b/homeassistant/components/devolo_home_control/translations/fr.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "Cette unit\u00e9 centrale Home Control est d\u00e9j\u00e0 utilis\u00e9e." + "already_configured": "Cette unit\u00e9 centrale Home Control est d\u00e9j\u00e0 utilis\u00e9e.", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { - "invalid_auth": "Authentification invalide" + "invalid_auth": "Authentification invalide", + "reauth_failed": "Veuillez utiliser le m\u00eame utilisateur mydevolo que pr\u00e9c\u00e9demment." }, "step": { "user": { diff --git a/homeassistant/components/devolo_home_control/translations/he.json b/homeassistant/components/devolo_home_control/translations/he.json index f9ab82881a1..2ac09df14fd 100644 --- a/homeassistant/components/devolo_home_control/translations/he.json +++ b/homeassistant/components/devolo_home_control/translations/he.json @@ -11,7 +11,7 @@ "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "username": "\u05d3\u05d5\u05d0\u05e8 \u05d0\u05dc\u05e7\u05d8\u05e8\u05d5\u05e0\u05d9/ \u05de\u05d6\u05d4\u05d4 devolo" + "username": "\u05d3\u05d5\u05d0\"\u05dc / \u05de\u05d6\u05d4\u05d4 devolo" } }, "zeroconf_confirm": { diff --git a/homeassistant/components/directv/translations/he.json b/homeassistant/components/directv/translations/he.json index f057c4e4629..bc28ff4eba5 100644 --- a/homeassistant/components/directv/translations/he.json +++ b/homeassistant/components/directv/translations/he.json @@ -9,6 +9,14 @@ }, "flow_title": "{name}", "step": { + "ssdp_confirm": { + "data": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/dsmr/translations/fr.json b/homeassistant/components/dsmr/translations/fr.json index 3f462c74c05..0f348acdbff 100644 --- a/homeassistant/components/dsmr/translations/fr.json +++ b/homeassistant/components/dsmr/translations/fr.json @@ -1,23 +1,46 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_communicate": "\u00c9chec de la communication", + "cannot_connect": "\u00c9chec de connexion" }, "error": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_communicate": "\u00c9chec de la communication", + "cannot_connect": "\u00c9chec de connexion", "one": "Vide", "other": "Vide" }, "step": { "one": "", "other": "Autre", + "setup_network": { + "data": { + "dsmr_version": "S\u00e9lectionner la version DSMR", + "host": "H\u00f4te", + "port": "Port" + }, + "title": "S\u00e9lectionner l'adresse de connexion" + }, "setup_serial": { "data": { + "dsmr_version": "S\u00e9lectionner la version DSMR", "port": "S\u00e9lectionner un appareil" }, "title": "Appareil" }, "setup_serial_manual_path": { + "data": { + "port": "Chemin du p\u00e9riph\u00e9rique USB" + }, "title": "Chemin" + }, + "user": { + "data": { + "type": "Type de connexion" + }, + "title": "S\u00e9lectionner le type de connexion" } } }, diff --git a/homeassistant/components/emonitor/translations/he.json b/homeassistant/components/emonitor/translations/he.json index 4ec15aa12cb..77bd85b18b8 100644 --- a/homeassistant/components/emonitor/translations/he.json +++ b/homeassistant/components/emonitor/translations/he.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} ({host})?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name} ({host})?" }, "user": { "data": { diff --git a/homeassistant/components/energy/translations/cs.json b/homeassistant/components/energy/translations/cs.json new file mode 100644 index 00000000000..53457a69447 --- /dev/null +++ b/homeassistant/components/energy/translations/cs.json @@ -0,0 +1,3 @@ +{ + "title": "Energie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/de.json b/homeassistant/components/energy/translations/de.json new file mode 100644 index 00000000000..53457a69447 --- /dev/null +++ b/homeassistant/components/energy/translations/de.json @@ -0,0 +1,3 @@ +{ + "title": "Energie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/fr.json b/homeassistant/components/energy/translations/fr.json new file mode 100644 index 00000000000..f947a07baec --- /dev/null +++ b/homeassistant/components/energy/translations/fr.json @@ -0,0 +1,3 @@ +{ + "title": "\u00c9nergie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/he.json b/homeassistant/components/energy/translations/he.json new file mode 100644 index 00000000000..3c61aad6089 --- /dev/null +++ b/homeassistant/components/energy/translations/he.json @@ -0,0 +1,3 @@ +{ + "title": "\u05d0\u05e0\u05e8\u05d2\u05d9\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/it.json b/homeassistant/components/energy/translations/it.json new file mode 100644 index 00000000000..c8d85790fdd --- /dev/null +++ b/homeassistant/components/energy/translations/it.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/nl.json b/homeassistant/components/energy/translations/nl.json new file mode 100644 index 00000000000..53457a69447 --- /dev/null +++ b/homeassistant/components/energy/translations/nl.json @@ -0,0 +1,3 @@ +{ + "title": "Energie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/pl.json b/homeassistant/components/energy/translations/pl.json new file mode 100644 index 00000000000..c8d85790fdd --- /dev/null +++ b/homeassistant/components/energy/translations/pl.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/ru.json b/homeassistant/components/energy/translations/ru.json new file mode 100644 index 00000000000..b351e407168 --- /dev/null +++ b/homeassistant/components/energy/translations/ru.json @@ -0,0 +1,3 @@ +{ + "title": "\u042d\u043d\u0435\u0440\u0433\u0438\u044f" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/zh-Hant.json b/homeassistant/components/energy/translations/zh-Hant.json new file mode 100644 index 00000000000..bae50fae66e --- /dev/null +++ b/homeassistant/components/energy/translations/zh-Hant.json @@ -0,0 +1,3 @@ +{ + "title": "\u80fd\u6e90" +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/fr.json b/homeassistant/components/enphase_envoy/translations/fr.json index be1d5f3bca3..9587739e88a 100644 --- a/homeassistant/components/enphase_envoy/translations/fr.json +++ b/homeassistant/components/enphase_envoy/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/flipr/translations/cs.json b/homeassistant/components/flipr/translations/cs.json new file mode 100644 index 00000000000..29c2ebc1713 --- /dev/null +++ b/homeassistant/components/flipr/translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/fr.json b/homeassistant/components/flipr/translations/fr.json index 32769aab9b7..ec9260aa8a7 100644 --- a/homeassistant/components/flipr/translations/fr.json +++ b/homeassistant/components/flipr/translations/fr.json @@ -4,6 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "no_flipr_id_found": "Aucun identifiant Flipr n'est associ\u00e9 \u00e0 votre compte pour le moment. Vous devez d'abord v\u00e9rifier qu'il fonctionne avec l'application mobile de Flipr.", "unknown": "Erreur inattendue" diff --git a/homeassistant/components/flipr/translations/he.json b/homeassistant/components/flipr/translations/he.json index 85872f14f2c..ecb8a74bc6f 100644 --- a/homeassistant/components/flipr/translations/he.json +++ b/homeassistant/components/flipr/translations/he.json @@ -1,6 +1,11 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { diff --git a/homeassistant/components/flipr/translations/it.json b/homeassistant/components/flipr/translations/it.json new file mode 100644 index 00000000000..399d4c6b9d3 --- /dev/null +++ b/homeassistant/components/flipr/translations/it.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "no_flipr_id_found": "Nessun ID flipr associato al tuo account per ora. Dovresti prima verificare che funzioni con l'app mobile di Flipr.", + "unknown": "Errore imprevisto" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "ID Flipr" + }, + "description": "Scegli il tuo ID Flipr nell'elenco", + "title": "Scegli il tuo Flipr" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Password" + }, + "description": "Connettiti usando il tuo account Flipr.", + "title": "Connettiti a Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/pl.json b/homeassistant/components/flipr/translations/pl.json index 1095c33a83e..436061f7f61 100644 --- a/homeassistant/components/flipr/translations/pl.json +++ b/homeassistant/components/flipr/translations/pl.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "invalid_auth": "Niepoprawne uwierzytelnienie", - "no_flipr_id_found": "Brak identyfikatora flipr powi\u0105zanego z Twoim kontem. Sprawd\u017a najpierw, czy dzia\u0142a z aplikacj\u0105 mobiln\u0105 Flipr.", + "no_flipr_id_found": "Brak identyfikatora Flipr powi\u0105zanego z Twoim kontem. Sprawd\u017a najpierw, czy dzia\u0142a z aplikacj\u0105 mobiln\u0105 Flipr.", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { @@ -22,7 +22,7 @@ "email": "Adres e-mail", "password": "Has\u0142o" }, - "description": "Po\u0142\u0105cz u\u017cywaj\u0105c swojego konta Flipr.", + "description": "Po\u0142\u0105cz, u\u017cywaj\u0105c swojego konta Flipr.", "title": "Po\u0142\u0105czenie z Flipr" } } diff --git a/homeassistant/components/flume/translations/fr.json b/homeassistant/components/flume/translations/fr.json index a111d66b937..5fe7fcf2ca4 100644 --- a/homeassistant/components/flume/translations/fr.json +++ b/homeassistant/components/flume/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9.", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", diff --git a/homeassistant/components/forecast_solar/translations/fr.json b/homeassistant/components/forecast_solar/translations/fr.json index a6091b9c21b..efd9f7be3a6 100644 --- a/homeassistant/components/forecast_solar/translations/fr.json +++ b/homeassistant/components/forecast_solar/translations/fr.json @@ -3,8 +3,28 @@ "step": { "user": { "data": { + "azimuth": "Azimut (360 degr\u00e9s, 0 = Nord, 90 = Est, 180 = Sud, 270 = Ouest)", + "declination": "D\u00e9clinaison (0 = horizontale, 90 = verticale)", + "latitude": "Latitude", + "longitude": "Longitude", + "modules power": "Puissance de cr\u00eate totale en watts de vos modules solaires", "name": "Nom" - } + }, + "description": "Remplissez les donn\u00e9es de vos panneaux solaires. Veuillez vous r\u00e9f\u00e9rer \u00e0 la documentation si un champ n'est pas clair." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Cl\u00e9 API Forecast.Solar (facultatif)", + "azimuth": "Azimut (360 degr\u00e9s, 0 = Nord, 90 = Est, 180 = Sud, 270 = Ouest)", + "damping": "Facteur d'amortissement : ajuste les r\u00e9sultats matin et soir", + "declination": "D\u00e9clinaison (0 = horizontale, 90 = verticale)", + "modules power": "Puissance de cr\u00eate totale en watts de vos modules solaires" + }, + "description": "Ces valeurs permettent de peaufiner le r\u00e9sultat Solar.Forecast. Veuillez vous r\u00e9f\u00e9rer \u00e0 la documentation si un champ n'est pas clair." } } } diff --git a/homeassistant/components/freedompro/translations/fr.json b/homeassistant/components/freedompro/translations/fr.json index 4822faaef86..6667226a206 100644 --- a/homeassistant/components/freedompro/translations/fr.json +++ b/homeassistant/components/freedompro/translations/fr.json @@ -1,7 +1,18 @@ { "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" + }, "step": { "user": { + "data": { + "api_key": "cl\u00e9 API" + }, + "description": "Veuillez saisir la cl\u00e9 API obtenue sur https://home.freedompro.eu", "title": "Cl\u00e9 API Freedompro" } } diff --git a/homeassistant/components/fritz/translations/fr.json b/homeassistant/components/fritz/translations/fr.json index e0fa5dd3e8c..6518b5ed20c 100644 --- a/homeassistant/components/fritz/translations/fr.json +++ b/homeassistant/components/fritz/translations/fr.json @@ -1,10 +1,14 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9 ", + "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours", + "cannot_connect": "\u00c9chec de connexion", "connection_error": "Erreur de connexion", "invalid_auth": "Authentification invalide" }, @@ -35,6 +39,25 @@ }, "description": "Configuration de FRITZ!Box Tools pour contr\u00f4ler votre FRITZ!Box.\nMinimum requis: nom d'utilisateur, mot de passe.", "title": "Configuration FRITZ!Box Tools - obligatoire" + }, + "user": { + "data": { + "host": "H\u00f4te", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur" + }, + "description": "Configurer FRITZ!Box Tools pour contr\u00f4ler votre FRITZ!Box.\n Minimum requis : nom d'utilisateur, mot de passe.", + "title": "Configurer les outils de FRITZ!Box" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Secondes pour consid\u00e9rer un appareil \u00e0 la 'maison'" + } } } } diff --git a/homeassistant/components/garages_amsterdam/translations/fr.json b/homeassistant/components/garages_amsterdam/translations/fr.json new file mode 100644 index 00000000000..68530899d1e --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "garage_name": "Nom du garage" + }, + "title": "Choisisser un garage \u00e0 surveiller" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/goalzero/translations/fr.json b/homeassistant/components/goalzero/translations/fr.json index 7bd4929ad92..bb6a777b6be 100644 --- a/homeassistant/components/goalzero/translations/fr.json +++ b/homeassistant/components/goalzero/translations/fr.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", + "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -9,6 +11,10 @@ "unknown": "Erreur inconnue" }, "step": { + "confirm_discovery": { + "description": "La r\u00e9servation DHCP sur votre routeur est recommand\u00e9e. S'il n'est pas configur\u00e9, l'appareil peut devenir indisponible jusqu'\u00e0 ce que Home Assistant d\u00e9tecte la nouvelle adresse IP. Reportez-vous au manuel d'utilisation de votre routeur.", + "title": "Objectif Z\u00e9ro Y\u00e9ti" + }, "user": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/gogogate2/translations/fr.json b/homeassistant/components/gogogate2/translations/fr.json index 79f216738c4..94cee628a79 100644 --- a/homeassistant/components/gogogate2/translations/fr.json +++ b/homeassistant/components/gogogate2/translations/fr.json @@ -7,6 +7,7 @@ "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { diff --git a/homeassistant/components/group/translations/he.json b/homeassistant/components/group/translations/he.json index f0aa3b0c2d8..0ca969e6812 100644 --- a/homeassistant/components/group/translations/he.json +++ b/homeassistant/components/group/translations/he.json @@ -1,13 +1,13 @@ { "state": { "_": { - "closed": "\u05e0\u05e1\u05d2\u05e8", + "closed": "\u05e1\u05d2\u05d5\u05e8", "home": "\u05d1\u05d1\u05d9\u05ea", "locked": "\u05e0\u05e2\u05d5\u05dc", "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", "off": "\u05db\u05d1\u05d5\u05d9", "ok": "\u05ea\u05e7\u05d9\u05df", - "on": "\u05d3\u05dc\u05d5\u05e7", + "on": "\u05de\u05d5\u05e4\u05e2\u05dc", "open": "\u05e4\u05ea\u05d5\u05d7", "problem": "\u05d1\u05e2\u05d9\u05d4", "unlocked": "\u05e4\u05ea\u05d5\u05d7" diff --git a/homeassistant/components/growatt_server/translations/cs.json b/homeassistant/components/growatt_server/translations/cs.json new file mode 100644 index 00000000000..02c83a6e916 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/cs.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/fr.json b/homeassistant/components/growatt_server/translations/fr.json new file mode 100644 index 00000000000..1ad47166f8d --- /dev/null +++ b/homeassistant/components/growatt_server/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Aucune plante n'a \u00e9t\u00e9 trouv\u00e9e sur ce compte" + }, + "error": { + "invalid_auth": "Authentification incorrecte" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plante" + }, + "title": "S\u00e9lectionner votre plante" + }, + "user": { + "data": { + "name": "Nom", + "password": "Mot de passe", + "url": "URL", + "username": "Nom d'utilisateur" + }, + "title": "Entrer vos informations Growatt" + } + } + }, + "title": "Serveur Growatt" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/he.json b/homeassistant/components/growatt_server/translations/he.json index 14b47532075..8d430b5f4b2 100644 --- a/homeassistant/components/growatt_server/translations/he.json +++ b/homeassistant/components/growatt_server/translations/he.json @@ -4,6 +4,11 @@ "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" }, "step": { + "plant": { + "data": { + "plant_id": "\u05e6\u05de\u05d7" + } + }, "user": { "data": { "name": "\u05e9\u05dd", diff --git a/homeassistant/components/growatt_server/translations/it.json b/homeassistant/components/growatt_server/translations/it.json index 19862f82d83..a3160c4164b 100644 --- a/homeassistant/components/growatt_server/translations/it.json +++ b/homeassistant/components/growatt_server/translations/it.json @@ -17,6 +17,7 @@ "data": { "name": "Nome", "password": "Password", + "url": "URL", "username": "Utente" }, "title": "Inserisci le tue informazioni Growatt" diff --git a/homeassistant/components/guardian/translations/fr.json b/homeassistant/components/guardian/translations/fr.json index ca5635a17b7..62ffae35776 100644 --- a/homeassistant/components/guardian/translations/fr.json +++ b/homeassistant/components/guardian/translations/fr.json @@ -6,6 +6,9 @@ "cannot_connect": "\u00c9chec de connexion" }, "step": { + "discovery_confirm": { + "description": "Voulez-vous configurer cet appareil Guardian\u00a0?" + }, "user": { "data": { "ip_address": "Adresse IP", diff --git a/homeassistant/components/harmony/translations/he.json b/homeassistant/components/harmony/translations/he.json index 1331c17e961..49470b50ca9 100644 --- a/homeassistant/components/harmony/translations/he.json +++ b/homeassistant/components/harmony/translations/he.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "link": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} ({host})?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name} ({host})?" }, "user": { "data": { diff --git a/homeassistant/components/home_plus_control/translations/de.json b/homeassistant/components/home_plus_control/translations/de.json index 8e7d9e9bc24..8cb47ae3fec 100644 --- a/homeassistant/components/home_plus_control/translations/de.json +++ b/homeassistant/components/home_plus_control/translations/de.json @@ -17,5 +17,5 @@ } } }, - "title": "" + "title": "Legrand Home+ Steuerung" } \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/it.json b/homeassistant/components/homekit/translations/it.json index 0fb983f1a20..c9afa13fb85 100644 --- a/homeassistant/components/homekit/translations/it.json +++ b/homeassistant/components/homekit/translations/it.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domini da includere" }, - "description": "Scegli i domini da includere. Verranno incluse tutte le entit\u00e0 supportate nel dominio. Verr\u00e0 creata un'istanza HomeKit separata in modalit\u00e0 accessorio per ogni lettore multimediale TV e telecamera.", + "description": "Scegli i domini da includere. Tutte le entit\u00e0 supportate nel dominio saranno incluse. Verr\u00e0 creata un'istanza HomeKit separata in modalit\u00e0 accessoria per ogni lettore multimediale TV, telecomando basato sulle attivit\u00e0, serratura e telecamera.", "title": "Seleziona i domini da includere" } } diff --git a/homeassistant/components/homekit_controller/translations/fr.json b/homeassistant/components/homekit_controller/translations/fr.json index 5ae7a0faafd..faf970f88eb 100644 --- a/homeassistant/components/homekit_controller/translations/fr.json +++ b/homeassistant/components/homekit_controller/translations/fr.json @@ -12,6 +12,7 @@ }, "error": { "authentication_error": "Code HomeKit incorrect. S'il vous pla\u00eet v\u00e9rifier et essayez \u00e0 nouveau.", + "insecure_setup_code": "Le code de configuration demand\u00e9 n'est pas s\u00e9curis\u00e9 en raison de sa nature triviale. Cet accessoire ne r\u00e9pond pas aux exigences de s\u00e9curit\u00e9 de base.", "max_peers_error": "L'appareil a refus\u00e9 d'ajouter le couplage car il ne dispose pas de stockage de couplage libre.", "pairing_failed": "Une erreur non g\u00e9r\u00e9e s'est produite lors de la tentative d'appairage avec cet appareil. Il se peut qu'il s'agisse d'une panne temporaire ou que votre appareil ne soit pas pris en charge actuellement.", "unable_to_pair": "Impossible d'appairer, veuillez r\u00e9essayer.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Autoriser le jumelage avec des codes de configuration non s\u00e9curis\u00e9s.", "pairing_code": "Code d\u2019appairage" }, "description": "Le contr\u00f4leur HomeKit communique avec {name} sur le r\u00e9seau local en utilisant une connexion crypt\u00e9e s\u00e9curis\u00e9e sans contr\u00f4leur HomeKit s\u00e9par\u00e9 ou iCloud. Entrez votre code d'appariement HomeKit (au format XXX-XX-XXX) pour utiliser cet accessoire. Ce code se trouve g\u00e9n\u00e9ralement sur l'appareil lui-m\u00eame ou dans l'emballage.", diff --git a/homeassistant/components/honeywell/translations/cs.json b/homeassistant/components/honeywell/translations/cs.json new file mode 100644 index 00000000000..25ad431df4e --- /dev/null +++ b/homeassistant/components/honeywell/translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/fr.json b/homeassistant/components/honeywell/translations/fr.json index 506e14ab26f..b9b625eb589 100644 --- a/homeassistant/components/honeywell/translations/fr.json +++ b/homeassistant/components/honeywell/translations/fr.json @@ -1,10 +1,16 @@ { "config": { + "error": { + "invalid_auth": "Authentification incorrecte" + }, "step": { "user": { "data": { - "password": "Mot de passe" - } + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Veuillez saisir les informations d'identification utilis\u00e9es pour vous connecter \u00e0 mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (\u00c9tats-Unis)" } } } diff --git a/homeassistant/components/honeywell/translations/it.json b/homeassistant/components/honeywell/translations/it.json new file mode 100644 index 00000000000..52c828ddcde --- /dev/null +++ b/homeassistant/components/honeywell/translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Autenticazione non valida" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci le credenziali utilizzate per accedere a mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/fr.json b/homeassistant/components/huawei_lte/translations/fr.json index df7e6c2e380..da8fbcbd115 100644 --- a/homeassistant/components/huawei_lte/translations/fr.json +++ b/homeassistant/components/huawei_lte/translations/fr.json @@ -35,7 +35,8 @@ "name": "Nom du service de notification (red\u00e9marrage requis)", "recipient": "Destinataires des notifications SMS", "track_new_devices": "Suivre les nouveaux appareils", - "track_wired_clients": "Suivre les clients du r\u00e9seau filaire" + "track_wired_clients": "Suivre les clients du r\u00e9seau filaire", + "unauthenticated_mode": "Mode non authentifi\u00e9 (le changement n\u00e9cessite un rechargement)" } } } diff --git a/homeassistant/components/hue/translations/fr.json b/homeassistant/components/hue/translations/fr.json index f19c5ec7a34..e9dd546840f 100644 --- a/homeassistant/components/hue/translations/fr.json +++ b/homeassistant/components/hue/translations/fr.json @@ -11,13 +11,13 @@ "unknown": "Une erreur inconnue s'est produite" }, "error": { - "linking": "Une erreur inconnue s'est produite lors de la liaison entre le pont et Home Assistant", + "linking": "Erreur inattendue", "register_failed": "\u00c9chec d'enregistrement. Veuillez r\u00e9essayer." }, "step": { "init": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "title": "Choisissez le pont Philips Hue" }, diff --git a/homeassistant/components/hunterdouglas_powerview/translations/fr.json b/homeassistant/components/hunterdouglas_powerview/translations/fr.json index a1bd06078c6..68ea30b293f 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/fr.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/fr.json @@ -7,6 +7,7 @@ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", "unknown": "Erreur inattendue" }, + "flow_title": "{name} ({host})", "step": { "link": { "description": "Voulez-vous configurer {name} ({host})?", diff --git a/homeassistant/components/hunterdouglas_powerview/translations/he.json b/homeassistant/components/hunterdouglas_powerview/translations/he.json index c6610f79e77..8bd6c87154c 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/he.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/he.json @@ -10,7 +10,7 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} ({host})?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name} ({host})?" }, "user": { "data": { diff --git a/homeassistant/components/input_boolean/translations/he.json b/homeassistant/components/input_boolean/translations/he.json index 08bdc30a602..b5d50c10627 100644 --- a/homeassistant/components/input_boolean/translations/he.json +++ b/homeassistant/components/input_boolean/translations/he.json @@ -2,7 +2,7 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05e7\u05dc\u05d8 \u05d1\u05d5\u05dc\u05d9\u05d0\u05e0\u05d9" diff --git a/homeassistant/components/isy994/translations/fr.json b/homeassistant/components/isy994/translations/fr.json index 01f2145cd12..0bd04cd14b1 100644 --- a/homeassistant/components/isy994/translations/fr.json +++ b/homeassistant/components/isy994/translations/fr.json @@ -36,5 +36,13 @@ "title": "Options ISY994" } } + }, + "system_health": { + "info": { + "device_connected": "ISY connect\u00e9", + "host_reachable": "H\u00f4te joignable", + "last_heartbeat": "Heure du dernier pulsation", + "websocket_status": "\u00c9tat du socket d'\u00e9v\u00e9nement" + } } } \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/fr.json b/homeassistant/components/keenetic_ndms2/translations/fr.json index bf3ebbf5b22..74685da509b 100644 --- a/homeassistant/components/keenetic_ndms2/translations/fr.json +++ b/homeassistant/components/keenetic_ndms2/translations/fr.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "no_udn": "Les informations de d\u00e9couverte SSDP n'ont pas d'UDN", + "not_keenetic_ndms2": "L'\u00e9l\u00e9ment d\u00e9couvert n'est pas un routeur Keenetic" }, "error": { "cannot_connect": "\u00c9chec de connexion" }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/kodi/translations/he.json b/homeassistant/components/kodi/translations/he.json index 04e02ee88a0..5b992705068 100644 --- a/homeassistant/components/kodi/translations/he.json +++ b/homeassistant/components/kodi/translations/he.json @@ -22,7 +22,7 @@ "description": "\u05e0\u05d0 \u05d4\u05d6\u05df \u05d0\u05ea \u05e9\u05dd \u05d4\u05de\u05e9\u05ea\u05de\u05e9 \u05d5\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05d1-Kodi. \u05e0\u05d9\u05ea\u05df \u05dc\u05de\u05e6\u05d5\u05d0 \u05d0\u05d5\u05ea\u05dd \u05d1\u05de\u05e2\u05e8\u05db\u05ea/\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea/\u05e8\u05e9\u05ea/\u05e9\u05d9\u05e8\u05d5\u05ea\u05d9\u05dd." }, "discovery_confirm": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea \u05e7\u05d5\u05d3\u05d9 ((`{name}`) \u05dc-Home Assistant?", + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea \u05e7\u05d5\u05d3\u05d9 (`{name}`) \u05dc-Home Assistant?", "title": "\u05d2\u05d9\u05dc\u05d4 \u05d0\u05ea \u05e7\u05d5\u05d3\u05d9" }, "user": { diff --git a/homeassistant/components/konnected/translations/he.json b/homeassistant/components/konnected/translations/he.json index 9e791916df7..0a436bc2d3c 100644 --- a/homeassistant/components/konnected/translations/he.json +++ b/homeassistant/components/konnected/translations/he.json @@ -22,14 +22,30 @@ } }, "options": { + "error": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + }, "step": { "options_binary": { "data": { "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" } }, + "options_digital": { + "data": { + "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + } + }, "options_io": { "description": "\u05d4\u05ea\u05d2\u05dc\u05d4 {model} \u05d1-{host} . \u05d1\u05d7\u05e8 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d1\u05e1\u05d9\u05e1 \u05e9\u05dc \u05db\u05dc \u05e7\u05dc\u05d8/\u05e4\u05dc\u05d8 \u05dc\u05de\u05d8\u05d4 - \u05d1\u05d4\u05ea\u05d0\u05dd \u05dc\u05e7\u05dc\u05d8/\u05e4\u05dc\u05d8 \u05d6\u05d4 \u05e2\u05e9\u05d5\u05d9 \u05dc\u05d0\u05e4\u05e9\u05e8 \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d1\u05d9\u05e0\u05d0\u05e8\u05d9\u05d9\u05dd (\u05de\u05d2\u05e2\u05d9\u05dd \u05e4\u05ea\u05d5\u05d7\u05d9\u05dd/\u05e1\u05d2\u05d5\u05e8\u05d9\u05dd), \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d3\u05d9\u05d2\u05d9\u05d8\u05dc\u05d9\u05d9\u05dd (dht \u05d5-ds18b20), \u05d0\u05d5 \u05d9\u05e6\u05d9\u05d0\u05d5\u05ea \u05e0\u05d9\u05ea\u05e0\u05d5\u05ea \u05dc\u05d4\u05d7\u05dc\u05e4\u05d4. \u05ea\u05d5\u05db\u05dc \u05dc\u05e7\u05d1\u05d5\u05e2 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05de\u05e4\u05d5\u05e8\u05d8\u05d5\u05ea \u05d1\u05e9\u05dc\u05d1\u05d9\u05dd \u05d4\u05d1\u05d0\u05d9\u05dd." + }, + "options_switch": { + "data": { + "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + } } } } diff --git a/homeassistant/components/kraken/translations/fr.json b/homeassistant/components/kraken/translations/fr.json new file mode 100644 index 00000000000..1aa7fdfbf54 --- /dev/null +++ b/homeassistant/components/kraken/translations/fr.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." + }, + "error": { + "one": "UN", + "other": "AUTRE" + }, + "step": { + "user": { + "data": { + "one": "UN", + "other": "AUTRE" + }, + "description": "Voulez-vous commencer la configuration\u00a0?" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Intervalle de mise \u00e0 jour", + "tracked_asset_pairs": "Paires d'actifs suivis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/he.json b/homeassistant/components/kraken/translations/he.json index 4676729e600..2be0837c966 100644 --- a/homeassistant/components/kraken/translations/he.json +++ b/homeassistant/components/kraken/translations/he.json @@ -3,10 +3,31 @@ "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, + "error": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + }, "step": { "user": { + "data": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + }, "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u05de\u05e8\u05d5\u05d5\u05d7 \u05d6\u05de\u05df \u05dc\u05e2\u05d3\u05db\u05d5\u05df" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/fr.json b/homeassistant/components/litejet/translations/fr.json index 89459d1829f..b8ca0adb3d4 100644 --- a/homeassistant/components/litejet/translations/fr.json +++ b/homeassistant/components/litejet/translations/fr.json @@ -15,5 +15,15 @@ "title": "Connectez-vous \u00e0 LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Transition par d\u00e9faut (secondes)" + }, + "title": "Configurer LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/it.json b/homeassistant/components/litejet/translations/it.json index 5b3dc46753d..0b34e6fb365 100644 --- a/homeassistant/components/litejet/translations/it.json +++ b/homeassistant/components/litejet/translations/it.json @@ -15,5 +15,15 @@ "title": "Connetti a LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Transizione predefinita (secondi)" + }, + "title": "Configura LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/lyric/translations/fr.json b/homeassistant/components/lyric/translations/fr.json index db23120b40d..9eb02fc3811 100644 --- a/homeassistant/components/lyric/translations/fr.json +++ b/homeassistant/components/lyric/translations/fr.json @@ -2,7 +2,8 @@ "config": { "abort": { "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", - "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation." + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "create_entry": { "default": "Authentification r\u00e9ussie" @@ -12,7 +13,8 @@ "title": "S\u00e9lectionner une m\u00e9thode d'authentification" }, "reauth_confirm": { - "description": "L'int\u00e9gration Lyric doit authentifier \u00e0 nouveau votre compte." + "description": "L'int\u00e9gration Lyric doit authentifier \u00e0 nouveau votre compte.", + "title": "R\u00e9authentification de l'int\u00e9gration" } } } diff --git a/homeassistant/components/meteoclimatic/translations/fr.json b/homeassistant/components/meteoclimatic/translations/fr.json new file mode 100644 index 00000000000..ae087db2e6e --- /dev/null +++ b/homeassistant/components/meteoclimatic/translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "unknown": "Erreur inattendue" + }, + "error": { + "not_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau" + }, + "step": { + "user": { + "data": { + "code": "Code de la station" + }, + "description": "Entrer le code de la station m\u00e9t\u00e9orologique (par exemple, ESCAT4300000043206)", + "title": "M\u00e9t\u00e9oclimatique" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/translations/de.json b/homeassistant/components/mikrotik/translations/de.json index 1c11717c1b2..1a9c3b5d352 100644 --- a/homeassistant/components/mikrotik/translations/de.json +++ b/homeassistant/components/mikrotik/translations/de.json @@ -16,9 +16,9 @@ "password": "Passwort", "port": "Port", "username": "Benutzername", - "verify_ssl": "Verwenden Sie SSL" + "verify_ssl": "SSL verwenden" }, - "title": "Richten Sie den Mikrotik Router ein" + "title": "Mikrotik Router einrichten" } } }, @@ -28,7 +28,7 @@ "data": { "arp_ping": "ARP-Ping aktivieren", "detection_time": "Heimintervall ber\u00fccksichtigen", - "force_dhcp": "Erzwingen Sie das Scannen \u00fcber DHCP" + "force_dhcp": "Scannen mit DHCP erzwingen" } } } diff --git a/homeassistant/components/modern_forms/translations/fr.json b/homeassistant/components/modern_forms/translations/fr.json new file mode 100644 index 00000000000..d68f4a7f680 --- /dev/null +++ b/homeassistant/components/modern_forms/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Voulez-vous commencer la configuration\u00a0?" + }, + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Configurer votre ventilateur Modern Forms pour l'int\u00e9grer \u00e0 Home Assistant." + }, + "zeroconf_confirm": { + "description": "Voulez-vous ajouter le fan de Modern Forms nomm\u00e9 ` {name} ` \u00e0 Home Assistant\u00a0?", + "title": "D\u00e9couverte du dispositif de ventilateur Modern Forms" + } + } + }, + "title": "Formes modernes" +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/fr.json b/homeassistant/components/motioneye/translations/fr.json index 338bc392bd5..b8d79b683a6 100644 --- a/homeassistant/components/motioneye/translations/fr.json +++ b/homeassistant/components/motioneye/translations/fr.json @@ -1,18 +1,27 @@ { "config": { "abort": { - "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { - "invalid_url": "URL invalide" + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "invalid_url": "URL invalide", + "unknown": "Erreur inattendue" }, "step": { + "hassio_confirm": { + "description": "Voulez-vous configurer Home Assistant pour vous connecter au service motionEye fourni par le module compl\u00e9mentaire\u00a0: {addon}\u00a0?", + "title": "motionEye via le module compl\u00e9mentaire Home Assistant" + }, "user": { "data": { "admin_password": "Admin Mot de passe", "admin_username": "Admin Nom d'utilisateur", "surveillance_password": "Surveillance Mot de passe", - "surveillance_username": "Surveillance Nom d'utilisateur" + "surveillance_username": "Surveillance Nom d'utilisateur", + "url": "URL" } } } diff --git a/homeassistant/components/myq/translations/fr.json b/homeassistant/components/myq/translations/fr.json index e9a6bc60b82..c07e3710645 100644 --- a/homeassistant/components/myq/translations/fr.json +++ b/homeassistant/components/myq/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "MyQ est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "MyQ est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", diff --git a/homeassistant/components/mysensors/translations/de.json b/homeassistant/components/mysensors/translations/de.json index bb6a1ed7bfe..cfef6f7d363 100644 --- a/homeassistant/components/mysensors/translations/de.json +++ b/homeassistant/components/mysensors/translations/de.json @@ -76,5 +76,5 @@ } } }, - "title": "" + "title": "MySensors" } \ No newline at end of file diff --git a/homeassistant/components/nam/translations/fr.json b/homeassistant/components/nam/translations/fr.json index 0c58af2a800..1800e6da508 100644 --- a/homeassistant/components/nam/translations/fr.json +++ b/homeassistant/components/nam/translations/fr.json @@ -4,6 +4,10 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "device_unsupported": "L'appareil n'est pas pris en charge." }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, "flow_title": "{nom}", "step": { "confirm_discovery": { diff --git a/homeassistant/components/nest/translations/he.json b/homeassistant/components/nest/translations/he.json index dab438c24a8..a3b5411b536 100644 --- a/homeassistant/components/nest/translations/he.json +++ b/homeassistant/components/nest/translations/he.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "authorize_url_timeout": "\u05e2\u05d1\u05e8 \u05d4\u05d6\u05de\u05df \u05d4\u05e7\u05e6\u05d5\u05d1 \u05e2\u05d1\u05d5\u05e8 \u05d9\u05e6\u05d9\u05e8\u05ea \u05e7\u05d9\u05e9\u05d5\u05e8 \u05d0\u05d9\u05de\u05d5\u05ea", + "authorize_url_timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05dc\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8.", "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3.", "no_url_available": "\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05d6\u05de\u05d9\u05e0\u05d4. \u05e7\u05d1\u05dc\u05ea \u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05e9\u05d2\u05d9\u05d0\u05d4 \u05d6\u05d5, [\u05e2\u05d9\u05d9\u05df \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d4\u05e2\u05d6\u05e8\u05d4] ({docs_url})", "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", diff --git a/homeassistant/components/nexia/translations/fr.json b/homeassistant/components/nexia/translations/fr.json index 8082f912bed..5cec9b66836 100644 --- a/homeassistant/components/nexia/translations/fr.json +++ b/homeassistant/components/nexia/translations/fr.json @@ -11,6 +11,7 @@ "step": { "user": { "data": { + "brand": "Marque", "password": "Mot de passe", "username": "Nom d'utilisateur" }, diff --git a/homeassistant/components/nfandroidtv/translations/cs.json b/homeassistant/components/nfandroidtv/translations/cs.json new file mode 100644 index 00000000000..b268d8945a0 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "name": "Jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/fr.json b/homeassistant/components/nfandroidtv/translations/fr.json new file mode 100644 index 00000000000..6d00852889b --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom" + }, + "description": "Cette int\u00e9gration n\u00e9cessite l'application Notifications pour Android TV. \n\nPour Android TV\u00a0: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPour Fire TV\u00a0: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\nVous devez configurer soit une r\u00e9servation DHCP sur votre routeur (reportez-vous au manuel d'utilisation de votre routeur) soit une adresse IP statique sur l'appareil. Sinon, l'appareil finira par devenir indisponible.", + "title": "Notifications pour Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/it.json b/homeassistant/components/nfandroidtv/translations/it.json new file mode 100644 index 00000000000..3b8d089b5a5 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome" + }, + "description": "Questa integrazione richiede l'app Notifiche per Android TV. \n\nPer Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPer Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\n\u00c8 necessario impostare la prenotazione DHCP sul router (fare riferimento al manuale utente del router) o un indirizzo IP statico sul dispositivo. In caso contrario, il dispositivo alla fine non sar\u00e0 pi\u00f9 disponibile.", + "title": "Notifiche per Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/nl.json b/homeassistant/components/nfandroidtv/translations/nl.json index b231bd00c3c..acd936abe70 100644 --- a/homeassistant/components/nfandroidtv/translations/nl.json +++ b/homeassistant/components/nfandroidtv/translations/nl.json @@ -13,6 +13,7 @@ "host": "Host", "name": "Naam" }, + "description": "Voor deze integratie is de app Notifications for Android TV vereist.\n\nVoor Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nVoor Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nU moet een DHCP-reservering op uw router instellen (raadpleeg de gebruikershandleiding van uw router) of een statisch IP-adres op het apparaat instellen. Zo niet, dan zal het apparaat uiteindelijk onbeschikbaar worden.", "title": "Meldingen voor Android TV / Fire TV" } } diff --git a/homeassistant/components/nfandroidtv/translations/pl.json b/homeassistant/components/nfandroidtv/translations/pl.json index 597d8bb9200..4dd742b7c1f 100644 --- a/homeassistant/components/nfandroidtv/translations/pl.json +++ b/homeassistant/components/nfandroidtv/translations/pl.json @@ -13,7 +13,7 @@ "host": "Nazwa hosta lub adres IP", "name": "Nazwa" }, - "description": "Ta integracja wymaga aplikacji Powiadomienia dla Androida TV. \n\nAndroid TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\nNale\u017cy skonfigurowa\u0107 rezerwacj\u0119 DHCP na routerze (patrz instrukcja obs\u0142ugi routera) lub statyczny adres IP na urz\u0105dzeniu. Je\u015bli tego nie zrobisz, urz\u0105dzenie ostatecznie stanie si\u0119 niedost\u0119pne.", + "description": "Ta integracja wymaga aplikacji Powiadomienia dla Androida TV. \n\nAndroid TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\nNale\u017cy skonfigurowa\u0107 rezerwacj\u0119 DHCP na routerze (patrz instrukcja obs\u0142ugi routera) lub ustawi\u0107 statyczny adres IP na urz\u0105dzeniu. Je\u015bli tego nie zrobisz, urz\u0105dzenie ostatecznie stanie si\u0119 niedost\u0119pne.", "title": "Powiadomienia dla Android TV / Fire TV" } } diff --git a/homeassistant/components/nmap_tracker/translations/cs.json b/homeassistant/components/nmap_tracker/translations/cs.json new file mode 100644 index 00000000000..1a0d0ae0b53 --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/cs.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Um\u00edst\u011bn\u00ed je ji\u017e nastaveno" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/fr.json b/homeassistant/components/nmap_tracker/translations/fr.json index f4302a4173c..69d7d58f2e6 100644 --- a/homeassistant/components/nmap_tracker/translations/fr.json +++ b/homeassistant/components/nmap_tracker/translations/fr.json @@ -1,12 +1,40 @@ { + "config": { + "abort": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "invalid_hosts": "H\u00f4tes invalides" + }, + "step": { + "user": { + "data": { + "exclude": "Adresses r\u00e9seau (s\u00e9par\u00e9es par des virgules) \u00e0 exclure de l'analyse", + "home_interval": "Nombre minimum de minutes entre les analyses des appareils actifs (pr\u00e9server la batterie)", + "hosts": "Adresses r\u00e9seau (s\u00e9par\u00e9es par des virgules) \u00e0 analyser", + "scan_options": "Options d'analyse brutes configurables pour Nmap" + }, + "description": "Configurer les h\u00f4tes \u00e0 analyser par Nmap. L'adresse r\u00e9seau et les exclusions peuvent \u00eatre des adresses IP (192.168.1.1), des r\u00e9seaux IP (192.168.0.0/24) ou des plages IP (192.168.1.0-32)." + } + } + }, "options": { + "error": { + "invalid_hosts": "H\u00f4tes invalides" + }, "step": { "init": { "data": { + "exclude": "Adresses r\u00e9seau (s\u00e9par\u00e9es par des virgules) \u00e0 exclure de l'analyse", + "home_interval": "Nombre minimum de minutes entre les analyses des appareils actifs (pr\u00e9server la batterie)", + "hosts": "Adresses r\u00e9seau (s\u00e9par\u00e9es par des virgules) \u00e0 analyser", "interval_seconds": "Intervalle d\u2019analyse", + "scan_options": "Options d'analyse brutes configurables pour Nmap", "track_new_devices": "Suivre les nouveaux appareils" - } + }, + "description": "Configurer les h\u00f4tes \u00e0 analyser par Nmap. L'adresse r\u00e9seau et les exclusions peuvent \u00eatre des adresses IP (192.168.1.1),R\u00e9seaux IP (192.168.0.0/24) ou plages IP (192.168.1.0-32)." } } - } + }, + "title": "Traqueur Nmap" } \ No newline at end of file diff --git a/homeassistant/components/onvif/translations/fr.json b/homeassistant/components/onvif/translations/fr.json index d8d1cf89611..76eb733db3d 100644 --- a/homeassistant/components/onvif/translations/fr.json +++ b/homeassistant/components/onvif/translations/fr.json @@ -18,6 +18,16 @@ }, "title": "Configurer l'authentification" }, + "configure": { + "data": { + "host": "H\u00f4te", + "name": "Nom", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur" + }, + "title": "Configurer le p\u00e9riph\u00e9rique ONVIF" + }, "configure_profile": { "data": { "include": "Cr\u00e9er une entit\u00e9 cam\u00e9ra" @@ -40,6 +50,9 @@ "title": "Configurer l\u2019appareil ONVIF" }, "user": { + "data": { + "auto": "Rechercher automatiquement" + }, "description": "En cliquant sur soumettre, nous rechercherons sur votre r\u00e9seau, des \u00e9quipements ONVIF qui supporte le Profile S.\n\nCertains constructeurs ont commenc\u00e9 \u00e0 d\u00e9sactiver ONvif par d\u00e9faut. Assurez-vous qu\u2019ONVIF est activ\u00e9 dans la configuration de votre cam\u00e9ra", "title": "Configuration de l'appareil ONVIF" } diff --git a/homeassistant/components/onvif/translations/he.json b/homeassistant/components/onvif/translations/he.json index 290effbc48e..6d1229f1a5d 100644 --- a/homeassistant/components/onvif/translations/he.json +++ b/homeassistant/components/onvif/translations/he.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", - "no_h264": "\u05dc\u05d0 \u05d4\u05d9\u05d5 \u05d6\u05e8\u05de\u05d9 H264 \u05d6\u05de\u05d9\u05e0\u05d9\u05dd. \u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e4\u05e8\u05d5\u05e4\u05d9\u05dc \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05dc\u05da.", + "no_h264": "\u05dc\u05d0 \u05d4\u05d9\u05d5 \u05d6\u05e8\u05de\u05d9 H264 \u05d6\u05de\u05d9\u05e0\u05d9\u05dd. \u05d9\u05e9 \u05dc\u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e4\u05e8\u05d5\u05e4\u05d9\u05dc \u05d1\u05d4\u05ea\u05e7\u05df \u05e9\u05dc\u05da.", "no_mac": "\u05dc\u05d0 \u05d4\u05d9\u05ea\u05d4 \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05e7\u05d1\u05d5\u05e2 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05de\u05d6\u05d4\u05d4 \u05d9\u05d9\u05d7\u05d5\u05d3\u05d9 \u05e2\u05d1\u05d5\u05e8 \u05d4\u05ea\u05e7\u05df ONVIF." }, "error": { diff --git a/homeassistant/components/ovo_energy/translations/he.json b/homeassistant/components/ovo_energy/translations/he.json index 7864218bc3b..270d8744b96 100644 --- a/homeassistant/components/ovo_energy/translations/he.json +++ b/homeassistant/components/ovo_energy/translations/he.json @@ -17,7 +17,8 @@ "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" }, - "description": "\u05d4\u05d2\u05d3\u05e8 \u05de\u05d5\u05e4\u05e2 OVO \u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d2\u05e9\u05ea \u05dc\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05e9\u05dc\u05da." + "description": "\u05d4\u05d2\u05d3\u05e8 \u05de\u05d5\u05e4\u05e2 OVO \u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d2\u05e9\u05ea \u05dc\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05e9\u05dc\u05da.", + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05d7\u05e9\u05d1\u05d5\u05df \u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05e9\u05dc OVO" } } } diff --git a/homeassistant/components/philips_js/translations/fr.json b/homeassistant/components/philips_js/translations/fr.json index eb16bb92271..8cc5d187743 100644 --- a/homeassistant/components/philips_js/translations/fr.json +++ b/homeassistant/components/philips_js/translations/fr.json @@ -29,5 +29,14 @@ "trigger_type": { "turn_on": "Il a \u00e9t\u00e9 demand\u00e9 \u00e0 l'appareil de s'allumer" } + }, + "options": { + "step": { + "init": { + "data": { + "allow_notify": "Autoriser l'utilisation du service de notification de donn\u00e9es." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/picnic/translations/fr.json b/homeassistant/components/picnic/translations/fr.json index 044b0a72771..75e35a951de 100644 --- a/homeassistant/components/picnic/translations/fr.json +++ b/homeassistant/components/picnic/translations/fr.json @@ -3,6 +3,11 @@ "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "unknown": "Erreur inattendue" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/plugwise/translations/he.json b/homeassistant/components/plugwise/translations/he.json index a89120b85ab..db3eeef2d53 100644 --- a/homeassistant/components/plugwise/translations/he.json +++ b/homeassistant/components/plugwise/translations/he.json @@ -10,6 +10,9 @@ }, "flow_title": "{name}", "step": { + "user": { + "description": "\u05de\u05d5\u05e6\u05e8:" + }, "user_gateway": { "data": { "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP", diff --git a/homeassistant/components/poolsense/translations/de.json b/homeassistant/components/poolsense/translations/de.json index 0f82f949ede..5ce9313b442 100644 --- a/homeassistant/components/poolsense/translations/de.json +++ b/homeassistant/components/poolsense/translations/de.json @@ -13,7 +13,7 @@ "password": "Passwort" }, "description": "M\u00f6chtest Du mit der Einrichtung beginnen?", - "title": "" + "title": "PoolSense" } } } diff --git a/homeassistant/components/prosegur/translations/ca.json b/homeassistant/components/prosegur/translations/ca.json new file mode 100644 index 00000000000..a6c7c925a25 --- /dev/null +++ b/homeassistant/components/prosegur/translations/ca.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Torna a autenticar-te amb el compte de Prosegur.", + "password": "Contrasenya", + "username": "Nom d'usuari" + } + }, + "user": { + "data": { + "country": "Pa\u00eds", + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/cs.json b/homeassistant/components/prosegur/translations/cs.json new file mode 100644 index 00000000000..13c0827ff40 --- /dev/null +++ b/homeassistant/components/prosegur/translations/cs.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + }, + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/de.json b/homeassistant/components/prosegur/translations/de.json new file mode 100644 index 00000000000..aa5667d8d54 --- /dev/null +++ b/homeassistant/components/prosegur/translations/de.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Authentifiziere dich erneut mit deinem Prosegur-Konto.", + "password": "Passwort", + "username": "Benutzername" + } + }, + "user": { + "data": { + "country": "Land", + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/et.json b/homeassistant/components/prosegur/translations/et.json new file mode 100644 index 00000000000..bc5a1a5a7ea --- /dev/null +++ b/homeassistant/components/prosegur/translations/et.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Tundmatu viga" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Taastuvasta oma Prosegur kontoga.", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + }, + "user": { + "data": { + "country": "Riik", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/fr.json b/homeassistant/components/prosegur/translations/fr.json new file mode 100644 index 00000000000..7c0d361da6a --- /dev/null +++ b/homeassistant/components/prosegur/translations/fr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "unknown": "Erreur inattendue" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "R\u00e9-authentifiez-vous avec le compte Prosegur.", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + }, + "user": { + "data": { + "country": "Pays", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/he.json b/homeassistant/components/prosegur/translations/he.json new file mode 100644 index 00000000000..89a7445f2c3 --- /dev/null +++ b/homeassistant/components/prosegur/translations/he.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user": { + "data": { + "country": "\u05de\u05d3\u05d9\u05e0\u05d4", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/it.json b/homeassistant/components/prosegur/translations/it.json new file mode 100644 index 00000000000..79409045d7b --- /dev/null +++ b/homeassistant/components/prosegur/translations/it.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Eseguire nuovamente l'autenticazione con l'account Prosegur.", + "password": "Password", + "username": "Nome utente" + } + }, + "user": { + "data": { + "country": "Nazione", + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/nl.json b/homeassistant/components/prosegur/translations/nl.json new file mode 100644 index 00000000000..d87556b0742 --- /dev/null +++ b/homeassistant/components/prosegur/translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Verifieer opnieuw met Prosegur-account.", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + }, + "user": { + "data": { + "country": "Land", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/no.json b/homeassistant/components/prosegur/translations/no.json new file mode 100644 index 00000000000..5732bb920b2 --- /dev/null +++ b/homeassistant/components/prosegur/translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + }, + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/pl.json b/homeassistant/components/prosegur/translations/pl.json new file mode 100644 index 00000000000..342c17222b2 --- /dev/null +++ b/homeassistant/components/prosegur/translations/pl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Ponownie uwierzytelnij za pomoc\u0105 konta Prosegur.", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + }, + "user": { + "data": { + "country": "Kraj", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/ru.json b/homeassistant/components/prosegur/translations/ru.json new file mode 100644 index 00000000000..c75f3e572c6 --- /dev/null +++ b/homeassistant/components/prosegur/translations/ru.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "reauth_confirm": { + "data": { + "description": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Prosegur.", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + } + }, + "user": { + "data": { + "country": "\u0421\u0442\u0440\u0430\u043d\u0430", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/zh-Hant.json b/homeassistant/components/prosegur/translations/zh-Hant.json new file mode 100644 index 00000000000..501e979448e --- /dev/null +++ b/homeassistant/components/prosegur/translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "\u91cd\u65b0\u8a8d\u8b49 Prosegur \u5e33\u865f\u3002", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + }, + "user": { + "data": { + "country": "\u570b\u5bb6", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/translations/he.json b/homeassistant/components/ps4/translations/he.json index 837dd13b925..e9543da8206 100644 --- a/homeassistant/components/ps4/translations/he.json +++ b/homeassistant/components/ps4/translations/he.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", - "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9 \u05e4\u05dc\u05d9\u05d9\u05e1\u05d8\u05d9\u05d9\u05e9\u05df 4 \u05d1\u05e8\u05e9\u05ea." + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" @@ -14,7 +14,7 @@ "link": { "data": { "code": "\u05e7\u05d5\u05d3 PIN", - "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d4 - IP", + "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP", "name": "\u05e9\u05dd", "region": "\u05d0\u05d9\u05d6\u05d5\u05e8" }, diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/de.json b/homeassistant/components/pvpc_hourly_pricing/translations/de.json index 626382fcb6f..545a3d2cd9f 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/de.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/de.json @@ -11,7 +11,7 @@ "power_p3": "Vertraglich vereinbarte Leistung f\u00fcr Talperiode P3 (kW)", "tariff": "Geltender Tarif nach geografischer Zone" }, - "description": "Dieser Sensor verwendet die offizielle API, um [st\u00fcndliche Strompreise (PVPC)] (https://www.esios.ree.es/es/pvpc) in Spanien zu erhalten. Weitere Informationen findest du in den [Integrations-Dokumentation] (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/). ", + "description": "Dieser Sensor verwendet die offizielle API, um [st\u00fcndliche Strompreise (PVPC)] (https://www.esios.ree.es/es/pvpc) in Spanien zu erhalten. Weitere Informationen findest du in den [Integrations-Dokumentation] (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", "title": "Sensoreinrichtung" } } diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/fr.json b/homeassistant/components/pvpc_hourly_pricing/translations/fr.json index 5386529e43a..f8511a80579 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/fr.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/fr.json @@ -7,11 +7,26 @@ "user": { "data": { "name": "Nom du capteur", + "power": "Puissance souscrite (kW)", + "power_p3": "Puissance souscrite pour la p\u00e9riode de vall\u00e9e P3 (kW)", "tariff": "Tarif souscrit (1, 2, ou 3 p\u00e9riodes)" }, "description": "Ce capteur utilise l'API officielle pour obtenir la [tarification horaire de l'\u00e9lectricit\u00e9 (PVPC)] (https://www.esios.ree.es/es/pvpc) en Espagne. \n Pour une explication plus pr\u00e9cise, visitez la [documentation d'int\u00e9gration] (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/). \n\n S\u00e9lectionnez le tarif contract\u00e9 en fonction du nombre de p\u00e9riodes de facturation par jour: \n - 1 p\u00e9riode: normale \n - 2 p\u00e9riodes: discrimination (tarif \u00e0 la nuit) \n - 3 p\u00e9riodes: voiture \u00e9lectrique (tarif \u00e0 la nuit sur 3 p\u00e9riodes)", "title": "S\u00e9lection tarifaire" } } + }, + "options": { + "step": { + "init": { + "data": { + "power": "Puissance souscrite (kW)", + "power_p3": "Puissance souscrite pour la p\u00e9riode de vall\u00e9e P3 (kW)", + "tariff": "Tarif applicable par zone g\u00e9ographique" + }, + "description": "Ce capteur utilise l'API officielle pour obtenir [tarification horaire de l'\u00e9lectricit\u00e9 (PVPC)](https://www.esios.ree.es/es/pvpc) en Espagne.\n Pour des explications plus pr\u00e9cises, visitez les [docs d'int\u00e9gration](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "title": "Configuration du capteur" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/he.json b/homeassistant/components/pvpc_hourly_pricing/translations/he.json index 48a6eeeea33..951e9b21b2f 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/he.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/he.json @@ -3,5 +3,12 @@ "abort": { "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" } + }, + "options": { + "step": { + "init": { + "title": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05d7\u05d9\u05d9\u05e9\u05df" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/renault/translations/ca.json b/homeassistant/components/renault/translations/ca.json new file mode 100644 index 00000000000..8315d35b87b --- /dev/null +++ b/homeassistant/components/renault/translations/ca.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat", + "kamereon_no_account": "No s'ha pogut trobar cap compte Kamereon." + }, + "error": { + "invalid_credentials": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "ID del compte Kamereon" + }, + "title": "Seleccioneu l'ID del compte Kamereon" + }, + "user": { + "data": { + "locale": "Llengua/regi\u00f3", + "password": "Contrasenya", + "username": "Correu electr\u00f2nic" + }, + "title": "Defineix les credencials de Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/cs.json b/homeassistant/components/renault/translations/cs.json new file mode 100644 index 00000000000..d731b4c2ec0 --- /dev/null +++ b/homeassistant/components/renault/translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven" + }, + "error": { + "invalid_credentials": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "E-mail" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/de.json b/homeassistant/components/renault/translations/de.json new file mode 100644 index 00000000000..16650b8d63e --- /dev/null +++ b/homeassistant/components/renault/translations/de.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "kamereon_no_account": "Kamereon-Konto kann nicht gefunden werden." + }, + "error": { + "invalid_credentials": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon-Kontonummer" + }, + "title": "Kamereon-Kontonummer ausw\u00e4hlen" + }, + "user": { + "data": { + "locale": "Gebietsschema", + "password": "Passwort", + "username": "E-Mail" + }, + "title": "Renault-Anmeldeinformationen festlegen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/en.json b/homeassistant/components/renault/translations/en.json index bb65493a3b3..87186e6f59c 100644 --- a/homeassistant/components/renault/translations/en.json +++ b/homeassistant/components/renault/translations/en.json @@ -1,27 +1,27 @@ { - "config": { - "abort": { - "already_configured": "Account already configured", - "kamereon_no_account": "Unable to find Kamereon account." - }, - "error": { - "invalid_credentials": "Invalid credentials." - }, - "step": { - "kamereon": { - "data": { - "kamereon_account_id": "Kamereon account id" + "config": { + "abort": { + "already_configured": "Account is already configured", + "kamereon_no_account": "Unable to find Kamereon account." }, - "title": "Select Kamereon account id" - }, - "user": { - "data": { - "locale": "Locale", - "username": "Email", - "password": "Password" + "error": { + "invalid_credentials": "Invalid authentication" }, - "title": "Set Renault credentials" - } + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon account id" + }, + "title": "Select Kamereon account id" + }, + "user": { + "data": { + "locale": "Locale", + "password": "Password", + "username": "Email" + }, + "title": "Set Renault credentials" + } + } } - } } \ No newline at end of file diff --git a/homeassistant/components/renault/translations/et.json b/homeassistant/components/renault/translations/et.json new file mode 100644 index 00000000000..bae0db1aed7 --- /dev/null +++ b/homeassistant/components/renault/translations/et.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud", + "kamereon_no_account": "Kamereoni kontot ei leitud." + }, + "error": { + "invalid_credentials": "Tuvastamine nurjus" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereoni konto ID" + }, + "title": "Vali Kamereoni konto ID" + }, + "user": { + "data": { + "locale": "Riigi kood (n\u00e4iteks EE)", + "password": "Salas\u00f5na", + "username": "E-posti aadress" + }, + "title": "M\u00e4\u00e4ra Renault sidumise parameetrid" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/fr.json b/homeassistant/components/renault/translations/fr.json new file mode 100644 index 00000000000..874a9b8df67 --- /dev/null +++ b/homeassistant/components/renault/translations/fr.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "kamereon_no_account": "Impossible de trouver le compte Kamereon." + }, + "error": { + "invalid_credentials": "Authentification incorrecte" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Identifiant du compte Kamereon" + }, + "title": "S\u00e9lectionner l'identifiant du compte Kamereon" + }, + "user": { + "data": { + "locale": "Lieu", + "password": "Mot de passe", + "username": "Email" + }, + "title": "D\u00e9finir les informations d'identification de Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/he.json b/homeassistant/components/renault/translations/he.json new file mode 100644 index 00000000000..d20e2d36a81 --- /dev/null +++ b/homeassistant/components/renault/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_credentials": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05d3\u05d5\u05d0\"\u05dc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/it.json b/homeassistant/components/renault/translations/it.json new file mode 100644 index 00000000000..37ba94b3cdf --- /dev/null +++ b/homeassistant/components/renault/translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "kamereon_no_account": "Impossibile trovare l'account Kamereon." + }, + "error": { + "invalid_credentials": "Autenticazione non valida" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "ID account Kamereon" + }, + "title": "Seleziona l'id dell'account Kamereon" + }, + "user": { + "data": { + "locale": "Locale", + "password": "Password", + "username": "E-mail" + }, + "title": "Imposta le credenziali Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/nl.json b/homeassistant/components/renault/translations/nl.json new file mode 100644 index 00000000000..4840dd0c07b --- /dev/null +++ b/homeassistant/components/renault/translations/nl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "kamereon_no_account": "Kan Kamereon-account niet vinden." + }, + "error": { + "invalid_credentials": "Ongeldige authenticatie" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon account id" + }, + "title": "Selecteer Kamereon-account-ID" + }, + "user": { + "data": { + "locale": "Locale", + "password": "Wachtwoord", + "username": "E-mail" + }, + "title": "Renault-inloggegevens instellen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/no.json b/homeassistant/components/renault/translations/no.json new file mode 100644 index 00000000000..f367c8c540d --- /dev/null +++ b/homeassistant/components/renault/translations/no.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Passord", + "username": "E-Post" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/pl.json b/homeassistant/components/renault/translations/pl.json new file mode 100644 index 00000000000..1d518cc14fb --- /dev/null +++ b/homeassistant/components/renault/translations/pl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "kamereon_no_account": "Nie mo\u017cna znale\u017a\u0107 konta Kamereon." + }, + "error": { + "invalid_credentials": "Niepoprawne uwierzytelnienie" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Identyfikator konta Kamereon" + }, + "title": "Wyb\u00f3r identyfikatora konta Kamereon" + }, + "user": { + "data": { + "locale": "Ustawienia regionalne", + "password": "Has\u0142o", + "username": "Adres e-mail" + }, + "title": "Dane logowania Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/ru.json b/homeassistant/components/renault/translations/ru.json new file mode 100644 index 00000000000..822d42b6117 --- /dev/null +++ b/homeassistant/components/renault/translations/ru.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "kamereon_no_account": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043d\u0430\u0439\u0442\u0438 \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Kamereon." + }, + "error": { + "invalid_credentials": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "ID \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Kamereon" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 ID \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Kamereon" + }, + "user": { + "data": { + "locale": "\u0420\u0435\u0433\u0438\u043e\u043d", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" + }, + "title": "\u0423\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/zh-Hant.json b/homeassistant/components/renault/translations/zh-Hant.json new file mode 100644 index 00000000000..4ae5413499d --- /dev/null +++ b/homeassistant/components/renault/translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "kamereon_no_account": "\u627e\u4e0d\u5230 Kamereon \u5e33\u865f\u3002" + }, + "error": { + "invalid_credentials": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon \u5e33\u865f ID" + }, + "title": "\u9078\u64c7 Kamereon \u5e33\u865f ID" + }, + "user": { + "data": { + "locale": "\u4f4d\u7f6e", + "password": "\u5bc6\u78bc", + "username": "\u96fb\u5b50\u90f5\u4ef6" + }, + "title": "\u8a2d\u5b9a Renault \u6191\u8b49" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roku/translations/he.json b/homeassistant/components/roku/translations/he.json index 41d59c29fd8..12dc4bb482b 100644 --- a/homeassistant/components/roku/translations/he.json +++ b/homeassistant/components/roku/translations/he.json @@ -10,6 +10,14 @@ }, "flow_title": "{name}", "step": { + "ssdp_confirm": { + "data": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/roomba/translations/he.json b/homeassistant/components/roomba/translations/he.json index e2d0c48b0b9..4520671eedb 100644 --- a/homeassistant/components/roomba/translations/he.json +++ b/homeassistant/components/roomba/translations/he.json @@ -34,7 +34,7 @@ "host": "\u05de\u05d0\u05e8\u05d7", "password": "\u05e1\u05d9\u05e1\u05de\u05d4" }, - "description": "\u05db\u05e8\u05d2\u05e2 \u05d0\u05d7\u05d6\u05d5\u05e8 \u05d4-BLID \u05d5\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05d4\u05d5\u05d0 \u05ea\u05d4\u05dc\u05d9\u05da \u05d9\u05d3\u05e0\u05d9. \u05e0\u05d0 \u05d1\u05e6\u05e2 \u05d0\u05ea \u05d4\u05e9\u05dc\u05d1\u05d9\u05dd \u05d4\u05de\u05ea\u05d5\u05d0\u05e8\u05d9\u05dd \u05d1\u05ea\u05d9\u05e2\u05d5\u05d3 \u05d1\u05db\u05ea\u05d5\u05d1\u05ea: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials" + "description": "\u05d1\u05d7\u05d9\u05e8\u05ea Roomba \u05d0\u05d5 Braava." } } } diff --git a/homeassistant/components/roon/translations/nl.json b/homeassistant/components/roon/translations/nl.json index 452436d0e05..df8fa80b4dd 100644 --- a/homeassistant/components/roon/translations/nl.json +++ b/homeassistant/components/roon/translations/nl.json @@ -16,7 +16,7 @@ "data": { "host": "Host" }, - "description": "Voer de hostnaam of het IP-adres van uw Roon-server in." + "description": "Kon de Roon-server niet vinden, voer de hostnaam of het IP-adres in." } } } diff --git a/homeassistant/components/samsungtv/translations/fr.json b/homeassistant/components/samsungtv/translations/fr.json index 15a529c94b2..5a20992d8e5 100644 --- a/homeassistant/components/samsungtv/translations/fr.json +++ b/homeassistant/components/samsungtv/translations/fr.json @@ -5,7 +5,13 @@ "already_in_progress": "La configuration du t\u00e9l\u00e9viseur Samsung est d\u00e9j\u00e0 en cours.", "auth_missing": "Home Assistant n'est pas autoris\u00e9 \u00e0 se connecter \u00e0 ce t\u00e9l\u00e9viseur Samsung. Veuillez v\u00e9rifier les param\u00e8tres de votre t\u00e9l\u00e9viseur pour autoriser Home Assistant.", "cannot_connect": "\u00c9chec de connexion", - "not_supported": "Ce t\u00e9l\u00e9viseur Samsung n'est actuellement pas pris en charge." + "id_missing": "Cet appareil Samsung n'a pas de num\u00e9ro de s\u00e9rie.", + "not_supported": "Ce t\u00e9l\u00e9viseur Samsung n'est actuellement pas pris en charge.", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie", + "unknown": "Erreur inattendue" + }, + "error": { + "auth_missing": "Home Assistant n'est pas autoris\u00e9 \u00e0 se connecter \u00e0 ce t\u00e9l\u00e9viseur Samsung. V\u00e9rifiez les param\u00e8tres du Gestionnaire de p\u00e9riph\u00e9riques externes de votre t\u00e9l\u00e9viseur pour autoriser Home Assistant." }, "flow_title": "Samsung TV: {model}", "step": { @@ -13,6 +19,9 @@ "description": "Voulez vous installer la TV {model} Samsung? Si vous n'avez jamais connect\u00e9 Home Assistant avant, vous devriez voir une fen\u00eatre contextuelle sur votre t\u00e9l\u00e9viseur demandant une authentification. Les configurations manuelles de ce t\u00e9l\u00e9viseur seront \u00e9cras\u00e9es.", "title": "TV Samsung" }, + "reauth_confirm": { + "description": "Apr\u00e8s avoir soumis, acceptez la fen\u00eatre contextuelle sur {device} demandant l'autorisation dans les 30 secondes." + }, "user": { "data": { "host": "Nom d'h\u00f4te ou adresse IP", diff --git a/homeassistant/components/screenlogic/translations/de.json b/homeassistant/components/screenlogic/translations/de.json index 84f425be218..30e3ee0c726 100644 --- a/homeassistant/components/screenlogic/translations/de.json +++ b/homeassistant/components/screenlogic/translations/de.json @@ -18,7 +18,7 @@ }, "gateway_select": { "data": { - "selected_gateway": "" + "selected_gateway": "Gateway" }, "description": "Die folgenden ScreenLogic-Gateways wurden erkannt. Bitte w\u00e4hle eines aus, um es zu konfigurieren oder w\u00e4hle ein ScreenLogic-Gateway zum manuellen Konfigurieren.", "title": "ScreenLogic" diff --git a/homeassistant/components/select/translations/fr.json b/homeassistant/components/select/translations/fr.json index 7a3633cb309..5248940b2c4 100644 --- a/homeassistant/components/select/translations/fr.json +++ b/homeassistant/components/select/translations/fr.json @@ -1,3 +1,14 @@ { + "device_automation": { + "action_type": { + "select_option": "Modifier l'option {entity_name}" + }, + "condition_type": { + "selected_option": "Option actuellement s\u00e9lectionn\u00e9e {entity_name}" + }, + "trigger_type": { + "current_option_changed": "Modification de l\u2019option {entity_name}" + } + }, "title": "S\u00e9lectionner" } \ No newline at end of file diff --git a/homeassistant/components/sia/translations/fr.json b/homeassistant/components/sia/translations/fr.json new file mode 100644 index 00000000000..2b3188dd082 --- /dev/null +++ b/homeassistant/components/sia/translations/fr.json @@ -0,0 +1,50 @@ +{ + "config": { + "error": { + "invalid_account_format": "Le compte n'est pas une valeur hexad\u00e9cimale, veuillez utiliser uniquement 0-9 et AF.", + "invalid_account_length": "Le compte n'est pas de la bonne longueur, il doit faire entre 3 et 16 caract\u00e8res.", + "invalid_key_format": "La cl\u00e9 n'est pas une valeur hexad\u00e9cimale, veuillez utiliser uniquement 0-9 et AF.", + "invalid_key_length": "La cl\u00e9 n'est pas de la bonne longueur, elle doit comporter 16, 24 ou 32 caract\u00e8res hexad\u00e9cimaux.", + "invalid_ping": "L'intervalle de ping doit \u00eatre compris entre 1 et 1440 minutes.", + "invalid_zones": "Il doit y avoir au moins 1 zone.", + "unknown": "Erreur inattendue" + }, + "step": { + "additional_account": { + "data": { + "account": "Identifiant du compte", + "additional_account": "Comptes suppl\u00e9mentaires", + "encryption_key": "Cl\u00e9 de cryptage", + "ping_interval": "Intervalle de ping (min)", + "zones": "Nombre de zones pour le compte" + }, + "title": "Ajouter un autre compte au port actuel." + }, + "user": { + "data": { + "account": "Identifiant de compte", + "additional_account": "Comptes suppl\u00e9mentaires", + "encryption_key": "Cl\u00e9 de cryptage", + "ping_interval": "Intervalle de ping (min)", + "port": "Port", + "protocol": "Protocole", + "zones": "Nombre de zones pour le compte" + }, + "title": "Cr\u00e9er une connexion pour les syst\u00e8mes d'alarme bas\u00e9s sur SIA." + } + } + }, + "options": { + "step": { + "options": { + "data": { + "ignore_timestamps": "Ignorer la v\u00e9rification de l'horodatage des \u00e9v\u00e9nements SIA", + "zones": "Nombre de zones pour le compte" + }, + "description": "D\u00e9finisser les options du compte\u00a0: {account}", + "title": "Options pour la configuration SIA." + } + } + }, + "title": "Syst\u00e8mes d'alarme SIA" +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/translations/de.json b/homeassistant/components/simplisafe/translations/de.json index 966f3598d95..ae354b2138a 100644 --- a/homeassistant/components/simplisafe/translations/de.json +++ b/homeassistant/components/simplisafe/translations/de.json @@ -12,7 +12,7 @@ }, "step": { "mfa": { - "description": "Pr\u00fcfe deine E-Mail auf einen Link von SimpliSafe. Kehre nach der Verifizierung des Links hierher zur\u00fcck, um die Installation der Integration abzuschlie\u00dfen.", + "description": "Pr\u00fcfe deine E-Mail auf einen Link von SimpliSafe. Kehre nach der Verifizierung des Links hierher zur\u00fcck, um die Installation der Integration abzuschlie\u00dfen.", "title": "SimpliSafe Multi-Faktor-Authentifizierung" }, "reauth_confirm": { diff --git a/homeassistant/components/sma/translations/fr.json b/homeassistant/components/sma/translations/fr.json index ab154fea3f8..e70401c87f5 100644 --- a/homeassistant/components/sma/translations/fr.json +++ b/homeassistant/components/sma/translations/fr.json @@ -1,17 +1,23 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours" }, "error": { - "cannot_retrieve_device_info": "Connexion r\u00e9ussie, mais impossible de r\u00e9cup\u00e9rer les informations sur l'appareil" + "cannot_connect": "\u00c9chec de connexion", + "cannot_retrieve_device_info": "Connexion r\u00e9ussie, mais impossible de r\u00e9cup\u00e9rer les informations sur l'appareil", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" }, "step": { "user": { "data": { "group": "Groupe", "host": "H\u00f4te ", - "password": "Mot de passe" + "password": "Mot de passe", + "ssl": "Utilise un certificat SSL", + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "description": "Saisissez les informations relatives \u00e0 votre appareil SMA.", "title": "Configurer SMA Solar" diff --git a/homeassistant/components/smartthings/translations/he.json b/homeassistant/components/smartthings/translations/he.json index c098bfafbee..db5bc91bf7b 100644 --- a/homeassistant/components/smartthings/translations/he.json +++ b/homeassistant/components/smartthings/translations/he.json @@ -19,7 +19,7 @@ } }, "user": { - "description": "\u05d4\u05d6\u05df SmartThings [\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4 \u05d0\u05d9\u05e9\u05d9\u05ea] ( {token_url} ) \u05e9\u05e0\u05d5\u05e6\u05e8 \u05dc\u05e4\u05d9 [\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea] ( {component_url} ).", + "description": "SmartThings \u05d9\u05d5\u05d2\u05d3\u05e8 \u05dc\u05e9\u05dc\u05d5\u05d7 \u05e2\u05d3\u05db\u05d5\u05e0\u05d9 \u05d3\u05d7\u05d9\u05e4\u05d4 \u05dc-Home Assistant \u05d1:\n> {webhook_url}\n\n\u05d0\u05dd \u05d4\u05d3\u05d1\u05e8 \u05d0\u05d9\u05e0\u05d5 \u05e0\u05db\u05d5\u05df, \u05e0\u05d0 \u05dc\u05e2\u05d3\u05db\u05df \u05d0\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4, \u05d5\u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea Home Assistant \u05d5\u05dc\u05e0\u05e1\u05d5\u05ea \u05e9\u05d5\u05d1.", "title": "\u05d0\u05d9\u05e9\u05d5\u05e8 \u05e7\u05d9\u05e9\u05d5\u05e8 \u05dc\u05d4\u05ea\u05e7\u05e9\u05e8\u05d5\u05ea \u05d7\u05d6\u05e8\u05d4" } } diff --git a/homeassistant/components/smarttub/translations/fr.json b/homeassistant/components/smarttub/translations/fr.json index c6f3fdb8ce3..c660ca15e87 100644 --- a/homeassistant/components/smarttub/translations/fr.json +++ b/homeassistant/components/smarttub/translations/fr.json @@ -9,7 +9,8 @@ }, "step": { "reauth_confirm": { - "description": "L'int\u00e9gration SmartTub doit r\u00e9-authentifier votre compte" + "description": "L'int\u00e9gration SmartTub doit r\u00e9-authentifier votre compte", + "title": "R\u00e9authentification de l'int\u00e9gration" }, "user": { "data": { diff --git a/homeassistant/components/somfy_mylink/translations/de.json b/homeassistant/components/somfy_mylink/translations/de.json index d88d1320279..9fc1af6d92c 100644 --- a/homeassistant/components/somfy_mylink/translations/de.json +++ b/homeassistant/components/somfy_mylink/translations/de.json @@ -49,5 +49,5 @@ } } }, - "title": "" + "title": "Somfy MyLink" } \ No newline at end of file diff --git a/homeassistant/components/sonos/translations/fr.json b/homeassistant/components/sonos/translations/fr.json index 2bae0a69826..50a6086e2e8 100644 --- a/homeassistant/components/sonos/translations/fr.json +++ b/homeassistant/components/sonos/translations/fr.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "Aucun p\u00e9riph\u00e9rique Sonos trouv\u00e9 sur le r\u00e9seau.", + "not_sonos_device": "L'appareil d\u00e9couvert n'est pas un appareil Sonos", "single_instance_allowed": "Une seule configuration de Sonos est n\u00e9cessaire." }, "step": { diff --git a/homeassistant/components/sonos/translations/he.json b/homeassistant/components/sonos/translations/he.json index 91cbe81a2a6..878c14a5119 100644 --- a/homeassistant/components/sonos/translations/he.json +++ b/homeassistant/components/sonos/translations/he.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9 Sonos \u05d1\u05e8\u05e9\u05ea.", - "single_instance_allowed": "\u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Sonos \u05e0\u05d7\u05d5\u05e6\u05d4." + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, "step": { "confirm": { diff --git a/homeassistant/components/switcher_kis/translations/fr.json b/homeassistant/components/switcher_kis/translations/fr.json index 059be87fc07..e6e7a3c271f 100644 --- a/homeassistant/components/switcher_kis/translations/fr.json +++ b/homeassistant/components/switcher_kis/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau" + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { diff --git a/homeassistant/components/switcher_kis/translations/it.json b/homeassistant/components/switcher_kis/translations/it.json new file mode 100644 index 00000000000..0278fe07bfe --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/it.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo trovato sulla rete", + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "step": { + "confirm": { + "description": "Vuoi iniziare la configurazione?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthing/translations/fr.json b/homeassistant/components/syncthing/translations/fr.json new file mode 100644 index 00000000000..12486fb5cf2 --- /dev/null +++ b/homeassistant/components/syncthing/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte" + }, + "step": { + "user": { + "data": { + "title": "Configurer l'int\u00e9gration de Syncthing", + "token": "Jeton", + "url": "URL", + "verify_ssl": "V\u00e9rifier le certificat SSL" + } + } + } + }, + "title": "Synchroniser" +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/cs.json b/homeassistant/components/synology_dsm/translations/cs.json index a9fdd199618..561c6c97e0b 100644 --- a/homeassistant/components/synology_dsm/translations/cs.json +++ b/homeassistant/components/synology_dsm/translations/cs.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", @@ -28,6 +29,13 @@ "description": "Chcete nastavit {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "title": "Synology DSM Znovu ov\u011b\u0159it integraci" + }, "user": { "data": { "host": "Hostitel", diff --git a/homeassistant/components/synology_dsm/translations/fr.json b/homeassistant/components/synology_dsm/translations/fr.json index ba4d0a88a6b..b254fc8e561 100644 --- a/homeassistant/components/synology_dsm/translations/fr.json +++ b/homeassistant/components/synology_dsm/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9" + "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -34,7 +35,8 @@ "password": "Mot de passe", "username": "Nom d'utilisateur" }, - "description": "Raison: {details}" + "description": "Raison: {details}", + "title": "Synology DSM R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { diff --git a/homeassistant/components/synology_dsm/translations/he.json b/homeassistant/components/synology_dsm/translations/he.json index a671684a770..4d95d5c2c3c 100644 --- a/homeassistant/components/synology_dsm/translations/he.json +++ b/homeassistant/components/synology_dsm/translations/he.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", @@ -19,7 +20,13 @@ "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9", "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" }, - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} ({host})?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name} ({host})?" + }, + "reauth": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } }, "user": { "data": { diff --git a/homeassistant/components/synology_dsm/translations/it.json b/homeassistant/components/synology_dsm/translations/it.json index 6f5fd4ac245..bb6965255bb 100644 --- a/homeassistant/components/synology_dsm/translations/it.json +++ b/homeassistant/components/synology_dsm/translations/it.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "cannot_connect": "Impossibile connettersi", @@ -29,6 +30,14 @@ "description": "Vuoi impostare {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Motivo: {details}", + "title": "Synology DSM Autenticare nuovamente l'integrazione" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/system_bridge/translations/fr.json b/homeassistant/components/system_bridge/translations/fr.json index 187360bac5e..a21fab81777 100644 --- a/homeassistant/components/system_bridge/translations/fr.json +++ b/homeassistant/components/system_bridge/translations/fr.json @@ -1,7 +1,14 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie", + "unknown": "Erreur inattendue" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "unknown": "Erreur inattendue" }, "flow_title": "Pont syst\u00e8me: {name}", "step": { diff --git a/homeassistant/components/system_health/translations/he.json b/homeassistant/components/system_health/translations/he.json index 2c46fb48c7d..ae5bdc2388a 100644 --- a/homeassistant/components/system_health/translations/he.json +++ b/homeassistant/components/system_health/translations/he.json @@ -1,3 +1,3 @@ { - "title": "\u05d1\u05e8\u05d9\u05d0\u05d5\u05ea \u05de\u05e2\u05e8\u05db\u05ea" + "title": "\u05d1\u05e8\u05d9\u05d0\u05d5\u05ea \u05d4\u05de\u05e2\u05e8\u05db\u05ea" } \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/ca.json b/homeassistant/components/tesla/translations/ca.json index 2a51c0297ae..f5c0117f6a0 100644 --- a/homeassistant/components/tesla/translations/ca.json +++ b/homeassistant/components/tesla/translations/ca.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "Codi MFA (opcional)", "password": "Contrasenya", "username": "Correu electr\u00f2nic" }, diff --git a/homeassistant/components/tesla/translations/de.json b/homeassistant/components/tesla/translations/de.json index bdcd8237b3b..09934369f6b 100644 --- a/homeassistant/components/tesla/translations/de.json +++ b/homeassistant/components/tesla/translations/de.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA-Code (optional)", "password": "Passwort", "username": "E-Mail" }, diff --git a/homeassistant/components/tesla/translations/et.json b/homeassistant/components/tesla/translations/et.json index c7ceae36990..ab36a4e503d 100644 --- a/homeassistant/components/tesla/translations/et.json +++ b/homeassistant/components/tesla/translations/et.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA kood (valikuline)", "password": "Salas\u00f5na", "username": "E-post" }, diff --git a/homeassistant/components/tesla/translations/fr.json b/homeassistant/components/tesla/translations/fr.json index 889c32a7d91..174b687f26f 100644 --- a/homeassistant/components/tesla/translations/fr.json +++ b/homeassistant/components/tesla/translations/fr.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "Code MFA (facultatif)", "password": "Mot de passe", "username": "Email" }, diff --git a/homeassistant/components/tesla/translations/it.json b/homeassistant/components/tesla/translations/it.json index 3a137da78f1..05a663df149 100644 --- a/homeassistant/components/tesla/translations/it.json +++ b/homeassistant/components/tesla/translations/it.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "Codice autenticazione a pi\u00f9 fattori MFA (facoltativo)", "password": "Password", "username": "E-mail" }, diff --git a/homeassistant/components/tesla/translations/nl.json b/homeassistant/components/tesla/translations/nl.json index 5655a641f96..689766cd906 100644 --- a/homeassistant/components/tesla/translations/nl.json +++ b/homeassistant/components/tesla/translations/nl.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA Code (optioneel)", "password": "Wachtwoord", "username": "E-mail" }, diff --git a/homeassistant/components/tesla/translations/pl.json b/homeassistant/components/tesla/translations/pl.json index 7ec634cd56c..266a0e82dbe 100644 --- a/homeassistant/components/tesla/translations/pl.json +++ b/homeassistant/components/tesla/translations/pl.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "Kod uwierzytelniania wielosk\u0142adnikowego (opcjonalnie)", "password": "Has\u0142o", "username": "Adres e-mail" }, diff --git a/homeassistant/components/tesla/translations/ru.json b/homeassistant/components/tesla/translations/ru.json index d62a2e1f168..191d10b8bea 100644 --- a/homeassistant/components/tesla/translations/ru.json +++ b/homeassistant/components/tesla/translations/ru.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "\u041a\u043e\u0434 MFA (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" }, diff --git a/homeassistant/components/tesla/translations/zh-Hant.json b/homeassistant/components/tesla/translations/zh-Hant.json index d9b7fd4ef79..9ff407efaa3 100644 --- a/homeassistant/components/tesla/translations/zh-Hant.json +++ b/homeassistant/components/tesla/translations/zh-Hant.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA \u78bc\uff08\u9078\u9805\uff09", "password": "\u5bc6\u78bc", "username": "\u96fb\u5b50\u90f5\u4ef6" }, diff --git a/homeassistant/components/totalconnect/translations/fr.json b/homeassistant/components/totalconnect/translations/fr.json index b46bf127963..668b20726fc 100644 --- a/homeassistant/components/totalconnect/translations/fr.json +++ b/homeassistant/components/totalconnect/translations/fr.json @@ -11,7 +11,8 @@ "step": { "locations": { "data": { - "location": "Emplacement" + "location": "Emplacement", + "usercode": "Code d'utilisateur" }, "description": "Saisissez le code d'utilisateur de cet utilisateur \u00e0 cet emplacement", "title": "Codes d'utilisateur de l'emplacement" diff --git a/homeassistant/components/tplink/translations/he.json b/homeassistant/components/tplink/translations/he.json index 55d53f6e676..888c65226dc 100644 --- a/homeassistant/components/tplink/translations/he.json +++ b/homeassistant/components/tplink/translations/he.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05d4\u05ea\u05e7\u05e0\u05d9 TP-Link \u05d1\u05e8\u05e9\u05ea.", - "single_instance_allowed": "\u05e0\u05d3\u05e8\u05e9\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d1\u05dc\u05d1\u05d3" + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, "step": { "confirm": { diff --git a/homeassistant/components/traccar/translations/de.json b/homeassistant/components/traccar/translations/de.json index 7e253c1d05f..3e94aaeb4c5 100644 --- a/homeassistant/components/traccar/translations/de.json +++ b/homeassistant/components/traccar/translations/de.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "Deine Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Webhook-Nachrichten empfangen zu k\u00f6nnen." }, "create_entry": { - "default": "Um Ereignisse an den Heimassistenten zu senden, m\u00fcssen die Webhook-Funktionen in Traccar eingerichtet werden.\n\nVerwende die folgende URL: `{webhook_url}`\n\nSiehe [Dokumentation]({docs_url}) f\u00fcr weitere Details." + "default": "Um Ereignisse an den Heimassistenten zu senden, m\u00fcssen die Webhook-Funktionen in Traccar eingerichtet werden.\n\nVerwende die folgende URL: `{webhook_url}`\n\nSiehe [Dokumentation]({docs_url}) f\u00fcr weitere Details." }, "step": { "user": { diff --git a/homeassistant/components/unifi/translations/he.json b/homeassistant/components/unifi/translations/he.json index 83c34cb9c77..4fe52a3cf8b 100644 --- a/homeassistant/components/unifi/translations/he.json +++ b/homeassistant/components/unifi/translations/he.json @@ -33,6 +33,14 @@ "track_devices": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9 \u05e8\u05e9\u05ea (\u05d4\u05ea\u05e7\u05e0\u05d9 Ubiquiti)" } }, + "init": { + "data": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + } + }, "simple_options": { "data": { "block_client": "\u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05de\u05d1\u05d5\u05e7\u05e8\u05d9\u05dd \u05e9\u05dc \u05d2\u05d9\u05e9\u05d4 \u05dc\u05e8\u05e9\u05ea", diff --git a/homeassistant/components/upb/translations/de.json b/homeassistant/components/upb/translations/de.json index 86e4d7409cf..7c0454e7707 100644 --- a/homeassistant/components/upb/translations/de.json +++ b/homeassistant/components/upb/translations/de.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_upb_file": "Fehlende oder ung\u00fcltige UPB UPStart-Exportdatei, \u00fcberpr\u00fcfe den Namen und den Pfad der Datei.", + "invalid_upb_file": "Fehlende oder ung\u00fcltige UPB UPStart-Exportdatei, \u00fcberpr\u00fcfe den Namen und den Pfad der Datei.", "unknown": "Unerwarteter Fehler" }, "step": { diff --git a/homeassistant/components/upnp/translations/fr.json b/homeassistant/components/upnp/translations/fr.json index fe1f1366d39..ffbef69abe7 100644 --- a/homeassistant/components/upnp/translations/fr.json +++ b/homeassistant/components/upnp/translations/fr.json @@ -26,5 +26,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Intervalle de mise \u00e0 jour (secondes, minimum 30)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/upnp/translations/he.json b/homeassistant/components/upnp/translations/he.json index 706d87f0db4..e9aba0a7a58 100644 --- a/homeassistant/components/upnp/translations/he.json +++ b/homeassistant/components/upnp/translations/he.json @@ -12,6 +12,12 @@ }, "flow_title": "{name}", "step": { + "init": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + }, "user": { "data": { "unique_id": "\u05d4\u05ea\u05e7\u05df", diff --git a/homeassistant/components/wallbox/translations/fr.json b/homeassistant/components/wallbox/translations/fr.json new file mode 100644 index 00000000000..04428ef567f --- /dev/null +++ b/homeassistant/components/wallbox/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "station": "Num\u00e9ro de s\u00e9rie de la station", + "username": "Nom d'utilisateur" + } + } + } + }, + "title": "Wallbox" +} \ No newline at end of file diff --git a/homeassistant/components/wemo/translations/fr.json b/homeassistant/components/wemo/translations/fr.json index ccf2ac6ef21..e0372147f58 100644 --- a/homeassistant/components/wemo/translations/fr.json +++ b/homeassistant/components/wemo/translations/fr.json @@ -9,5 +9,10 @@ "description": "Voulez-vous configurer Wemo?" } } + }, + "device_automation": { + "trigger_type": { + "long_press": "Le bouton Wemo a \u00e9t\u00e9 enfonc\u00e9 pendant 2 secondes" + } } } \ No newline at end of file diff --git a/homeassistant/components/wled/translations/fr.json b/homeassistant/components/wled/translations/fr.json index 137decb7f40..dec038a8a92 100644 --- a/homeassistant/components/wled/translations/fr.json +++ b/homeassistant/components/wled/translations/fr.json @@ -20,5 +20,14 @@ "title": "Dispositif WLED d\u00e9couvert" } } + }, + "options": { + "step": { + "init": { + "data": { + "keep_master_light": "Garder la lumi\u00e8re principale, m\u00eame avec 1 segment LED." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.de.json b/homeassistant/components/wolflink/translations/sensor.de.json index 6b1baf8b0bf..497f559a9de 100644 --- a/homeassistant/components/wolflink/translations/sensor.de.json +++ b/homeassistant/components/wolflink/translations/sensor.de.json @@ -10,7 +10,7 @@ "at_abschaltung": "AT Abschaltung", "at_frostschutz": "AT Frostschutz", "aus": "Aus", - "auto": "", + "auto": "Automatisch", "auto_off_cool": "AutoOffCool", "auto_on_cool": "AutoOnCool", "automatik_aus": "Automatik AUS", diff --git a/homeassistant/components/wolflink/translations/sensor.he.json b/homeassistant/components/wolflink/translations/sensor.he.json index 68b635ba82b..8447fd66b31 100644 --- a/homeassistant/components/wolflink/translations/sensor.he.json +++ b/homeassistant/components/wolflink/translations/sensor.he.json @@ -1,6 +1,7 @@ { "state": { "wolflink__state": { + "permanent": "\u05e7\u05d1\u05d5\u05e2", "solarbetrieb": "\u05de\u05e6\u05d1 \u05e1\u05d5\u05dc\u05d0\u05e8\u05d9", "standby": "\u05de\u05e6\u05d1 \u05d4\u05de\u05ea\u05e0\u05d4", "start": "\u05d4\u05ea\u05d7\u05dc", diff --git a/homeassistant/components/xiaomi_miio/translations/de.json b/homeassistant/components/xiaomi_miio/translations/de.json index 23a003daa39..17363b347c0 100644 --- a/homeassistant/components/xiaomi_miio/translations/de.json +++ b/homeassistant/components/xiaomi_miio/translations/de.json @@ -13,7 +13,7 @@ "cloud_login_error": "Konnte sich nicht bei Xioami Miio Cloud anmelden, \u00fcberpr\u00fcfe die Anmeldedaten.", "cloud_no_devices": "Keine Ger\u00e4te in diesem Xiaomi Miio Cloud-Konto gefunden.", "no_device_selected": "Kein Ger\u00e4t ausgew\u00e4hlt, bitte w\u00e4hle ein Ger\u00e4t aus.", - "unknown_device": "Das Ger\u00e4temodell ist nicht bekannt und das Ger\u00e4t kann nicht mithilfe des Assistenten eingerichtet werden." + "unknown_device": "Das Ger\u00e4temodell ist nicht bekannt und das Ger\u00e4t kann nicht mithilfe des Assistenten eingerichtet werden." }, "flow_title": "{name}", "step": { diff --git a/homeassistant/components/xiaomi_miio/translations/fr.json b/homeassistant/components/xiaomi_miio/translations/fr.json index 82b2d4991cb..2b68325a246 100644 --- a/homeassistant/components/xiaomi_miio/translations/fr.json +++ b/homeassistant/components/xiaomi_miio/translations/fr.json @@ -2,10 +2,16 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour cet appareil Xiaomi Miio est d\u00e9j\u00e0 en cours." + "already_in_progress": "Le flux de configuration pour cet appareil Xiaomi Miio est d\u00e9j\u00e0 en cours.", + "incomplete_info": "Informations incompl\u00e8tes pour configurer l'appareil, aucun h\u00f4te ou jeton fourni.", + "not_xiaomi_miio": "L'appareil n'est pas (encore) pris en charge par Xiaomi Miio.", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "cannot_connect": "\u00c9chec de connexion", + "cloud_credentials_incomplete": "Identifiants cloud incomplets, veuillez renseigner le nom d'utilisateur, le mot de passe et le pays", + "cloud_login_error": "Impossible de se connecter \u00e0 Xioami Miio Cloud, v\u00e9rifiez les informations d'identification.", + "cloud_no_devices": "Aucun appareil trouv\u00e9 dans ce compte cloud Xiaomi Miio.", "no_device_selected": "Aucun appareil s\u00e9lectionn\u00e9, veuillez s\u00e9lectionner un appareil.", "unknown_device": "Le mod\u00e8le d'appareil n'est pas connu, impossible de configurer l'appareil \u00e0 l'aide du flux de configuration." }, @@ -13,14 +19,20 @@ "step": { "cloud": { "data": { + "cloud_country": "Pays du serveur cloud", "cloud_password": "Mot de passe cloud", - "cloud_username": "Nom d'utilisateur cloud" - } + "cloud_username": "Nom d'utilisateur cloud", + "manual": "Configurer manuellement (non recommand\u00e9)" + }, + "description": "Connectez-vous au cloud Xiaomi Miio, voir https://www.openhab.org/addons/bindings/miio/#country-servers pour le serveur cloud \u00e0 utiliser.", + "title": "Se connecter \u00e0 un appareil Xiaomi Miio ou \u00e0 une passerelle Xiaomi" }, "connect": { "data": { "model": "Mod\u00e8le d'appareil" - } + }, + "description": "S\u00e9lectionner manuellement le mod\u00e8le d'appareil parmi les mod\u00e8les pris en charge.", + "title": "Se connecter \u00e0 un appareil Xiaomi Miio ou \u00e0 une passerelle Xiaomi" }, "device": { "data": { @@ -41,10 +53,24 @@ "description": "Vous aurez besoin du jeton API, voir https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token pour les instructions.", "title": "Se connecter \u00e0 la passerelle Xiaomi" }, + "manual": { + "data": { + "host": "Adresse IP", + "token": "Jeton API" + }, + "description": "Vous aurez besoin du jeton API de 32 caract\u00e8res, voir https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token pour les instructions. Veuillez noter que ce jeton API est diff\u00e9rent de la cl\u00e9 utilis\u00e9e par l'int\u00e9gration Xiaomi Aqara.", + "title": "Se connecter \u00e0 un appareil Xiaomi Miio ou \u00e0 une passerelle Xiaomi" + }, + "reauth_confirm": { + "description": "L'int\u00e9gration de Xiaomi Miio doit r\u00e9-authentifier votre compte afin de mettre \u00e0 jour les jetons ou d'ajouter les informations d'identification cloud manquantes.", + "title": "R\u00e9authentification de l'int\u00e9gration" + }, "select": { "data": { "select_device": "Appareil Miio" - } + }, + "description": "S\u00e9lectionner l'appareil Xiaomi Miio \u00e0 configurer.", + "title": "Se connecter \u00e0 un appareil Xiaomi Miio ou \u00e0 une passerelle Xiaomi" }, "user": { "data": { diff --git a/homeassistant/components/xiaomi_miio/translations/he.json b/homeassistant/components/xiaomi_miio/translations/he.json index 36f54e79361..e3bf59f9459 100644 --- a/homeassistant/components/xiaomi_miio/translations/he.json +++ b/homeassistant/components/xiaomi_miio/translations/he.json @@ -58,7 +58,7 @@ "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP", "token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df API" }, - "description": "\u05ea\u05d6\u05d3\u05e7\u05e7 \u05dc-32 \u05ea\u05d5\u05d5\u05d9 \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05df API, \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token\u05dc\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea. \u05e9\u05d9\u05dd \u05dc\u05d1, \u05d6\u05d4 \u05d0\u05e1\u05d9\u05de\u05d5\u05df API \u05e9\u05d5\u05e0\u05d4 \u05de\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05e9\u05de\u05e9 \u05dc\u05e9\u05d9\u05dc\u05d5\u05d1 Xiaomi Aqara.", + "description": "\u05ea\u05d6\u05d3\u05e7\u05e7 \u05dc-32 \u05ea\u05d5\u05d5\u05d9 \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05df API, \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u05dc\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea. \u05e9\u05d9\u05dd \u05dc\u05d1, \u05d6\u05d4 \u05d0\u05e1\u05d9\u05de\u05d5\u05df API \u05e9\u05d5\u05e0\u05d4 \u05de\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05e9\u05de\u05e9 \u05dc\u05e9\u05d9\u05dc\u05d5\u05d1 Xiaomi Aqara.", "title": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9" }, "reauth_confirm": { diff --git a/homeassistant/components/yale_smart_alarm/translations/ca.json b/homeassistant/components/yale_smart_alarm/translations/ca.json index f3865103996..ab77170999b 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ca.json +++ b/homeassistant/components/yale_smart_alarm/translations/ca.json @@ -9,16 +9,16 @@ "step": { "reauth_confirm": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "ID d'\u00e0rea", + "name": "Nom", "password": "Contrasenya", "username": "Nom d'usuari" } }, "user": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "ID d'\u00e0rea", + "name": "Nom", "password": "Contrasenya", "username": "Nom d'usuari" } diff --git a/homeassistant/components/yale_smart_alarm/translations/cs.json b/homeassistant/components/yale_smart_alarm/translations/cs.json new file mode 100644 index 00000000000..f19158bca25 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/cs.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven" + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", + "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + }, + "user": { + "data": { + "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", + "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/en.json b/homeassistant/components/yale_smart_alarm/translations/en.json index 84cfb893ad5..a439971fb3f 100644 --- a/homeassistant/components/yale_smart_alarm/translations/en.json +++ b/homeassistant/components/yale_smart_alarm/translations/en.json @@ -9,16 +9,16 @@ "step": { "reauth_confirm": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "Area ID", + "name": "Name", "password": "Password", "username": "Username" } }, "user": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "Area ID", + "name": "Name", "password": "Password", "username": "Username" } diff --git a/homeassistant/components/yale_smart_alarm/translations/fr.json b/homeassistant/components/yale_smart_alarm/translations/fr.json new file mode 100644 index 00000000000..60d6f5cc548 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "invalid_auth": "Authentification incorrecte" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID de la zone", + "name": "Nom", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + }, + "user": { + "data": { + "area_id": "ID de la zone", + "name": "Nom", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/he.json b/homeassistant/components/yale_smart_alarm/translations/he.json new file mode 100644 index 00000000000..41f5d4493bf --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/he.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "\u05de\u05d6\u05d4\u05d4 \u05d0\u05d6\u05d5\u05e8", + "name": "\u05e9\u05dd", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user": { + "data": { + "area_id": "\u05de\u05d6\u05d4\u05d4 \u05d0\u05d6\u05d5\u05e8", + "name": "\u05e9\u05dd", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/it.json b/homeassistant/components/yale_smart_alarm/translations/it.json new file mode 100644 index 00000000000..2f510e46396 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/it.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "invalid_auth": "Autenticazione non valida" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID area", + "name": "Nome", + "password": "Password", + "username": "Nome utente" + } + }, + "user": { + "data": { + "area_id": "ID area", + "name": "Nome", + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/nl.json b/homeassistant/components/yale_smart_alarm/translations/nl.json new file mode 100644 index 00000000000..53c1b8fb086 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/nl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "Area ID", + "name": "Naam", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + }, + "user": { + "data": { + "area_id": "Area ID", + "name": "Naam", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/no.json b/homeassistant/components/yale_smart_alarm/translations/no.json new file mode 100644 index 00000000000..bbeedb7dc89 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "name": "Navn", + "password": "Passord", + "username": "Brukernavn" + } + }, + "user": { + "data": { + "name": "Navn", + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/pl.json b/homeassistant/components/yale_smart_alarm/translations/pl.json new file mode 100644 index 00000000000..553d05ee439 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/pl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane" + }, + "error": { + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "Identyfikator obszaru", + "name": "Nazwa", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + }, + "user": { + "data": { + "area_id": "Identyfikator obszaru", + "name": "[%key::common::config_flow::data::name%]", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/ru.json b/homeassistant/components/yale_smart_alarm/translations/ru.json index 03af44dd983..aedf07d030e 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ru.json +++ b/homeassistant/components/yale_smart_alarm/translations/ru.json @@ -9,16 +9,16 @@ "step": { "reauth_confirm": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "ID \u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u044f", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" } }, "user": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "ID \u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u044f", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" } diff --git a/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json b/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json index 6cfcaf7c83b..e02b74f27a1 100644 --- a/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json +++ b/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json @@ -9,16 +9,16 @@ "step": { "reauth_confirm": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "\u5206\u5340 ID", + "name": "\u540d\u7a31", "password": "\u5bc6\u78bc", "username": "\u4f7f\u7528\u8005\u540d\u7a31" } }, "user": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "\u5206\u5340 ID", + "name": "\u540d\u7a31", "password": "\u5bc6\u78bc", "username": "\u4f7f\u7528\u8005\u540d\u7a31" } diff --git a/homeassistant/components/yamaha_musiccast/translations/fr.json b/homeassistant/components/yamaha_musiccast/translations/fr.json new file mode 100644 index 00000000000..0a8671dc2aa --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "yxc_control_url_missing": "L'URL de contr\u00f4le n'est pas donn\u00e9e dans la description ssdp." + }, + "error": { + "no_musiccast_device": "Cet appareil ne semble pas \u00eatre un appareil MusicCast." + }, + "flow_title": "MusicCast: {name}", + "step": { + "confirm": { + "description": "Voulez-vous commencer la configuration\u00a0?" + }, + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Configurer MusicCast pour l'int\u00e9grer \u00e0 Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yeelight/translations/fr.json b/homeassistant/components/yeelight/translations/fr.json index be55fe57ee0..9682f0d9b6f 100644 --- a/homeassistant/components/yeelight/translations/fr.json +++ b/homeassistant/components/yeelight/translations/fr.json @@ -7,7 +7,11 @@ "error": { "cannot_connect": "\u00c9chec de connexion" }, + "flow_title": "{model} {host}", "step": { + "discovery_confirm": { + "description": "Voulez-vous configurer {model} ({host})\u00a0?" + }, "pick_device": { "data": { "device": "Appareil" diff --git a/homeassistant/components/youless/translations/ca.json b/homeassistant/components/youless/translations/ca.json new file mode 100644 index 00000000000..1237597b797 --- /dev/null +++ b/homeassistant/components/youless/translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/cs.json b/homeassistant/components/youless/translations/cs.json new file mode 100644 index 00000000000..7a27355056b --- /dev/null +++ b/homeassistant/components/youless/translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "name": "Jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/de.json b/homeassistant/components/youless/translations/de.json new file mode 100644 index 00000000000..a87bbe1aa46 --- /dev/null +++ b/homeassistant/components/youless/translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/en.json b/homeassistant/components/youless/translations/en.json index 38923682b10..584a8283675 100644 --- a/homeassistant/components/youless/translations/en.json +++ b/homeassistant/components/youless/translations/en.json @@ -1,21 +1,15 @@ { "config": { - "abort": { - "already_configured": "Device is already configured" - }, "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" + "cannot_connect": "Failed to connect" }, "step": { "user": { "data": { "host": "Host", - "password": "Password", - "username": "Username" + "name": "Name" } } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/et.json b/homeassistant/components/youless/translations/et.json new file mode 100644 index 00000000000..9a26513c333 --- /dev/null +++ b/homeassistant/components/youless/translations/et.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/fr.json b/homeassistant/components/youless/translations/fr.json new file mode 100644 index 00000000000..6f9c76d9ba1 --- /dev/null +++ b/homeassistant/components/youless/translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/he.json b/homeassistant/components/youless/translations/he.json new file mode 100644 index 00000000000..33660936e12 --- /dev/null +++ b/homeassistant/components/youless/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/it.json b/homeassistant/components/youless/translations/it.json new file mode 100644 index 00000000000..8f93107a15d --- /dev/null +++ b/homeassistant/components/youless/translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/nl.json b/homeassistant/components/youless/translations/nl.json new file mode 100644 index 00000000000..a05a1e161cc --- /dev/null +++ b/homeassistant/components/youless/translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Naam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/no.json b/homeassistant/components/youless/translations/no.json new file mode 100644 index 00000000000..01ea5b65fb1 --- /dev/null +++ b/homeassistant/components/youless/translations/no.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Navn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/pl.json b/homeassistant/components/youless/translations/pl.json new file mode 100644 index 00000000000..98acbf5ef4b --- /dev/null +++ b/homeassistant/components/youless/translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "name": "Nazwa" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/ru.json b/homeassistant/components/youless/translations/ru.json new file mode 100644 index 00000000000..341b6a603aa --- /dev/null +++ b/homeassistant/components/youless/translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/zh-Hant.json b/homeassistant/components/youless/translations/zh-Hant.json new file mode 100644 index 00000000000..9ba777cefba --- /dev/null +++ b/homeassistant/components/youless/translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/translations/fr.json b/homeassistant/components/zha/translations/fr.json index 75ba26ca809..90d0908d6c3 100644 --- a/homeassistant/components/zha/translations/fr.json +++ b/homeassistant/components/zha/translations/fr.json @@ -41,6 +41,8 @@ "title": "Options du panneau de contr\u00f4le d'alarme" }, "zha_options": { + "consider_unavailable_battery": "Consid\u00e9rer les appareils aliment\u00e9s par batterie indisponibles apr\u00e8s (secondes)", + "consider_unavailable_mains": "Consid\u00e9rer les appareils aliment\u00e9s par le secteur indisponibles apr\u00e8s (secondes)", "default_light_transition": "Temps de transition de la lumi\u00e8re par d\u00e9faut (en secondes)", "enable_identify_on_join": "Activer l'effet d'identification quand les appareils rejoignent le r\u00e9seau", "title": "Options g\u00e9n\u00e9rales" diff --git a/homeassistant/components/zwave/translations/de.json b/homeassistant/components/zwave/translations/de.json index b226a2e51e0..9d432edd1e5 100644 --- a/homeassistant/components/zwave/translations/de.json +++ b/homeassistant/components/zwave/translations/de.json @@ -13,7 +13,7 @@ "network_key": "Netzwerkschl\u00fcssel (leer lassen, um automatisch zu generieren)", "usb_path": "USB-Ger\u00e4te-Pfad" }, - "description": "Diese Integration wird nicht mehr gepflegt. Verwenden Sie bei Neuinstallationen stattdessen Z-Wave JS.\n\nSiehe https://www.home-assistant.io/docs/z-wave/installation/ f\u00fcr Informationen zu den Konfigurationsvariablen" + "description": "Diese Integration wird nicht mehr gepflegt. Verwende bei Neuinstallationen stattdessen Z-Wave JS.\n\nSiehe https://www.home-assistant.io/docs/z-wave/installation/ f\u00fcr Informationen zu den Konfigurationsvariablen" } } }, diff --git a/homeassistant/components/zwave/translations/he.json b/homeassistant/components/zwave/translations/he.json index 585b696b496..9cbf39a6d16 100644 --- a/homeassistant/components/zwave/translations/he.json +++ b/homeassistant/components/zwave/translations/he.json @@ -15,13 +15,13 @@ "state": { "_": { "dead": "\u05de\u05ea", - "initializing": "\u05de\u05d0\u05ea\u05d7\u05dc", + "initializing": "\u05d0\u05ea\u05d7\u05d5\u05dc", "ready": "\u05de\u05d5\u05db\u05df", "sleeping": "\u05d9\u05e9\u05df" }, "query_stage": { - "dead": "\u05de\u05ea ({query_stage})", - "initializing": "\u05de\u05d0\u05ea\u05d7\u05dc ({query_stage})" + "dead": "\u05de\u05ea", + "initializing": "\u05d0\u05ea\u05d7\u05d5\u05dc" } } } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/de.json b/homeassistant/components/zwave_js/translations/de.json index 6435453b1df..9b01865d3be 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -116,5 +116,5 @@ } } }, - "title": "" + "title": "Z-Wave JS" } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/fr.json b/homeassistant/components/zwave_js/translations/fr.json index 46737b8c79d..1e51e97044a 100644 --- a/homeassistant/components/zwave_js/translations/fr.json +++ b/homeassistant/components/zwave_js/translations/fr.json @@ -52,23 +52,67 @@ } }, "device_automation": { + "condition_type": { + "config_parameter": "Valeur du param\u00e8tre de configuration {subtype}", + "node_status": "\u00c9tat du n\u0153ud", + "value": "Valeur actuelle d'une valeur Z-Wave" + }, "trigger_type": { + "event.notification.entry_control": "Envoi d'une notification de contr\u00f4le d'entr\u00e9e", "event.notification.notification": "Envoyer une notification", + "event.value_notification.basic": "\u00c9v\u00e9nement CC de base sur {subtype}", + "event.value_notification.central_scene": "Action de la sc\u00e8ne centrale sur {subtype}", "event.value_notification.scene_activation": "Activation de la sc\u00e8ne sur {sous-type}", "state.node_status": "Changement de statut du noeud" } }, "options": { + "abort": { + "addon_get_discovery_info_failed": "\u00c9chec de l'obtention des informations de d\u00e9couverte du module compl\u00e9mentaire Z-Wave JS.", + "addon_info_failed": "\u00c9chec de l'obtention des informations sur le module compl\u00e9mentaire Z-Wave JS.", + "addon_install_failed": "\u00c9chec de l'installation du module compl\u00e9mentaire Z-Wave JS.", + "addon_set_config_failed": "\u00c9chec de la d\u00e9finition de la configuration Z-Wave JS.", + "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "different_device": "Le p\u00e9riph\u00e9rique USB connect\u00e9 n'est pas le m\u00eame que pr\u00e9c\u00e9demment configur\u00e9 pour cette entr\u00e9e de configuration. Veuillez plut\u00f4t cr\u00e9er une nouvelle entr\u00e9e de configuration pour le nouveau p\u00e9riph\u00e9rique." + }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_ws_url": "URL websocket invalide" + "invalid_ws_url": "URL websocket invalide", + "unknown": "Erreur inattendue" + }, + "progress": { + "install_addon": "Veuillez patienter pendant que l'installation du module compl\u00e9mentaire Z-Wave JS se termine. Cela peut prendre plusieurs minutes.", + "start_addon": "Veuillez patienter pendant que le d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS se termine. Cela peut prendre quelques secondes." }, "step": { "configure_addon": { "data": { + "emulate_hardware": "\u00c9muler le mat\u00e9riel", "log_level": "Niveau du journal", - "network_key": "Cl\u00e9 r\u00e9seau" + "network_key": "Cl\u00e9 r\u00e9seau", + "usb_path": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "title": "Entrer dans la configuration du module compl\u00e9mentaire Z-Wave JS" + }, + "install_addon": { + "title": "L'installation du module compl\u00e9mentaire Z-Wave JS a commenc\u00e9" + }, + "manual": { + "data": { + "url": "URL" } + }, + "on_supervisor": { + "data": { + "use_addon": "Utiliser le module compl\u00e9mentaire Z-Wave JS Supervisor" + }, + "description": "Voulez-vous utiliser le module compl\u00e9mentaire Z-Wave JS Supervisor\u00a0?", + "title": "S\u00e9lectionner la m\u00e9thode de connexion" + }, + "start_addon": { + "title": "Le module compl\u00e9mentaire Z-Wave JS d\u00e9marre." } } }, From 73bc62949bfbd681dbc7ef307e58c738738e1273 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 2 Aug 2021 04:59:32 +0100 Subject: [PATCH 082/903] Fix error in homekit_controller causing some entities to get an incorrect unique id (#53848) --- .../components/homekit_controller/__init__.py | 7 +- .../components/homekit_controller/number.py | 3 +- .../components/homekit_controller/sensor.py | 3 +- .../specific_devices/test_ecobee3.py | 2 +- .../specific_devices/test_koogeek_p1eu.py | 2 +- .../specific_devices/test_koogeek_sw2.py | 2 +- .../specific_devices/test_mysa_living.py | 91 +++++++ .../test_vocolinc_flowerbud.py | 4 +- .../homekit_controller/mysa_living.json | 250 ++++++++++++++++++ 9 files changed, 354 insertions(+), 10 deletions(-) create mode 100644 tests/components/homekit_controller/specific_devices/test_mysa_living.py create mode 100644 tests/fixtures/homekit_controller/mysa_living.json diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 404b2f54ab0..c14cfbb8a7e 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -189,11 +189,16 @@ class CharacteristicEntity(HomeKitEntity): the service entity. """ + def __init__(self, accessory, devinfo, char): + """Initialise a generic single characteristic HomeKit entity.""" + self._char = char + super().__init__(accessory, devinfo) + @property def unique_id(self) -> str: """Return the ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) - return f"homekit-{serial}-aid:{self._aid}-sid:{self._iid}-cid:{self._iid}" + return f"homekit-{serial}-aid:{self._aid}-sid:{self._char.service.iid}-cid:{self._char.iid}" async def async_setup_entry(hass, entry): diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index 2e0193fa080..73d8cd6adbd 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -53,9 +53,8 @@ class HomeKitNumber(CharacteristicEntity, NumberEntity): self._device_class = device_class self._icon = icon self._name = name - self._char = char - super().__init__(conn, info) + super().__init__(conn, info, char) def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index f34fc3f0a9b..6bf8a7fc084 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -254,9 +254,8 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): self._unit = unit self._icon = icon self._name = name - self._char = char - super().__init__(conn, info) + super().__init__(conn, info, char) def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 96dbd3b0718..94f3aabc12a 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -60,7 +60,7 @@ async def test_ecobee3_setup(hass): assert climate_state.attributes["max_humidity"] == 50 climate_sensor = entity_registry.async_get("sensor.homew_current_temperature") - assert climate_sensor.unique_id == "homekit-123456789012-aid:1-sid:16-cid:16" + assert climate_sensor.unique_id == "homekit-123456789012-aid:1-sid:16-cid:19" occ1 = entity_registry.async_get("binary_sensor.kitchen") assert occ1.unique_id == "homekit-AB1C-56" diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py index db72aad7541..6302013223d 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py @@ -37,7 +37,7 @@ async def test_koogeek_p1eu_setup(hass): # Assert the power sensor is detected entry = entity_registry.async_get("sensor.koogeek_p1_a00aa0_real_time_energy") - assert entry.unique_id == "homekit-EUCP03190xxxxx48-aid:1-sid:21-cid:21" + assert entry.unique_id == "homekit-EUCP03190xxxxx48-aid:1-sid:21-cid:22" helper = Helper( hass, diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py index 00057822071..1d46b633153 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py @@ -45,7 +45,7 @@ async def test_koogeek_ls1_setup(hass): # Assert that the power sensor entity is correctly added to the entity registry entry = entity_registry.async_get("sensor.koogeek_sw2_187a91_real_time_energy") - assert entry.unique_id == "homekit-CNNT061751001372-aid:1-sid:14-cid:14" + assert entry.unique_id == "homekit-CNNT061751001372-aid:1-sid:14-cid:18" helper = Helper( hass, diff --git a/tests/components/homekit_controller/specific_devices/test_mysa_living.py b/tests/components/homekit_controller/specific_devices/test_mysa_living.py new file mode 100644 index 00000000000..ea1c1084071 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_mysa_living.py @@ -0,0 +1,91 @@ +"""Make sure that Mysa Living is enumerated properly.""" + +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_mysa_living_setup(hass): + """Test that the accessory can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "mysa_living.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + # Check that the switch entity is handled correctly + + entry = entity_registry.async_get("sensor.mysa_85dda9_current_humidity") + assert entry.unique_id == "homekit-AAAAAAA000-aid:1-sid:20-cid:27" + + helper = Helper( + hass, + "sensor.mysa_85dda9_current_humidity", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "Mysa-85dda9 - Current Humidity" + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == "Empowered Homes Inc." + assert device.name == "Mysa-85dda9" + assert device.model == "v1" + assert device.sw_version == "2.8.1" + assert device.via_device_id is None + + # Assert the humidifier is detected + entry = entity_registry.async_get("sensor.mysa_85dda9_current_temperature") + assert entry.unique_id == "homekit-AAAAAAA000-aid:1-sid:20-cid:25" + + helper = Helper( + hass, + "sensor.mysa_85dda9_current_temperature", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "Mysa-85dda9 - Current Temperature" + + # The sensor should be part of the same device + assert entry.device_id == device.id + + # Assert the light is detected + entry = entity_registry.async_get("light.mysa_85dda9") + assert entry.unique_id == "homekit-AAAAAAA000-40" + + helper = Helper( + hass, + "light.mysa_85dda9", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "Mysa-85dda9" + + # The light should be part of the same device + assert entry.device_id == device.id + + # Assert the climate entity is detected + entry = entity_registry.async_get("climate.mysa_85dda9") + assert entry.unique_id == "homekit-AAAAAAA000-20" + + helper = Helper( + hass, + "climate.mysa_85dda9", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "Mysa-85dda9" + + # The light should be part of the same device + assert entry.device_id == device.id diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py index e2762b5c153..6968c62257f 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py @@ -20,7 +20,7 @@ async def test_vocolinc_flowerbud_setup(hass): # Check that the switch entity is handled correctly entry = entity_registry.async_get("number.vocolinc_flowerbud_0d324b") - assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:30" + assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:38" helper = Helper( hass, "number.vocolinc_flowerbud_0d324b", pairing, accessories[0], config_entry @@ -73,7 +73,7 @@ async def test_vocolinc_flowerbud_setup(hass): entry = entity_registry.async_get( "sensor.vocolinc_flowerbud_0d324b_current_humidity" ) - assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:30" + assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:33" helper = Helper( hass, diff --git a/tests/fixtures/homekit_controller/mysa_living.json b/tests/fixtures/homekit_controller/mysa_living.json new file mode 100644 index 00000000000..da26b654fe5 --- /dev/null +++ b/tests/fixtures/homekit_controller/mysa_living.json @@ -0,0 +1,250 @@ +[ + { + "aid": 1, + "primary": true, + "services": [ + { + "type": "0000004A-0000-1000-8000-0026BB765291", + "primary": true, + "iid": 20, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Thermostat", + "perms": [ + "pr" + ], + "iid": 24 + }, + { + "type": "00000010-0000-1000-8000-0026BB765291", + "format": "float", + "minValue": 0, + "maxValue": 100, + "stepValue": 1, + "value": 40, + "iid": 27, + "unit": "percentage", + "perms": [ + "pr", + "ev" + ] + }, + { + "type": "0000000F-0000-1000-8000-0026BB765291", + "value": 0, + "minValue": 0, + "maxValue": 2, + "stepValue": 1, + "format": "uint8", + "perms": [ + "pr", + "ev" + ], + "iid": 21 + }, + { + "type": "00000033-0000-1000-8000-0026BB765291", + "value": 0, + "minValue": 0, + "maxValue": 3, + "stepValue": 1, + "format": "uint8", + "perms": [ + "pr", + "pw", + "ev" + ], + "iid": 22 + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "value": 24.1, + "minValue": 0, + "maxValue": 100, + "stepValue": 0.1, + "unit": "celsius", + "format": "float", + "perms": [ + "pr", + "ev" + ], + "iid": 25 + }, + { + "type": "00000035-0000-1000-8000-0026BB765291", + "value": 22, + "minValue": 5, + "maxValue": 30, + "stepValue": 0.1, + "unit": "celsius", + "format": "float", + "perms": [ + "pr", + "pw", + "ev" + ], + "iid": 23 + }, + { + "type": "00000036-0000-1000-8000-0026BB765291", + "format": "uint8", + "minValue": 0, + "maxValue": 1, + "stepValue": 1, + "value": 0, + "iid": 26, + "perms": [ + "pr", + "pw", + "ev" + ] + } + ] + }, + { + "type": "0000003E-0000-1000-8000-0026BB765291", + "iid": 1, + "characteristics": [ + { + "type": "00000014-0000-1000-8000-0026BB765291", + "perms": [ + "pw" + ], + "iid": 2, + "format": "bool" + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Empowered Homes Inc.", + "perms": [ + "pr" + ], + "iid": 3 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "format": "string", + "value": "v1", + "perms": [ + "pr" + ], + "iid": 4 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Mysa-85dda9", + "perms": [ + "pr" + ], + "iid": 5 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "format": "string", + "value": "AAAAAAA000", + "perms": [ + "pr" + ], + "iid": 6 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "format": "string", + "value": "2.8.1", + "perms": [ + "pr" + ], + "iid": 7 + }, + { + "hidden": true, + "type": "22280E2C-9B79-43BD-8370-5A8F67777B29", + "format": "string", + "value": "b4e62d85dda9", + "perms": [ + "pr" + ], + "iid": 8 + } + ] + }, + { + "type": "000000A2-0000-1000-8000-0026BB765291", + "iid": 10, + "characteristics": [ + { + "type": "00000037-0000-1000-8000-0026BB765291", + "format": "string", + "value": "1.1.0", + "perms": [ + "pr" + ], + "iid": 11 + } + ] + }, + { + "type": "00000043-0000-1000-8000-0026BB765291", + "iid": 40, + "characteristics": [ + { + "type": "00000025-0000-1000-8000-0026BB765291", + "format": "bool", + "value": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "iid": 42 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Display", + "perms": [ + "pr" + ], + "iid": 41 + }, + { + "type": "00000008-0000-1000-8000-0026BB765291", + "format": "int", + "minValue": 0, + "maxValue": 100, + "stepValue": 1, + "value": 0, + "iid": 43, + "unit": "percentage", + "perms": [ + "pr", + "pw", + "ev" + ] + } + ] + }, + { + "type": "3354EC82-AF38-4755-B4A4-4DB8E418F555", + "iid": 50, + "characteristics": [ + { + "hidden": true, + "type": "E71D8348-BB33-4C34-8C50-A64B1136EDD2", + "format": "bool", + "value": 0, + "perms": [ + "pr", + "pw" + ], + "iid": 51 + } + ] + } + ] + } +] \ No newline at end of file From 38832618bf30e84e5b62a65cd4d97e53520fb225 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 2 Aug 2021 09:18:59 +0200 Subject: [PATCH 083/903] Please mypy. (#53786) --- homeassistant/components/marytts/tts.py | 4 +++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py index 0f75fbcee35..5904e271a6a 100644 --- a/homeassistant/components/marytts/tts.py +++ b/homeassistant/components/marytts/tts.py @@ -1,4 +1,6 @@ """Support for the MaryTTS service.""" +from __future__ import annotations + from speak2mary import MaryTTS import voluptuous as vol @@ -19,7 +21,7 @@ DEFAULT_PORT = 59125 DEFAULT_LANG = "en_US" DEFAULT_VOICE = "cmu-slt-hsmm" DEFAULT_CODEC = "WAVE_FILE" -DEFAULT_EFFECTS = {} +DEFAULT_EFFECTS: dict[str, str] = {} MAP_MARYTTS_CODEC = {"WAVE_FILE": "wav", "AIFF_FILE": "aiff", "AU_FILE": "au"} diff --git a/mypy.ini b/mypy.ini index a26926b4012..4b0a0ad294b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1483,9 +1483,6 @@ ignore_errors = true [mypy-homeassistant.components.lyric.*] ignore_errors = true -[mypy-homeassistant.components.marytts.*] -ignore_errors = true - [mypy-homeassistant.components.media_source.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 1b47ff8a16c..71009ccd14d 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -94,7 +94,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.luftdaten.*", "homeassistant.components.lutron_caseta.*", "homeassistant.components.lyric.*", - "homeassistant.components.marytts.*", "homeassistant.components.media_source.*", "homeassistant.components.melcloud.*", "homeassistant.components.meteo_france.*", From da3419945cc5f598822007752d4e5634183d5fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 2 Aug 2021 11:07:21 +0200 Subject: [PATCH 084/903] Rename snapshot -> backup (#53851) --- homeassistant/components/hassio/__init__.py | 57 +++++++++++++----- homeassistant/components/hassio/http.py | 9 ++- homeassistant/components/hassio/services.yaml | 58 +++++++++++++++++-- homeassistant/components/zwave_js/__init__.py | 2 +- homeassistant/components/zwave_js/addon.py | 14 ++--- tests/components/hassio/test_http.py | 20 +++---- tests/components/hassio/test_init.py | 43 ++++++++++---- tests/components/hassio/test_websocket_api.py | 4 +- tests/components/zwave_js/conftest.py | 12 ++-- tests/components/zwave_js/test_init.py | 44 +++++++------- 10 files changed, 181 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 6c71f2eb042..d55de8e275b 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -89,6 +89,8 @@ SERVICE_HOST_SHUTDOWN = "host_shutdown" SERVICE_HOST_REBOOT = "host_reboot" SERVICE_SNAPSHOT_FULL = "snapshot_full" SERVICE_SNAPSHOT_PARTIAL = "snapshot_partial" +SERVICE_BACKUP_FULL = "backup_full" +SERVICE_BACKUP_PARTIAL = "backup_partial" SERVICE_RESTORE_FULL = "restore_full" SERVICE_RESTORE_PARTIAL = "restore_partial" @@ -101,11 +103,11 @@ SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} ) -SCHEMA_SNAPSHOT_FULL = vol.Schema( +SCHEMA_BACKUP_FULL = vol.Schema( {vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_PASSWORD): cv.string} ) -SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend( +SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( { vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), @@ -113,7 +115,12 @@ SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend( ) SCHEMA_RESTORE_FULL = vol.Schema( - {vol.Required(ATTR_SNAPSHOT): cv.slug, vol.Optional(ATTR_PASSWORD): cv.string} + { + vol.Exclusive(ATTR_SLUG, ATTR_SLUG): cv.slug, + vol.Exclusive(ATTR_SNAPSHOT, ATTR_SLUG): cv.slug, + vol.Optional(ATTR_PASSWORD): cv.string, + }, + cv.has_at_least_one_key(ATTR_SLUG, ATTR_SNAPSHOT), ) SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( @@ -133,25 +140,32 @@ MAP_SERVICE_API = { SERVICE_ADDON_STDIN: ("/addons/{addon}/stdin", SCHEMA_ADDON_STDIN, 60, False), SERVICE_HOST_SHUTDOWN: ("/host/shutdown", SCHEMA_NO_DATA, 60, False), SERVICE_HOST_REBOOT: ("/host/reboot", SCHEMA_NO_DATA, 60, False), - SERVICE_SNAPSHOT_FULL: ("/snapshots/new/full", SCHEMA_SNAPSHOT_FULL, 300, True), - SERVICE_SNAPSHOT_PARTIAL: ( - "/snapshots/new/partial", - SCHEMA_SNAPSHOT_PARTIAL, + SERVICE_BACKUP_FULL: ("/backups/new/full", SCHEMA_BACKUP_FULL, 300, True), + SERVICE_BACKUP_PARTIAL: ( + "/backups/new/partial", + SCHEMA_BACKUP_PARTIAL, 300, True, ), SERVICE_RESTORE_FULL: ( - "/snapshots/{snapshot}/restore/full", + "/backups/{slug}/restore/full", SCHEMA_RESTORE_FULL, 300, True, ), SERVICE_RESTORE_PARTIAL: ( - "/snapshots/{snapshot}/restore/partial", + "/backups/{slug}/restore/partial", SCHEMA_RESTORE_PARTIAL, 300, True, ), + SERVICE_SNAPSHOT_FULL: ("/backups/new/full", SCHEMA_BACKUP_FULL, 300, True), + SERVICE_SNAPSHOT_PARTIAL: ( + "/backups/new/partial", + SCHEMA_BACKUP_PARTIAL, + 300, + True, + ), } @@ -272,16 +286,16 @@ async def async_get_addon_discovery_info(hass: HomeAssistant, slug: str) -> dict @bind_hass @api_data -async def async_create_snapshot( +async def async_create_backup( hass: HomeAssistant, payload: dict, partial: bool = False ) -> dict: - """Create a full or partial snapshot. + """Create a full or partial backup. The caller of the function should handle HassioAPIError. """ hassio = hass.data[DOMAIN] - snapshot_type = "partial" if partial else "full" - command = f"/snapshots/new/{snapshot_type}" + backup_type = "partial" if partial else "full" + command = f"/backups/new/{backup_type}" return await hassio.send_command(command, payload=payload, timeout=None) @@ -453,9 +467,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_service_handler(service): """Handle service calls for Hass.io.""" api_command = MAP_SERVICE_API[service.service][0] + if "snapshot" in service.service: + _LOGGER.warning( + "The service '%s' is deprecated and will be removed in Home Assistant 2021.11, use '%s' instead", + service.service, + service.service.replace("snapshot", "backup"), + ) data = service.data.copy() addon = data.pop(ATTR_ADDON, None) + slug = data.pop(ATTR_SLUG, None) snapshot = data.pop(ATTR_SNAPSHOT, None) + if snapshot is not None: + _LOGGER.warning( + "Using 'snapshot' is deprecated and will be removed in Home Assistant 2021.11, use 'slug' instead" + ) + slug = snapshot + payload = None # Pass data to Hass.io API @@ -467,12 +494,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # Call API try: await hassio.send_command( - api_command.format(addon=addon, snapshot=snapshot), + api_command.format(addon=addon, slug=slug), payload=payload, timeout=MAP_SERVICE_API[service.service][2], ) except HassioAPIError as err: - _LOGGER.error("Error on Hass.io API: %s", err) + _LOGGER.error("Error on Supervisor API: %s", err) for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 47131b80de3..302cc00bb9f 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -29,6 +29,9 @@ NO_TIMEOUT = re.compile( r"|hassos/update/cli" r"|supervisor/update" r"|addons/[^/]+/(?:update|install|rebuild)" + r"|backups/.+/full" + r"|backups/.+/partial" + r"|backups/[^/]+/(?:upload|download)" r"|snapshots/.+/full" r"|snapshots/.+/partial" r"|snapshots/[^/]+/(?:upload|download)" @@ -36,7 +39,7 @@ NO_TIMEOUT = re.compile( ) NO_AUTH_ONBOARDING = re.compile( - r"^(?:" r"|supervisor/logs" r"|snapshots/[^/]+/.+" r")$" + r"^(?:" r"|supervisor/logs" r"|backups/[^/]+/.+" r"|snapshots/[^/]+/.+" r")$" ) NO_AUTH = re.compile( @@ -81,13 +84,13 @@ class HassIOView(HomeAssistantView): client_timeout = 10 data = None headers = _init_header(request) - if path == "snapshots/new/upload": + if path in ("snapshots/new/upload", "backups/new/upload"): # We need to reuse the full content type that includes the boundary headers[ "Content-Type" ] = request._stored_content_type # pylint: disable=protected-access - # Snapshots are big, so we need to adjust the allowed size + # Backups are big, so we need to adjust the allowed size request._client_max_size = ( # pylint: disable=protected-access MAX_UPLOAD_SIZE ) diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 0652b65d6e2..38d78984ddc 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -67,13 +67,13 @@ host_shutdown: description: Poweroff the host system. snapshot_full: - name: Create a full snapshot. - description: Create a full snapshot. + name: Create a full backup. + description: Create a full backup (deprecated, use backup_full instead). fields: name: name: Name description: Optional or it will be the current date and time. - example: "Snapshot 1" + example: "backup 1" selector: text: password: @@ -84,8 +84,8 @@ snapshot_full: text: snapshot_partial: - name: Create a partial snapshot. - description: Create a partial snapshot. + name: Create a partial backup. + description: Create a partial backup (deprecated, use backup_partial instead). fields: addons: name: Add-ons @@ -102,7 +102,53 @@ snapshot_partial: name: name: Name description: Optional or it will be the current date and time. - example: "Partial Snapshot 1" + example: "Partial backup 1" + selector: + text: + password: + name: Password + description: Optional password. + example: "password" + selector: + text: + +backup_full: + name: Create a full backup. + description: Create a full backup. + fields: + name: + name: Name + description: Optional or it will be the current date and time. + example: "backup 1" + selector: + text: + password: + name: Password + description: Optional password. + example: "password" + selector: + text: + +backup_partial: + name: Create a partial backup. + description: Create a partial backup. + fields: + addons: + name: Add-ons + description: Optional list of addon slugs. + example: ["core_ssh", "core_samba", "core_mosquitto"] + selector: + object: + folders: + name: Folders + description: Optional list of directories. + example: ["homeassistant", "share"] + selector: + object: + name: + name: Name + description: Optional or it will be the current date and time. + example: "Partial backup 1" selector: text: password: diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 6cd0104e298..6320efddb60 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -547,7 +547,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: LOGGER.error(err) return try: - await addon_manager.async_create_snapshot() + await addon_manager.async_create_backup() except AddonError as err: LOGGER.error(err) return diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index a0caaa15488..29ae887b4bc 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -8,7 +8,7 @@ from functools import partial from typing import Any, Callable, TypeVar, cast from homeassistant.components.hassio import ( - async_create_snapshot, + async_create_backup, async_get_addon_discovery_info, async_get_addon_info, async_install_addon, @@ -202,7 +202,7 @@ class AddonManager: if not addon_info.update_available: return - await self.async_create_snapshot() + await self.async_create_backup() await async_update_addon(self._hass, ADDON_SLUG) @callback @@ -289,14 +289,14 @@ class AddonManager: ) return self._start_task - @api_error("Failed to create a snapshot of the Z-Wave JS add-on.") - async def async_create_snapshot(self) -> None: - """Create a partial snapshot of the Z-Wave JS add-on.""" + @api_error("Failed to create a backup of the Z-Wave JS add-on.") + async def async_create_backup(self) -> None: + """Create a partial backup of the Z-Wave JS add-on.""" addon_info = await self.async_get_addon_info() name = f"addon_{ADDON_SLUG}_{addon_info.version}" - LOGGER.debug("Creating snapshot: %s", name) - await async_create_snapshot( + LOGGER.debug("Creating backup: %s", name) + await async_create_backup( self._hass, {"name": name, "addons": [ADDON_SLUG]}, partial=True, diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index ff1c348a37b..fc4bb3e6a0d 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -132,13 +132,13 @@ async def test_forwarding_user_info(hassio_client, hass_admin_user, aioclient_mo assert req_headers["X-Hass-Is-Admin"] == "1" -async def test_snapshot_upload_headers(hassio_client, aioclient_mock): - """Test that we forward the full header for snapshot upload.""" +async def test_backup_upload_headers(hassio_client, aioclient_mock, caplog): + """Test that we forward the full header for backup upload.""" content_type = "multipart/form-data; boundary='--webkit'" - aioclient_mock.get("http://127.0.0.1/snapshots/new/upload") + aioclient_mock.get("http://127.0.0.1/backups/new/upload") resp = await hassio_client.get( - "/api/hassio/snapshots/new/upload", headers={"Content-Type": content_type} + "/api/hassio/backups/new/upload", headers={"Content-Type": content_type} ) # Check we got right response @@ -150,18 +150,18 @@ async def test_snapshot_upload_headers(hassio_client, aioclient_mock): assert req_headers["Content-Type"] == content_type -async def test_snapshot_download_headers(hassio_client, aioclient_mock): - """Test that we forward the full header for snapshot download.""" +async def test_backup_download_headers(hassio_client, aioclient_mock): + """Test that we forward the full header for backup download.""" content_disposition = "attachment; filename=test.tar" aioclient_mock.get( - "http://127.0.0.1/snapshots/slug/download", + "http://127.0.0.1/backups/slug/download", headers={ "Content-Length": "50000000", "Content-Disposition": content_disposition, }, ) - resp = await hassio_client.get("/api/hassio/snapshots/slug/download") + resp = await hassio_client.get("/api/hassio/backups/slug/download") # Check we got right response assert resp.status == 200 @@ -174,9 +174,9 @@ async def test_snapshot_download_headers(hassio_client, aioclient_mock): def test_need_auth(hass): """Test if the requested path needs authentication.""" assert not _need_auth(hass, "addons/test/logo") - assert _need_auth(hass, "snapshots/new/upload") + assert _need_auth(hass, "backups/new/upload") assert _need_auth(hass, "supervisor/logs") hass.data["onboarding"] = False - assert not _need_auth(hass, "snapshots/new/upload") + assert not _need_auth(hass, "backups/new/upload") assert not _need_auth(hass, "supervisor/logs") diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 8377e5287d0..5af9908de3a 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -303,11 +303,13 @@ async def test_service_register(hassio_env, hass): assert hass.services.has_service("hassio", "host_reboot") assert hass.services.has_service("hassio", "snapshot_full") assert hass.services.has_service("hassio", "snapshot_partial") + assert hass.services.has_service("hassio", "backup_full") + assert hass.services.has_service("hassio", "backup_partial") assert hass.services.has_service("hassio", "restore_full") assert hass.services.has_service("hassio", "restore_partial") -async def test_service_calls(hassio_env, hass, aioclient_mock): +async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): """Call service and check the API calls behind that.""" assert await async_setup_component(hass, "hassio", {}) @@ -318,13 +320,13 @@ async def test_service_calls(hassio_env, hass, aioclient_mock): aioclient_mock.post("http://127.0.0.1/addons/test/stdin", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/host/shutdown", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/host/reboot", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/snapshots/new/full", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/snapshots/new/partial", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/backups/new/full", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/backups/new/partial", json={"result": "ok"}) aioclient_mock.post( - "http://127.0.0.1/snapshots/test/restore/full", json={"result": "ok"} + "http://127.0.0.1/backups/test/restore/full", json={"result": "ok"} ) aioclient_mock.post( - "http://127.0.0.1/snapshots/test/restore/partial", json={"result": "ok"} + "http://127.0.0.1/backups/test/restore/partial", json={"result": "ok"} ) await hass.services.async_call("hassio", "addon_start", {"addon": "test"}) @@ -345,27 +347,48 @@ async def test_service_calls(hassio_env, hass, aioclient_mock): assert aioclient_mock.call_count == 10 + await hass.services.async_call("hassio", "backup_full", {}) + await hass.services.async_call( + "hassio", + "backup_partial", + {"addons": ["test"], "folders": ["ssl"], "password": "123456"}, + ) await hass.services.async_call("hassio", "snapshot_full", {}) await hass.services.async_call( "hassio", "snapshot_partial", - {"addons": ["test"], "folders": ["ssl"], "password": "123456"}, + {"addons": ["test"], "folders": ["ssl"]}, ) await hass.async_block_till_done() + assert ( + "The service 'snapshot_full' is deprecated and will be removed in Home Assistant 2021.11, use 'backup_full' instead" + in caplog.text + ) + assert ( + "The service 'snapshot_partial' is deprecated and will be removed in Home Assistant 2021.11, use 'backup_partial' instead" + in caplog.text + ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[-1][2] == { + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[-3][2] == { "addons": ["test"], "folders": ["ssl"], "password": "123456", } + await hass.services.async_call("hassio", "restore_full", {"slug": "test"}) await hass.services.async_call("hassio", "restore_full", {"snapshot": "test"}) + await hass.async_block_till_done() + assert ( + "Using 'snapshot' is deprecated and will be removed in Home Assistant 2021.11, use 'slug' instead" + in caplog.text + ) + await hass.services.async_call( "hassio", "restore_partial", { - "snapshot": "test", + "slug": "test", "homeassistant": False, "addons": ["test"], "folders": ["ssl"], @@ -374,7 +397,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 14 + assert aioclient_mock.call_count == 17 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 5278d2cbb91..5578194b87c 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -61,7 +61,7 @@ async def test_websocket_supervisor_api( assert await async_setup_component(hass, "hassio", {}) websocket_client = await hass_ws_client(hass) aioclient_mock.post( - "http://127.0.0.1/snapshots/new/partial", + "http://127.0.0.1/backups/new/partial", json={"result": "ok", "data": {"slug": "sn_slug"}}, ) @@ -69,7 +69,7 @@ async def test_websocket_supervisor_api( { WS_ID: 1, WS_TYPE: WS_TYPE_API, - ATTR_ENDPOINT: "/snapshots/new/partial", + ATTR_ENDPOINT: "/backups/new/partial", ATTR_METHOD: "post", } ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 0f336e396fe..75b5ab65d38 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -171,13 +171,13 @@ def uninstall_addon_fixture(): yield uninstall_addon -@pytest.fixture(name="create_shapshot") -def create_snapshot_fixture(): - """Mock create snapshot.""" +@pytest.fixture(name="create_backup") +def create_backup_fixture(): + """Mock create backup.""" with patch( - "homeassistant.components.zwave_js.addon.async_create_snapshot" - ) as create_shapshot: - yield create_shapshot + "homeassistant.components.zwave_js.addon.async_create_backup" + ) as create_backup: + yield create_backup @pytest.fixture(name="controller_state", scope="session") diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 0b9009cd1d7..447b052b8c0 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -365,8 +365,8 @@ async def test_addon_options_changed( @pytest.mark.parametrize( - "addon_version, update_available, update_calls, snapshot_calls, " - "update_addon_side_effect, create_shapshot_side_effect", + "addon_version, update_available, update_calls, backup_calls, " + "update_addon_side_effect, create_backup_side_effect", [ ("1.0", True, 1, 1, None, None), ("1.0", False, 0, 0, None, None), @@ -380,15 +380,15 @@ async def test_update_addon( addon_info, addon_installed, addon_running, - create_shapshot, + create_backup, update_addon, addon_options, addon_version, update_available, update_calls, - snapshot_calls, + backup_calls, update_addon_side_effect, - create_shapshot_side_effect, + create_backup_side_effect, ): """Test update the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -397,7 +397,7 @@ async def test_update_addon( addon_options["network_key"] = network_key addon_info.return_value["version"] = addon_version addon_info.return_value["update_available"] = update_available - create_shapshot.side_effect = create_shapshot_side_effect + create_backup.side_effect = create_backup_side_effect update_addon.side_effect = update_addon_side_effect client.connect.side_effect = InvalidServerVersion("Invalid version") entry = MockConfigEntry( @@ -416,7 +416,7 @@ async def test_update_addon( await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY - assert create_shapshot.call_count == snapshot_calls + assert create_backup.call_count == backup_calls assert update_addon.call_count == update_calls @@ -469,7 +469,7 @@ async def test_stop_addon( async def test_remove_entry( - hass, addon_installed, stop_addon, create_shapshot, uninstall_addon, caplog + hass, addon_installed, stop_addon, create_backup, uninstall_addon, caplog ): """Test remove the config entry.""" # test successful remove without created add-on @@ -500,8 +500,8 @@ async def test_remove_entry( assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_shapshot.call_count == 1 - assert create_shapshot.call_args == call( + assert create_backup.call_count == 1 + assert create_backup.call_args == call( hass, {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, partial=True, @@ -511,7 +511,7 @@ async def test_remove_entry( assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 stop_addon.reset_mock() - create_shapshot.reset_mock() + create_backup.reset_mock() uninstall_addon.reset_mock() # test add-on stop failure @@ -523,27 +523,27 @@ async def test_remove_entry( assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_shapshot.call_count == 0 + assert create_backup.call_count == 0 assert uninstall_addon.call_count == 0 assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to stop the Z-Wave JS add-on" in caplog.text stop_addon.side_effect = None stop_addon.reset_mock() - create_shapshot.reset_mock() + create_backup.reset_mock() uninstall_addon.reset_mock() - # test create snapshot failure + # test create backup failure entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - create_shapshot.side_effect = HassioAPIError() + create_backup.side_effect = HassioAPIError() await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_shapshot.call_count == 1 - assert create_shapshot.call_args == call( + assert create_backup.call_count == 1 + assert create_backup.call_args == call( hass, {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, partial=True, @@ -551,10 +551,10 @@ async def test_remove_entry( assert uninstall_addon.call_count == 0 assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - assert "Failed to create a snapshot of the Z-Wave JS add-on" in caplog.text - create_shapshot.side_effect = None + assert "Failed to create a backup of the Z-Wave JS add-on" in caplog.text + create_backup.side_effect = None stop_addon.reset_mock() - create_shapshot.reset_mock() + create_backup.reset_mock() uninstall_addon.reset_mock() # test add-on uninstall failure @@ -566,8 +566,8 @@ async def test_remove_entry( assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_shapshot.call_count == 1 - assert create_shapshot.call_args == call( + assert create_backup.call_count == 1 + assert create_backup.call_args == call( hass, {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, partial=True, From 14db83e4c16ecf3a82fb60826229ea71b0e70e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Klomp?= Date: Mon, 2 Aug 2021 11:14:45 +0200 Subject: [PATCH 085/903] Bump pysma to 0.6.5 (#53792) --- homeassistant/components/sma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 985a0506574..a462d0c854b 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,7 +3,7 @@ "name": "SMA Solar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", - "requirements": ["pysma==0.6.4"], + "requirements": ["pysma==0.6.5"], "codeowners": ["@kellerza", "@rklomp"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index d8e4523fab6..d03b00e5032 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1760,7 +1760,7 @@ pysignalclirestapi==0.3.4 pyskyqhub==0.1.3 # homeassistant.components.sma -pysma==0.6.4 +pysma==0.6.5 # homeassistant.components.smappee pysmappee==0.2.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea0e298a96b..04c093ad9a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1002,7 +1002,7 @@ pysiaalarm==3.0.0 pysignalclirestapi==0.3.4 # homeassistant.components.sma -pysma==0.6.4 +pysma==0.6.5 # homeassistant.components.smappee pysmappee==0.2.25 From e4fe27061a3d21e03546f64fd171ab25d45dd3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 2 Aug 2021 11:50:52 +0200 Subject: [PATCH 086/903] Add STATE_CLASS_MEASUREMENT to Tibber (#53802) --- homeassistant/components/tibber/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 3ee1a3749d1..b5012cdc41d 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -77,12 +77,14 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { key="power", name="power", device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=POWER_WATT, ), "powerProduction": TibberSensorEntityDescription( key="powerProduction", name="power production", device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=POWER_WATT, ), "minPower": TibberSensorEntityDescription( From 8500afa5d99c11b2de61cae572794c48c57aeea5 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Mon, 2 Aug 2021 12:16:41 +0200 Subject: [PATCH 087/903] Activate mypy for Sony Songpal (#53655) --- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 2 files changed, 4 deletions(-) diff --git a/mypy.ini b/mypy.ini index 4b0a0ad294b..32d6e7f106c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1660,9 +1660,6 @@ ignore_errors = true [mypy-homeassistant.components.sonarr.*] ignore_errors = true -[mypy-homeassistant.components.songpal.*] -ignore_errors = true - [mypy-homeassistant.components.sonos.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 71009ccd14d..410754e57bc 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -153,7 +153,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.somfy.*", "homeassistant.components.somfy_mylink.*", "homeassistant.components.sonarr.*", - "homeassistant.components.songpal.*", "homeassistant.components.sonos.*", "homeassistant.components.spotify.*", "homeassistant.components.stt.*", From bb11dc19d3c0ca7798a74b399b139d785d33d725 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 2 Aug 2021 14:30:30 +0200 Subject: [PATCH 088/903] Convert @property to _attr_variable for hdmi_sec (#53816) Convert @property to _attr_variable. Break __init__ with a local function. Make _attr_should_poll a class variable. Co-authored-by: Martin Hjelmare --- homeassistant/components/hdmi_cec/__init__.py | 57 +++++++------------ 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 471542824de..9d4fa286fd6 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -371,13 +371,32 @@ def setup(hass: HomeAssistant, base_config): # noqa: C901 class CecEntity(Entity): """Representation of a HDMI CEC device entity.""" + _attr_should_poll = False + def __init__(self, device, logical) -> None: """Initialize the device.""" self._device = device - self._icon = None self._state: str | None = None self._logical_address = logical self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) + self._set_attr_name() + if self._device.type in ICONS_BY_TYPE: + self._attr_icon = ICONS_BY_TYPE[self._device.type] + else: + self._attr_icon = ICON_UNKNOWN + + def _set_attr_name(self): + """Set name.""" + if ( + self._device.osd_name is not None + and self.vendor_name is not None + and self.vendor_name != "Unknown" + ): + self._attr_name = f"{self.vendor_name} {self._device.osd_name}" + elif self._device.osd_name is None: + self._attr_name = f"{self._device.type_name} {self._logical_address}" + else: + self._attr_name = f"{self._device.type_name} {self._logical_address} ({self._device.osd_name})" def _hdmi_cec_unavailable(self, callback_event): # Change state to unavailable. Without this, entity would remain in @@ -412,31 +431,6 @@ class CecEntity(Entity): """Device status changed, schedule an update.""" self.schedule_update_ha_state(True) - @property - def should_poll(self): - """ - Return false. - - CecEntity.update() is called by the HDMI network when there is new data. - """ - return False - - @property - def name(self): - """Return the name of the device.""" - return ( - f"{self.vendor_name} {self._device.osd_name}" - if ( - self._device.osd_name is not None - and self.vendor_name is not None - and self.vendor_name != "Unknown" - ) - else "%s %d" % (self._device.type_name, self._logical_address) - if self._device.osd_name is None - else "%s %d (%s)" - % (self._device.type_name, self._logical_address, self._device.osd_name) - ) - @property def vendor_id(self): """Return the ID of the device's vendor.""" @@ -462,17 +456,6 @@ class CecEntity(Entity): """Return the type ID of device.""" return self._device.type - @property - def icon(self): - """Return the icon for device by its type.""" - return ( - self._icon - if self._icon is not None - else ICONS_BY_TYPE.get(self._device.type) - if self._device.type in ICONS_BY_TYPE - else ICON_UNKNOWN - ) - @property def extra_state_attributes(self): """Return the state attributes.""" From 1012d823a04f7c36c4660791ff0e7f8caee1a868 Mon Sep 17 00:00:00 2001 From: Emilv2 Date: Mon, 2 Aug 2021 14:54:33 +0200 Subject: [PATCH 089/903] Fix missing default reconnect interval in dsmr (#53760) --- homeassistant/components/dsmr/sensor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 5afc229a727..faff62ddeb4 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -157,7 +157,9 @@ async def async_setup_entry( update_entities_telegram({}) # throttle reconnect attempts - await asyncio.sleep(entry.data[CONF_RECONNECT_INTERVAL]) + await asyncio.sleep( + entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) + ) except (serial.serialutil.SerialException, OSError): # Log any error while establishing connection and drop to retry @@ -167,7 +169,9 @@ async def async_setup_entry( protocol = None # throttle reconnect attempts - await asyncio.sleep(entry.data[CONF_RECONNECT_INTERVAL]) + await asyncio.sleep( + entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) + ) except CancelledError: if stop_listener: stop_listener() # pylint: disable=not-callable From 0f8a286cc7582db56d98c8203328637d8aa32c22 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 2 Aug 2021 14:55:52 +0200 Subject: [PATCH 090/903] Allow combinations write_coil/read_coils and write_coils/read_coil for modbus switch (#53856) --- homeassistant/components/modbus/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 8936ffc32ac..16be39230db 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -198,6 +198,8 @@ BASE_SWITCH_SCHEMA = BASE_COMPONENT_SCHEMA.extend( CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_INPUT, CALL_TYPE_COIL, + CALL_TYPE_X_COILS, + CALL_TYPE_X_REGISTER_HOLDINGS, ] ), vol.Optional(CONF_STATE_OFF): cv.positive_int, From 4f8c799610eba38bb5f4cdbdac0987e8742b81cd Mon Sep 17 00:00:00 2001 From: Vinny Furia Date: Mon, 2 Aug 2021 06:57:10 -0600 Subject: [PATCH 091/903] Fix Radiothermostat hold value updates (#53656) --- homeassistant/components/radiotherm/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index aad6bf3989e..fc108af56a7 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -80,6 +80,8 @@ PRESET_MODE_TO_CODE = {"home": 0, "alternate": 1, "away": 2, "holiday": 3} CODE_TO_PRESET_MODE = {0: "home", 1: "alternate", 2: "away", 3: "holiday"} +CODE_TO_HOLD_STATE = {0: False, 1: True} + def round_temp(temperature): """Round a temperature to the resolution of the thermostat. @@ -300,6 +302,7 @@ class RadioThermostat(ClimateEntity): self._fstate = CODE_TO_FAN_STATE[data["fstate"]] self._tmode = CODE_TO_TEMP_MODE[data["tmode"]] self._tstate = CODE_TO_TEMP_STATE[data["tstate"]] + self._hold_set = CODE_TO_HOLD_STATE[data["hold"]] self._current_operation = self._tmode if self._tmode == HVAC_MODE_COOL: From e8aee5ecf7b75ca4b762498c74585027e74bd962 Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel <41558831+felipediel@users.noreply.github.com> Date: Mon, 2 Aug 2021 09:59:23 -0300 Subject: [PATCH 092/903] Fix entry setup for Broadlink SP4 sensors (#53765) --- homeassistant/components/broadlink/sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index f73f669326d..30bc8047d03 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -69,7 +69,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [ BroadlinkSensor(device, monitored_condition) for monitored_condition in sensor_data - if sensor_data[monitored_condition] != 0 or device.api.type == "A1" + if monitored_condition in SENSOR_TYPES + and ( + # These devices have optional sensors. + # We don't create entities if the value is 0. + sensor_data[monitored_condition] != 0 + or device.api.type not in {"RM4PRO", "RM4MINI"} + ) ] async_add_entities(sensors) From 4022d539fe8b650cf06a9d109929320ed021e58d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Aug 2021 15:00:43 +0200 Subject: [PATCH 093/903] Add RPi.GPIO dependency to rpi_rf integration (#53858) --- homeassistant/components/rpi_rf/manifest.json | 2 +- requirements_all.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rpi_rf/manifest.json b/homeassistant/components/rpi_rf/manifest.json index e8806710724..022c84eb13f 100644 --- a/homeassistant/components/rpi_rf/manifest.json +++ b/homeassistant/components/rpi_rf/manifest.json @@ -2,7 +2,7 @@ "domain": "rpi_rf", "name": "Raspberry Pi RF", "documentation": "https://www.home-assistant.io/integrations/rpi_rf", - "requirements": ["rpi-rf==0.9.7"], + "requirements": ["rpi-rf==0.9.7", "RPi.GPIO==0.7.1a4"], "codeowners": [], "iot_class": "assumed_state" } diff --git a/requirements_all.txt b/requirements_all.txt index d03b00e5032..99c007ca4c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -69,6 +69,7 @@ PyXiaomiGateway==0.13.4 # homeassistant.components.bmp280 # homeassistant.components.mcp23017 # homeassistant.components.rpi_gpio +# homeassistant.components.rpi_rf # RPi.GPIO==0.7.1a4 # homeassistant.components.remember_the_milk From 857031df148e745a92bbb7a86ca521a85774ce9f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 2 Aug 2021 15:07:27 +0200 Subject: [PATCH 094/903] Activate mypy for Norway_air (#53787) --- homeassistant/components/norway_air/air_quality.py | 10 +++++----- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index 480121846e9..8e829355ea0 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -92,31 +92,31 @@ class AirSensor(AirQualityEntity): """Return the name of the sensor.""" return self._name - @property + @property # type: ignore @round_state def air_quality_index(self): """Return the Air Quality Index (AQI).""" return self._api.data.get("aqi") - @property + @property # type: ignore @round_state def nitrogen_dioxide(self): """Return the NO2 (nitrogen dioxide) level.""" return self._api.data.get("no2_concentration") - @property + @property # type: ignore @round_state def ozone(self): """Return the O3 (ozone) level.""" return self._api.data.get("o3_concentration") - @property + @property # type: ignore @round_state def particulate_matter_2_5(self): """Return the particulate matter 2.5 level.""" return self._api.data.get("pm25_concentration") - @property + @property # type: ignore @round_state def particulate_matter_10(self): """Return the particulate matter 10 level.""" diff --git a/mypy.ini b/mypy.ini index 32d6e7f106c..9e87e1466bf 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1528,9 +1528,6 @@ ignore_errors = true [mypy-homeassistant.components.nmap_tracker.*] ignore_errors = true -[mypy-homeassistant.components.norway_air.*] -ignore_errors = true - [mypy-homeassistant.components.nsw_fuel_station.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 410754e57bc..09c1280c472 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -109,7 +109,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.nightscout.*", "homeassistant.components.nilu.*", "homeassistant.components.nmap_tracker.*", - "homeassistant.components.norway_air.*", "homeassistant.components.nsw_fuel_station.*", "homeassistant.components.nuki.*", "homeassistant.components.nws.*", From 65c694378460ea1f60e239579b25ce323463f00c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Aug 2021 08:08:32 -0500 Subject: [PATCH 095/903] Bump HAP-python to 4.0.0 (#53780) --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homekit/test_accessories.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 887dfc3ee37..187d730de2f 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==3.6.0", + "HAP-python==4.0.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1", diff --git a/requirements_all.txt b/requirements_all.txt index 99c007ca4c2..36082ec2e74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -14,7 +14,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==3.6.0 +HAP-python==4.0.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04c093ad9a3..e9e49a67606 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.homekit -HAP-python==3.6.0 +HAP-python==4.0.0 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 84ed61322a2..3f2c4b67497 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -576,7 +576,7 @@ def test_home_bridge(hk_driver): assert bridge.hass == "hass" assert bridge.display_name == BRIDGE_NAME assert bridge.category == 2 # Category.BRIDGE - assert len(bridge.services) == 1 + assert len(bridge.services) == 2 serv = bridge.services[0] # SERV_ACCESSORY_INFO assert serv.display_name == SERV_ACCESSORY_INFO assert serv.get_characteristic(CHAR_NAME).value == BRIDGE_NAME @@ -587,7 +587,7 @@ def test_home_bridge(hk_driver): bridge = HomeBridge("hass", hk_driver, "test_name") assert bridge.display_name == "test_name" - assert len(bridge.services) == 1 + assert len(bridge.services) == 2 serv = bridge.services[0] # SERV_ACCESSORY_INFO # setup_message From 4f96f05a755166b2d59b93135fdd4b540cd22f52 Mon Sep 17 00:00:00 2001 From: Graham Rogers Date: Mon, 2 Aug 2021 14:10:56 +0100 Subject: [PATCH 096/903] Improve Universal media player toggle default behavior (#49395) Before it could not be overridden and the default behavior meant nothing was called when all children were off, so it could not be used to turn on the media player. The new default behavior is to delegate to `turn_on` and `turn_off` instead, which is more likely to be the expected behavior. --- homeassistant/components/universal/media_player.py | 6 +++++- tests/components/universal/test_media_player.py | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index a2981852dc1..0ebd9eaf890 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -612,7 +612,11 @@ class UniversalMediaPlayer(MediaPlayerEntity): async def async_toggle(self): """Toggle the power on the media player.""" - await self._async_call_service(SERVICE_TOGGLE, allow_override=True) + if SERVICE_TOGGLE in self._cmds: + await self._async_call_service(SERVICE_TOGGLE, allow_override=True) + else: + # Delegate to turn_on or turn_off by default + await super().async_toggle() async def async_update(self): """Update state in HA.""" diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 54617a61348..737d37052c2 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -998,7 +998,8 @@ class TestMediaPlayer(unittest.TestCase): assert len(self.mock_mp_2.service_calls["shuffle_set"]) == 1 asyncio.run_coroutine_threadsafe(ump.async_toggle(), self.hass.loop).result() - assert len(self.mock_mp_2.service_calls["toggle"]) == 1 + # Delegate to turn_off + assert len(self.mock_mp_2.service_calls["turn_off"]) == 2 def test_service_call_to_command(self): """Test service call to command.""" From 8ab3d9cc12eb7370efb39c0d273724d0b51c300f Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Mon, 2 Aug 2021 15:11:41 +0200 Subject: [PATCH 097/903] Use homeassistant.const instead of integration const for device_info ATTR_ (#53703) --- homeassistant/components/aurora/__init__.py | 13 +++++++++---- homeassistant/components/aurora/const.py | 3 --- homeassistant/components/bsblan/climate.py | 12 ++++-------- homeassistant/components/bsblan/const.py | 4 ---- homeassistant/components/directv/const.py | 4 ---- homeassistant/components/directv/entity.py | 15 +++++++-------- homeassistant/components/homekit/__init__.py | 8 ++++---- homeassistant/components/homekit/accessories.py | 10 +++++----- homeassistant/components/homekit/const.py | 3 --- homeassistant/components/ipp/const.py | 4 ---- homeassistant/components/ipp/entity.py | 16 ++++++++-------- homeassistant/components/kmtronic/const.py | 2 -- .../components/modern_forms/__init__.py | 13 ++++++++++--- homeassistant/components/modern_forms/const.py | 3 --- homeassistant/components/omnilogic/common.py | 15 +++++++-------- homeassistant/components/omnilogic/const.py | 3 --- homeassistant/components/sonarr/const.py | 5 ----- homeassistant/components/sonarr/entity.py | 11 ++++++++--- .../components/yamaha_musiccast/const.py | 4 ---- tests/components/homekit/test_accessories.py | 8 ++++---- 20 files changed, 66 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index 576ccc1275b..0c80cda4bd5 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -7,7 +7,15 @@ from aiohttp import ClientError from auroranoaa import AuroraForecast from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import ( @@ -18,9 +26,6 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( ATTR_ENTRY_TYPE, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, ATTRIBUTION, AURORA_API, CONF_THRESHOLD, diff --git a/homeassistant/components/aurora/const.py b/homeassistant/components/aurora/const.py index cd6f54a3d0c..8ce6bbad3f9 100644 --- a/homeassistant/components/aurora/const.py +++ b/homeassistant/components/aurora/const.py @@ -3,9 +3,6 @@ DOMAIN = "aurora" COORDINATOR = "coordinator" AURORA_API = "aurora_api" -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" ATTR_ENTRY_TYPE = "entry_type" DEFAULT_POLLING_INTERVAL = 5 CONF_THRESHOLD = "forecast_threshold" diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 160c4f9d9b3..23259101224 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -21,6 +21,9 @@ from homeassistant.components.climate.const import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_NAME, ATTR_TEMPERATURE, TEMP_CELSIUS, @@ -29,14 +32,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_TARGET_TEMPERATURE, - DATA_BSBLAN_CLIENT, - DOMAIN, -) +from .const import ATTR_TARGET_TEMPERATURE, DATA_BSBLAN_CLIENT, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py index e65100af90d..0dc2e15a7b4 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -7,10 +7,6 @@ DATA_BSBLAN_CLIENT: Final = "bsblan_client" DATA_BSBLAN_TIMER: Final = "bsblan_timer" DATA_BSBLAN_UPDATED: Final = "bsblan_updated" -ATTR_IDENTIFIERS: Final = "identifiers" -ATTR_MODEL: Final = "model" -ATTR_MANUFACTURER: Final = "manufacturer" - ATTR_TARGET_TEMPERATURE: Final = "target_temperature" ATTR_INSIDE_TEMPERATURE: Final = "inside_temperature" ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature" diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py index 9ad01a0179b..853386fd1d8 100644 --- a/homeassistant/components/directv/const.py +++ b/homeassistant/components/directv/const.py @@ -3,14 +3,10 @@ DOMAIN = "directv" # Attributes -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_RATING = "media_rating" ATTR_MEDIA_RECORDED = "media_recorded" ATTR_MEDIA_START_TIME = "media_start_time" -ATTR_MODEL = "model" -ATTR_SOFTWARE_VERSION = "sw_version" ATTR_VIA_DEVICE = "via_device" CONF_RECEIVER_ID = "receiver_id" diff --git a/homeassistant/components/directv/entity.py b/homeassistant/components/directv/entity.py index c632ad7e84c..2e6ffb81a52 100644 --- a/homeassistant/components/directv/entity.py +++ b/homeassistant/components/directv/entity.py @@ -3,17 +3,16 @@ from __future__ import annotations from directv import DIRECTV -from homeassistant.const import ATTR_NAME -from homeassistant.helpers.entity import DeviceInfo, Entity - -from .const import ( +from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, - ATTR_SOFTWARE_VERSION, - ATTR_VIA_DEVICE, - DOMAIN, + ATTR_NAME, + ATTR_SW_VERSION, ) +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import ATTR_VIA_DEVICE, DOMAIN class DIRECTVEntity(Entity): @@ -34,6 +33,6 @@ class DIRECTVEntity(Entity): ATTR_NAME: self.name, ATTR_MANUFACTURER: self.dtv.device.info.brand, ATTR_MODEL: None, - ATTR_SOFTWARE_VERSION: self.dtv.device.info.version, + ATTR_SW_VERSION: self.dtv.device.info.version, ATTR_VIA_DEVICE: (DOMAIN, self.dtv.device.info.receiver_id), } diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index f85c8ad5063..967acaf7ddc 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -25,6 +25,9 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, ATTR_ENTITY_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, @@ -61,9 +64,6 @@ from .accessories import HomeBridge, HomeDriver, get_accessory from .aidmanager import AccessoryAidStorage from .const import ( ATTR_INTEGRATION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SOFTWARE_VERSION, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CONF_ADVERTISE_IP, @@ -886,7 +886,7 @@ class HomeKit: if dev_reg_ent.model: ent_cfg[ATTR_MODEL] = dev_reg_ent.model if dev_reg_ent.sw_version: - ent_cfg[ATTR_SOFTWARE_VERSION] = dev_reg_ent.sw_version + ent_cfg[ATTR_SW_VERSION] = dev_reg_ent.sw_version if ATTR_MANUFACTURER not in ent_cfg: try: integration = await async_get_integration( diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 03d00c42a91..c13bb870551 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -18,8 +18,11 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_SERVICE, ATTR_SUPPORTED_FEATURES, + ATTR_SW_VERSION, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, @@ -43,9 +46,6 @@ from homeassistant.util.decorator import Registry from .const import ( ATTR_DISPLAY_NAME, ATTR_INTEGRATION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SOFTWARE_VERSION, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_SERIAL_NUMBER, @@ -231,8 +231,8 @@ class HomeAccessory(Accessory): model = self.config[ATTR_MODEL] else: model = domain.title() - if ATTR_SOFTWARE_VERSION in self.config: - sw_version = format_sw_version(self.config[ATTR_SOFTWARE_VERSION]) + if ATTR_SW_VERSION in self.config: + sw_version = format_sw_version(self.config[ATTR_SW_VERSION]) else: sw_version = __version__ diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 4fecd64b2b2..d2802ec8fb0 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -22,9 +22,6 @@ AUDIO_CODEC_COPY = "copy" ATTR_DISPLAY_NAME = "display_name" ATTR_VALUE = "value" ATTR_INTEGRATION = "platform" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" -ATTR_SOFTWARE_VERSION = "sw_version" ATTR_KEY_NAME = "key_name" # Current attribute used by homekit_controller ATTR_OBSTRUCTION_DETECTED = "obstruction-detected" diff --git a/homeassistant/components/ipp/const.py b/homeassistant/components/ipp/const.py index d482f2d73e4..3501759074f 100644 --- a/homeassistant/components/ipp/const.py +++ b/homeassistant/components/ipp/const.py @@ -5,15 +5,11 @@ DOMAIN = "ipp" # Attributes ATTR_COMMAND_SET = "command_set" -ATTR_IDENTIFIERS = "identifiers" ATTR_INFO = "info" -ATTR_MANUFACTURER = "manufacturer" ATTR_MARKER_TYPE = "marker_type" ATTR_MARKER_LOW_LEVEL = "marker_low_level" ATTR_MARKER_HIGH_LEVEL = "marker_high_level" -ATTR_MODEL = "model" ATTR_SERIAL = "serial" -ATTR_SOFTWARE_VERSION = "sw_version" ATTR_STATE_MESSAGE = "state_message" ATTR_STATE_REASON = "state_reason" ATTR_URI_SUPPORTED = "uri_supported" diff --git a/homeassistant/components/ipp/entity.py b/homeassistant/components/ipp/entity.py index 0038bbd7370..55a0e76a658 100644 --- a/homeassistant/components/ipp/entity.py +++ b/homeassistant/components/ipp/entity.py @@ -1,17 +1,17 @@ """Entities for The Internet Printing Protocol (IPP) integration.""" from __future__ import annotations -from homeassistant.const import ATTR_NAME -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import ( +from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, - ATTR_SOFTWARE_VERSION, - DOMAIN, + ATTR_NAME, + ATTR_SW_VERSION, ) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN from .coordinator import IPPDataUpdateCoordinator @@ -47,5 +47,5 @@ class IPPEntity(CoordinatorEntity): ATTR_NAME: self.coordinator.data.info.name, ATTR_MANUFACTURER: self.coordinator.data.info.manufacturer, ATTR_MODEL: self.coordinator.data.info.model, - ATTR_SOFTWARE_VERSION: self.coordinator.data.info.version, + ATTR_SW_VERSION: self.coordinator.data.info.version, } diff --git a/homeassistant/components/kmtronic/const.py b/homeassistant/components/kmtronic/const.py index 8b34d423724..3bdb3074851 100644 --- a/homeassistant/components/kmtronic/const.py +++ b/homeassistant/components/kmtronic/const.py @@ -8,7 +8,5 @@ DATA_HUB = "hub" DATA_COORDINATOR = "coordinator" MANUFACTURER = "KMtronic" -ATTR_MANUFACTURER = "manufacturer" -ATTR_IDENTIFIERS = "identifiers" UPDATE_LISTENER = "update_listener" diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index a31b2655184..09ca43797af 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -17,7 +17,14 @@ 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 from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODEL, ATTR_NAME, ATTR_SW_VERSION, CONF_HOST +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, + CONF_HOST, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo @@ -27,7 +34,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, DOMAIN +from .const import DOMAIN SCAN_INTERVAL = timedelta(seconds=5) PLATFORMS = [ @@ -153,7 +160,7 @@ class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator def device_info(self) -> DeviceInfo: """Return device information about this Modern Forms device.""" return { - ATTR_IDENTIFIERS: {(DOMAIN, self.coordinator.data.info.mac_address)}, # type: ignore + ATTR_IDENTIFIERS: {(DOMAIN, self.coordinator.data.info.mac_address)}, ATTR_NAME: self.coordinator.data.info.device_name, ATTR_MANUFACTURER: "Modern Forms", ATTR_MODEL: self.coordinator.data.info.fan_type, diff --git a/homeassistant/components/modern_forms/const.py b/homeassistant/components/modern_forms/const.py index 9dbefcfc570..b96cf57351c 100644 --- a/homeassistant/components/modern_forms/const.py +++ b/homeassistant/components/modern_forms/const.py @@ -2,9 +2,6 @@ DOMAIN = "modern_forms" -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" - OPT_ON = "on" OPT_SPEED = "speed" OPT_BRIGHTNESS = "brightness" diff --git a/homeassistant/components/omnilogic/common.py b/homeassistant/components/omnilogic/common.py index a103b8d112c..798c5abd69e 100644 --- a/homeassistant/components/omnilogic/common.py +++ b/homeassistant/components/omnilogic/common.py @@ -6,7 +6,12 @@ import logging from omnilogic import OmniLogic, OmniLogicException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -14,13 +19,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import ( - ALL_ITEM_KINDS, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - DOMAIN, -) +from .const import ALL_ITEM_KINDS, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/omnilogic/const.py b/homeassistant/components/omnilogic/const.py index 41db7be5064..a00a7d7021d 100644 --- a/homeassistant/components/omnilogic/const.py +++ b/homeassistant/components/omnilogic/const.py @@ -6,9 +6,6 @@ DEFAULT_SCAN_INTERVAL = 6 DEFAULT_PH_OFFSET = 0 COORDINATOR = "coordinator" OMNI_API = "omni_api" -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" PUMP_TYPES = { "FMT_VARIABLE_SPEED_PUMP": "VARIABLE", diff --git a/homeassistant/components/sonarr/const.py b/homeassistant/components/sonarr/const.py index 45b26166c92..be0fa00d597 100644 --- a/homeassistant/components/sonarr/const.py +++ b/homeassistant/components/sonarr/const.py @@ -1,11 +1,6 @@ """Constants for Sonarr.""" DOMAIN = "sonarr" -# Attributes -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" -ATTR_SOFTWARE_VERSION = "sw_version" - # Config Keys CONF_BASE_PATH = "base_path" CONF_DAYS = "days" diff --git a/homeassistant/components/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index 3fc74b1ddb5..d3f1b089d14 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -3,10 +3,15 @@ from __future__ import annotations from sonarr import Sonarr -from homeassistant.const import ATTR_NAME +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_NAME, + ATTR_SW_VERSION, +) from homeassistant.helpers.entity import DeviceInfo, Entity -from .const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_SOFTWARE_VERSION, DOMAIN +from .const import DOMAIN class SonarrEntity(Entity): @@ -34,6 +39,6 @@ class SonarrEntity(Entity): ATTR_IDENTIFIERS: {(DOMAIN, self._device_id)}, ATTR_NAME: "Activity Sensor", ATTR_MANUFACTURER: "Sonarr", - ATTR_SOFTWARE_VERSION: self.sonarr.app.info.version, + ATTR_SW_VERSION: self.sonarr.app.info.version, "entry_type": "service", } diff --git a/homeassistant/components/yamaha_musiccast/const.py b/homeassistant/components/yamaha_musiccast/const.py index b442a3135b9..d7daaab4117 100644 --- a/homeassistant/components/yamaha_musiccast/const.py +++ b/homeassistant/components/yamaha_musiccast/const.py @@ -11,12 +11,8 @@ DOMAIN = "yamaha_musiccast" BRAND = "Yamaha Corporation" # Attributes -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" ATTR_PLAYLIST = "playlist" ATTR_PRESET = "preset" -ATTR_SOFTWARE_VERSION = "sw_version" ATTR_MC_LINK = "mc_link" ATTR_MAIN_SYNC = "main_sync" ATTR_MC_LINK_SOURCES = [ATTR_MC_LINK, ATTR_MAIN_SYNC] diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 3f2c4b67497..987a90ce61a 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -14,9 +14,6 @@ from homeassistant.components.homekit.accessories import ( from homeassistant.components.homekit.const import ( ATTR_DISPLAY_NAME, ATTR_INTEGRATION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SOFTWARE_VERSION, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_NAME, @@ -36,7 +33,10 @@ from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_SERVICE, + ATTR_SW_VERSION, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -105,7 +105,7 @@ async def test_home_accessory(hass, hk_driver): { ATTR_MODEL: "Awesome", ATTR_MANUFACTURER: "Lux Brands", - ATTR_SOFTWARE_VERSION: "0.4.3", + ATTR_SW_VERSION: "0.4.3", ATTR_INTEGRATION: "luxe", }, ) From 3296772bd39381dd35fb9c8320a735ba771170de Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 2 Aug 2021 16:02:39 +0200 Subject: [PATCH 098/903] Late review on hdmi_cec (#53763) (#53863) --- homeassistant/components/hdmi_cec/switch.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py index 2ba606c9245..3764766275e 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging from homeassistant.components.switch import DOMAIN, SwitchEntity -from homeassistant.const import STATE_OFF, STATE_ON from . import ATTR_NEW, CecEntity @@ -35,30 +34,17 @@ class CecSwitchEntity(CecEntity, SwitchEntity): def turn_on(self, **kwargs) -> None: """Turn device on.""" self._device.turn_on() - self._state = STATE_ON + self._attr_is_on = True self.schedule_update_ha_state(force_refresh=False) def turn_off(self, **kwargs) -> None: """Turn device off.""" self._device.turn_off() - self._state = STATE_OFF + self._attr_is_on = False self.schedule_update_ha_state(force_refresh=False) def toggle(self, **kwargs): """Toggle the entity.""" self._device.toggle() - if self._state == STATE_ON: - self._state = STATE_OFF - else: - self._state = STATE_ON + self._attr_is_on = not self._attr_is_on self.schedule_update_ha_state(force_refresh=False) - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._state == STATE_ON - - @property # type: ignore - def state(self) -> str | None: - """Return the cached state of device.""" - return self._state From 4241c4ca5bea487a667486ac5cc8184f6a918026 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 2 Aug 2021 16:13:54 +0200 Subject: [PATCH 099/903] Fix crash when AVM FRITZ!SmartHome devices are unreachable (#53809) --- homeassistant/components/fritzbox/__init__.py | 5 +++++ homeassistant/components/fritzbox/binary_sensor.py | 2 -- homeassistant/components/fritzbox/climate.py | 5 ----- homeassistant/components/fritzbox/sensor.py | 8 ++++++-- homeassistant/components/fritzbox/switch.py | 5 ----- tests/components/fritzbox/test_binary_sensor.py | 4 ++-- 6 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 087faeb2be9..cef325a61f3 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -143,6 +143,11 @@ class FritzBoxEntity(CoordinatorEntity): self._device_class = entity_info[ATTR_DEVICE_CLASS] self._attr_state_class = entity_info[ATTR_STATE_CLASS] + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.device.present + @property def device(self) -> FritzhomeDevice: """Return device object from coordinator.""" diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index f6dbaed97cf..5514408cb3c 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -54,6 +54,4 @@ class FritzboxBinarySensor(FritzBoxEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if sensor is on.""" - if not self.device.present: - return False return self.device.alert_state # type: ignore [no-any-return] diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 0551c5e0455..4baa1b3b81a 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -93,11 +93,6 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity): """Return the list of supported features.""" return SUPPORT_FLAGS - @property - def available(self) -> bool: - """Return if thermostat is available.""" - return self.device.present # type: ignore [no-any-return] - @property def temperature_unit(self) -> str: """Return the unit of measurement that is used.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index d325e592faf..9d78afca4de 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -121,7 +121,9 @@ class FritzBoxPowerSensor(FritzBoxEntity, SensorEntity): @property def state(self) -> float | None: """Return the state of the sensor.""" - return self.device.power / 1000 # type: ignore [no-any-return] + if power := self.device.power: + return power / 1000 # type: ignore [no-any-return] + return 0.0 class FritzBoxEnergySensor(FritzBoxEntity, SensorEntity): @@ -130,7 +132,9 @@ class FritzBoxEnergySensor(FritzBoxEntity, SensorEntity): @property def state(self) -> float | None: """Return the state of the sensor.""" - return (self.device.energy or 0.0) / 1000 + if energy := self.device.energy: + return energy / 1000 # type: ignore [no-any-return] + return 0.0 @property def last_reset(self) -> datetime: diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 62d1cb1ecf8..133db92feda 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -56,11 +56,6 @@ async def async_setup_entry( class FritzboxSwitch(FritzBoxEntity, SwitchEntity): """The switch class for FRITZ!SmartHome switches.""" - @property - def available(self) -> bool: - """Return if switch is available.""" - return self.device.present # type: ignore [no-any-return] - @property def is_on(self) -> bool: """Return true if the switch is on.""" diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index f4e32fbe3df..cb76109e0ff 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -14,8 +14,8 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, PERCENTAGE, - STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -60,7 +60,7 @@ async def test_is_off(hass: HomeAssistant, fritz: Mock): state = hass.states.get(ENTITY_ID) assert state - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE async def test_update(hass: HomeAssistant, fritz: Mock): From f3d4dac11f8b93a62272f7c0a69c26511660a8f6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Aug 2021 16:33:13 +0200 Subject: [PATCH 100/903] Fix cloud accountlinking replacing token data (#53865) --- homeassistant/components/cloud/account_link.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index df93ca6a6ab..5ad7ddcffed 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -143,6 +143,7 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement async def _async_refresh_token(self, token: dict) -> dict: """Refresh a token.""" - return await account_link.async_fetch_access_token( + new_token = await account_link.async_fetch_access_token( self.hass.data[DOMAIN], self.service, token["refresh_token"] ) + return {**token, **new_token} From d722d13b0e7efa7cc2fd112961321d15afc43b63 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Aug 2021 16:33:27 +0200 Subject: [PATCH 101/903] Add measurement state class to ZHA power devices (#53866) --- homeassistant/components/zha/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index b9a86b79063..3c3aba919ed 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -214,6 +214,7 @@ class ElectricalMeasurement(Sensor): SENSOR_ATTR = "active_power" _device_class = DEVICE_CLASS_POWER + _state_class = STATE_CLASS_MEASUREMENT _unit = POWER_WATT @property From 1265aa0f64218d768e59335d30151a733a1b113c Mon Sep 17 00:00:00 2001 From: pdcemulator <20071350+pdcemulator@users.noreply.github.com> Date: Mon, 2 Aug 2021 16:58:45 +0200 Subject: [PATCH 102/903] Add edl21 OBIS IDs for DZG DWS76 (#53029) --- homeassistant/components/edl21/sensor.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index d2d0d375733..a00f77efa0b 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -59,6 +59,7 @@ class EDL21: "1-0:0.0.9*255": "Electricity ID", # D=2: Program entries "1-0:0.2.0*0": "Configuration program version number", + "1-0:0.2.0*1": "Firmware version number", # C=1: Active power + # D=8: Time integral 1 # E=0: Total @@ -94,6 +95,10 @@ class EDL21: # D=7: Instantaneous value # E=0: Total "1-0:31.7.0*255": "L1 active instantaneous amperage", + # C=32: Active voltage L1 + # D=7: Instantaneous value + # E=0: Total + "1-0:32.7.0*255": "L1 active instantaneous voltage", # C=36: Active power L1 # D=7: Instantaneous value # E=0: Total @@ -102,6 +107,10 @@ class EDL21: # D=7: Instantaneous value # E=0: Total "1-0:51.7.0*255": "L2 active instantaneous amperage", + # C=52: Active voltage L2 + # D=7: Instantaneous value + # E=0: Total + "1-0:52.7.0*255": "L2 active instantaneous voltage", # C=56: Active power L2 # D=7: Instantaneous value # E=0: Total @@ -110,13 +119,21 @@ class EDL21: # D=7: Instantaneous value # E=0: Total "1-0:71.7.0*255": "L3 active instantaneous amperage", + # C=72: Active voltage L3 + # D=7: Instantaneous value + # E=0: Total + "1-0:72.7.0*255": "L3 active instantaneous voltage", # C=76: Active power L3 # D=7: Instantaneous value # E=0: Total "1-0:76.7.0*255": "L3 active instantaneous power", # C=81: Angles # D=7: Instantaneous value + # E=4: U(L1) x I(L1) + # E=15: U(L2) x I(L2) # E=26: U(L3) x I(L3) + "1-0:81.7.4*255": "U(L1)/I(L1) phase angle", + "1-0:81.7.15*255": "U(L2)/I(L2) phase angle", "1-0:81.7.26*255": "U(L3)/I(L3) phase angle", # C=96: Electricity-related service entries "1-0:96.1.0*255": "Metering point ID 1", @@ -126,6 +143,7 @@ class EDL21: # C=96: Electricity-related service entries "1-0:96.50.1*1", # Manufacturer specific "1-0:96.90.2*1", # Manufacturer specific + "1-0:96.90.2*2", # Manufacturer specific # A=129: Manufacturer specific "129-129:199.130.3*255", # Iskraemeco: Manufacturer "129-129:199.130.5*255", # Iskraemeco: Public Key From bffa9f960dac0130f26d98fc16fce67341eedf30 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 2 Aug 2021 17:00:25 +0200 Subject: [PATCH 103/903] Add state class measurement to all suitable sensors on Speedtest.net (#53693) * Add state class measurement * use tuple instead of list --- .../components/speedtestdotnet/const.py | 31 ++++++++++++++++--- .../components/speedtestdotnet/sensor.py | 29 +++++++++-------- .../components/speedtestdotnet/test_sensor.py | 8 ++--- 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index 04f3ea0cc55..897ffa126fa 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -1,17 +1,38 @@ """Consts used by Speedtest.net.""" +from __future__ import annotations + from typing import Final +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND, TIME_MILLISECONDS DOMAIN: Final = "speedtestdotnet" SPEED_TEST_SERVICE: Final = "speedtest" -SENSOR_TYPES: Final = { - "ping": ["Ping", TIME_MILLISECONDS], - "download": ["Download", DATA_RATE_MEGABITS_PER_SECOND], - "upload": ["Upload", DATA_RATE_MEGABITS_PER_SECOND], -} +SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + key="ping", + name="Ping", + unit_of_measurement=TIME_MILLISECONDS, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="download", + name="Download", + unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="upload", + name="Upload", + unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + state_class=STATE_CLASS_MEASUREMENT, + ), +) CONF_SERVER_NAME: Final = "server_name" CONF_SERVER_ID: Final = "server_id" diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 8dcc5bc3459..1c6c80a6af1 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.components.speedtestdotnet import SpeedTestDataCoordinator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION @@ -34,8 +34,8 @@ async def async_setup_entry( """Set up the Speedtestdotnet sensors.""" speedtest_coordinator = hass.data[DOMAIN] async_add_entities( - SpeedtestSensor(speedtest_coordinator, sensor_type) - for sensor_type in SENSOR_TYPES + SpeedtestSensor(speedtest_coordinator, description) + for description in SENSOR_TYPES ) @@ -46,14 +46,17 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): _attr_icon = ICON - def __init__(self, coordinator: SpeedTestDataCoordinator, sensor_type: str) -> None: + def __init__( + self, + coordinator: SpeedTestDataCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self.type = sensor_type + self.entity_description = description - self._attr_name = f"{DEFAULT_NAME} {SENSOR_TYPES[sensor_type][0]}" - self._attr_unit_of_measurement = SENSOR_TYPES[self.type][1] - self._attr_unique_id = sensor_type + self._attr_name = f"{DEFAULT_NAME} {description.name}" + self._attr_unique_id = description.key self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} @property @@ -68,11 +71,11 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): } ) - if self.type == "download": + if self.entity_description.key == "download": self._attrs[ATTR_BYTES_RECEIVED] = self.coordinator.data[ "bytes_received" ] - elif self.type == "upload": + elif self.entity_description.key == "upload": self._attrs[ATTR_BYTES_SENT] = self.coordinator.data["bytes_sent"] return self._attrs @@ -96,9 +99,9 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): def _update_state(self): """Update sensors state.""" if self.coordinator.data: - if self.type == "ping": + if self.entity_description.key == "ping": self._attr_state = self.coordinator.data["ping"] - elif self.type == "download": + elif self.entity_description.key == "download": self._attr_state = round(self.coordinator.data["download"] / 10 ** 6, 2) - elif self.type == "upload": + elif self.entity_description.key == "upload": self._attr_state = round(self.coordinator.data["upload"] / 10 ** 6, 2) diff --git a/tests/components/speedtestdotnet/test_sensor.py b/tests/components/speedtestdotnet/test_sensor.py index 11db05d2994..d0378731c28 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -26,9 +26,7 @@ async def test_speedtestdotnet_sensors( assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 - for sensor_type in SENSOR_TYPES: - sensor = hass.states.get( - f"sensor.{DEFAULT_NAME}_{SENSOR_TYPES[sensor_type][0]}" - ) + for description in SENSOR_TYPES: + sensor = hass.states.get(f"sensor.{DEFAULT_NAME}_{description.name}") assert sensor - assert sensor.state == MOCK_STATES[sensor_type] + assert sensor.state == MOCK_STATES[description.key] From 1c38e9168c61145155526f80f39533562fa2f5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 2 Aug 2021 18:46:07 +0200 Subject: [PATCH 104/903] Add base energy analytics (#53855) --- .../components/analytics/analytics.py | 11 +++ homeassistant/components/analytics/const.py | 2 + .../components/analytics/manifest.json | 15 +++- homeassistant/components/energy/__init__.py | 7 ++ tests/components/analytics/test_analytics.py | 69 +++++++++++++++++++ tests/components/energy/test_websocket_api.py | 6 +- 6 files changed, 106 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 42630ad2df1..37aff988162 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -8,6 +8,10 @@ import async_timeout from homeassistant.components import hassio from homeassistant.components.api import ATTR_INSTALLATION_TYPE from homeassistant.components.automation.const import DOMAIN as AUTOMATION_DOMAIN +from homeassistant.components.energy import ( + DOMAIN as ENERGY_DOMAIN, + is_configured as energy_is_configured, +) from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -26,8 +30,10 @@ from .const import ( ATTR_AUTOMATION_COUNT, ATTR_BASE, ATTR_BOARD, + ATTR_CONFIGURED, ATTR_CUSTOM_INTEGRATIONS, ATTR_DIAGNOSTICS, + ATTR_ENERGY, ATTR_HEALTHY, ATTR_INTEGRATION_COUNT, ATTR_INTEGRATIONS, @@ -222,6 +228,11 @@ class Analytics: if supervisor_info is not None: payload[ATTR_ADDONS] = addons + if ENERGY_DOMAIN in integrations: + payload[ATTR_ENERGY] = { + ATTR_CONFIGURED: await energy_is_configured(self.hass) + } + if self.preferences.get(ATTR_STATISTICS, False): payload[ATTR_STATE_COUNT] = len(self.hass.states.async_all()) payload[ATTR_AUTOMATION_COUNT] = len( diff --git a/homeassistant/components/analytics/const.py b/homeassistant/components/analytics/const.py index 4688c578a00..8576e22073f 100644 --- a/homeassistant/components/analytics/const.py +++ b/homeassistant/components/analytics/const.py @@ -21,8 +21,10 @@ ATTR_AUTO_UPDATE = "auto_update" ATTR_AUTOMATION_COUNT = "automation_count" ATTR_BASE = "base" ATTR_BOARD = "board" +ATTR_CONFIGURED = "configured" ATTR_CUSTOM_INTEGRATIONS = "custom_integrations" ATTR_DIAGNOSTICS = "diagnostics" +ATTR_ENERGY = "energy" ATTR_HEALTHY = "healthy" ATTR_INSTALLATION_TYPE = "installation_type" ATTR_INTEGRATION_COUNT = "integration_count" diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json index 49edf1bcf8c..2dae8d4e629 100644 --- a/homeassistant/components/analytics/manifest.json +++ b/homeassistant/components/analytics/manifest.json @@ -2,8 +2,17 @@ "domain": "analytics", "name": "Analytics", "documentation": "https://www.home-assistant.io/integrations/analytics", - "codeowners": ["@home-assistant/core", "@ludeeus"], - "dependencies": ["api", "websocket_api"], + "codeowners": [ + "@home-assistant/core", + "@ludeeus" + ], + "dependencies": [ + "api", + "websocket_api" + ], + "after_dependencies": [ + "energy" + ], "quality_scale": "internal", "iot_class": "cloud_push" -} +} \ No newline at end of file diff --git a/homeassistant/components/energy/__init__.py b/homeassistant/components/energy/__init__.py index 1e060c1f35b..c856ffb1541 100644 --- a/homeassistant/components/energy/__init__.py +++ b/homeassistant/components/energy/__init__.py @@ -8,6 +8,13 @@ from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN +from .data import async_get_manager + + +async def is_configured(hass: HomeAssistant) -> bool: + """Return a boolean to indicate if energy is configured.""" + manager = await async_get_manager(hass) + return bool(manager.data != manager.default_preferences()) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index ee67a7e3935..a781cb4c662 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -424,3 +424,72 @@ async def test_nightly_endpoint(hass, aioclient_mock): payload = aioclient_mock.mock_calls[0] assert str(payload[1]) == ANALYTICS_ENDPOINT_URL + + +async def test_send_with_no_energy(hass, aioclient_mock): + """Test send base prefrences are defined.""" + aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + analytics = Analytics(hass) + + await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex, patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.components.analytics.analytics.energy_is_configured", AsyncMock() + ) as energy_is_configured: + energy_is_configured.return_value = False + hex.return_value = MOCK_UUID + await analytics.send_analytics() + + postdata = aioclient_mock.mock_calls[-1][2] + + assert "energy" not in postdata + + +async def test_send_with_no_energy_config(hass, aioclient_mock): + """Test send base prefrences are defined.""" + aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + analytics = Analytics(hass) + + await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) + assert await async_setup_component( + hass, "energy", {"recorder": {"db_url": "sqlite://"}} + ) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex, patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.components.analytics.analytics.energy_is_configured", AsyncMock() + ) as energy_is_configured: + energy_is_configured.return_value = False + hex.return_value = MOCK_UUID + await analytics.send_analytics() + + postdata = aioclient_mock.mock_calls[-1][2] + + assert not postdata["energy"]["configured"] + + +async def test_send_with_energy_config(hass, aioclient_mock): + """Test send base prefrences are defined.""" + aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + analytics = Analytics(hass) + + await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) + assert await async_setup_component( + hass, "energy", {"recorder": {"db_url": "sqlite://"}} + ) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex, patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.components.analytics.analytics.energy_is_configured", AsyncMock() + ) as energy_is_configured: + energy_is_configured.return_value = True + hex.return_value = MOCK_UUID + await analytics.send_analytics() + + postdata = aioclient_mock.mock_calls[-1][2] + + assert postdata["energy"]["configured"] diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 80ede3a7548..92e6cf3a5b5 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -1,7 +1,7 @@ """Test the Energy websocket API.""" import pytest -from homeassistant.components.energy import data +from homeassistant.components.energy import data, is_configured from homeassistant.setup import async_setup_component from tests.common import flush_store @@ -34,6 +34,8 @@ async def test_get_preferences_default(hass, hass_ws_client, hass_storage) -> No manager.data = data.EnergyManager.default_preferences() client = await hass_ws_client(hass) + assert not await is_configured(hass) + await client.send_json({"id": 5, "type": "energy/get_prefs"}) msg = await client.receive_json() @@ -119,6 +121,8 @@ async def test_save_preferences(hass, hass_ws_client, hass_storage) -> None: assert hass_storage[data.STORAGE_KEY]["data"] == new_prefs + assert await is_configured(hass) + # Verify info reflects data. await client.send_json({"id": 7, "type": "energy/info"}) From b2725918b1e95624d12e9d7ea860c3a8fd23df87 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 2 Aug 2021 18:47:54 +0200 Subject: [PATCH 105/903] Fix TP-Link smart strip devices (#53799) --- homeassistant/components/tplink/__init__.py | 4 +- homeassistant/components/tplink/sensor.py | 4 +- homeassistant/components/tplink/switch.py | 2 +- tests/components/tplink/consts.py | 55 ++++++++++++++------- tests/components/tplink/test_init.py | 23 +++------ 5 files changed, 48 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 88160722669..552e5666db8 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -180,9 +180,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: continue hass_data[COORDINATORS][ - switch.mac + switch.context or switch.mac ] = coordinator = SmartPlugDataUpdateCoordinator(hass, switch) - await coordinator.async_config_entry_first_refresh() if unavailable_devices: @@ -275,4 +274,5 @@ class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): except SmartDeviceException as ex: raise UpdateFailed(ex) from ex + self.name = data[CONF_ALIAS] return data diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index bb6596b82d1..697641915f7 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -101,7 +101,9 @@ async def async_setup_entry( ] switches: list[SmartPlug] = hass.data[TPLINK_DOMAIN][CONF_SWITCH] for switch in switches: - coordinator: SmartPlugDataUpdateCoordinator = coordinators[switch.mac] + coordinator: SmartPlugDataUpdateCoordinator = coordinators[ + switch.context or switch.mac + ] if not switch.has_emeter and coordinator.data.get(CONF_EMETER_PARAMS) is None: continue for description in ENERGY_SENSORS: diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index f5319de999a..10cf5c64d75 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -39,7 +39,7 @@ async def async_setup_entry( ] switches: list[SmartPlug] = hass.data[TPLINK_DOMAIN][CONF_SWITCH] for switch in switches: - coordinator = coordinators[switch.mac] + coordinator = coordinators[switch.context or switch.mac] entities.append(SmartPlugSwitch(switch, coordinator)) async_add_entities(entities) diff --git a/tests/components/tplink/consts.py b/tests/components/tplink/consts.py index 95177a12a9c..e579be61df2 100644 --- a/tests/components/tplink/consts.py +++ b/tests/components/tplink/consts.py @@ -61,31 +61,48 @@ SMARTPLUG_HS100_DATA = { "err_code": 0, } } -SMARTSTRIPWITCH_DATA = { +SMARTSTRIP_KP303_DATA = { "sysinfo": { - "sw_ver": "1.0.4 Build 191111 Rel.143500", - "hw_ver": "4.0", - "model": "HS110(EU)", - "deviceId": "4C56447B395BB7A2FAC68C9DFEE2E84163222581", - "oemId": "40F54B43071E9436B6395611E9D91CEA", - "hwId": "A6C77E4FDD238B53D824AC8DA361F043", - "rssi": -24, - "longitude_i": 130793, - "latitude_i": 480582, - "alias": "SmartPlug", + "sw_ver": "1.0.4 Build 210428 Rel.135415", + "hw_ver": "1.0", + "model": "KP303(AU)", + "deviceId": "03102547AB1A57A4E4AA5B4EFE34C3005726B97D", + "oemId": "1F950FC9BFF278D9D35E046C129D9411", + "hwId": "9E86D4F840D2787D3D7A6523A731BA2C", + "rssi": -74, + "longitude_i": 1158985, + "latitude_i": -319172, + "alias": "TP-LINK_Power Strip_00B1", "status": "new", "mic_type": "IOT.SMARTPLUGSWITCH", "feature": "TIM", - "mac": "69:F2:3C:8E:E3:47", + "mac": "D4:DD:D6:95:B0:F9", "updating": 0, "led_off": 0, - "relay_state": 0, - "on_time": 0, - "active_mode": "none", - "icon_hash": "", - "dev_name": "Smart Wi-Fi Plug With Energy Monitoring", - "next_action": {"type": -1}, - "children": [{"id": "1", "state": 1, "alias": "SmartPlug#1"}], + "children": [ + { + "id": "8006B399B7FE68D4E6991CCCEA239C081DFA913000", + "state": 0, + "alias": "R-Plug 1", + "on_time": 0, + "next_action": {"type": -1}, + }, + { + "id": "8006B399B7FE68D4E6991CCCEA239C081DFA913001", + "state": 1, + "alias": "R-Plug 2", + "on_time": 93835, + "next_action": {"type": -1}, + }, + { + "id": "8006B399B7FE68D4E6991CCCEA239C081DFA913002", + "state": 1, + "alias": "R-Plug 3", + "on_time": 93834, + "next_action": {"type": -1}, + }, + ], + "child_num": 3, "err_code": 0, }, "realtime": { diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index a201788f35b..d96d6846939 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -20,11 +20,10 @@ from homeassistant.components.tplink.const import ( CONF_LIGHT, CONF_SW_VERSION, CONF_SWITCH, - COORDINATORS, UNAVAILABLE_RETRY_DELAY, ) from homeassistant.components.tplink.sensor import ENERGY_SENSORS -from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_HOST +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -34,7 +33,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, mock_coro from tests.components.tplink.consts import ( SMARTPLUG_HS100_DATA, SMARTPLUG_HS110_DATA, - SMARTSTRIPWITCH_DATA, + SMARTSTRIP_KP303_DATA, ) @@ -307,7 +306,7 @@ async def test_smartstrip_device(hass: HomeAssistant): """Moked SmartStrip class.""" def get_sysinfo(self): - return SMARTSTRIPWITCH_DATA["sysinfo"] + return SMARTSTRIP_KP303_DATA["sysinfo"] with patch( "homeassistant.components.tplink.common.Discover.discover" @@ -315,9 +314,7 @@ async def test_smartstrip_device(hass: HomeAssistant): "homeassistant.components.tplink.common.SmartDevice._query_helper" ), patch( "homeassistant.components.tplink.common.SmartPlug.get_sysinfo", - return_value=SMARTSTRIPWITCH_DATA["sysinfo"], - ), patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" + return_value=SMARTSTRIP_KP303_DATA["sysinfo"], ): strip = SmartStrip("123.123.123.123") @@ -326,16 +323,8 @@ async def test_smartstrip_device(hass: HomeAssistant): assert await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() - assert hass.data.get(tplink.DOMAIN) - assert hass.data[tplink.DOMAIN].get(COORDINATORS) - assert hass.data[tplink.DOMAIN][COORDINATORS].get(strip.mac) - assert isinstance( - hass.data[tplink.DOMAIN][COORDINATORS][strip.mac], - tplink.SmartPlugDataUpdateCoordinator, - ) - data = hass.data[tplink.DOMAIN][COORDINATORS][strip.mac].data - assert data[CONF_ALIAS] == strip.sys_info["children"][0]["alias"] - assert data[CONF_DEVICE_ID] == "1" + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 3 async def test_no_config_creates_no_entry(hass): From 3bf06de3633a58b8cc4e49e0b6e43e19086410f3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Aug 2021 18:48:17 +0200 Subject: [PATCH 106/903] Fix growat server config entry missing URL key (#53867) --- homeassistant/components/growatt_server/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 9d0aa098051..fe6bdeb70e8 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -598,7 +598,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config = config_entry.data username = config[CONF_USERNAME] password = config[CONF_PASSWORD] - url = config[CONF_URL] + url = config.get(CONF_URL, DEFAULT_URL) name = config[CONF_NAME] api = growattServer.GrowattApi() From c3d623a37e4a1546726054b4ecf33cf63e5193a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 2 Aug 2021 19:35:11 +0200 Subject: [PATCH 107/903] Fix issue when data is None (#53875) --- homeassistant/components/energy/__init__.py | 2 ++ tests/components/energy/test_websocket_api.py | 1 + 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/energy/__init__.py b/homeassistant/components/energy/__init__.py index c856ffb1541..30a1bf8e877 100644 --- a/homeassistant/components/energy/__init__.py +++ b/homeassistant/components/energy/__init__.py @@ -14,6 +14,8 @@ from .data import async_get_manager async def is_configured(hass: HomeAssistant) -> bool: """Return a boolean to indicate if energy is configured.""" manager = await async_get_manager(hass) + if manager.data is None: + return False return bool(manager.data != manager.default_preferences()) diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 92e6cf3a5b5..a14a8d0986e 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -30,6 +30,7 @@ async def test_get_preferences_no_data(hass, hass_ws_client) -> None: async def test_get_preferences_default(hass, hass_ws_client, hass_storage) -> None: """Test we get preferences.""" + assert not await is_configured(hass) manager = await data.async_get_manager(hass) manager.data = data.EnergyManager.default_preferences() client = await hass_ws_client(hass) From 80a8f35a4267b9a12eb930f1f35538d5da33e003 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Aug 2021 10:42:04 -0700 Subject: [PATCH 108/903] Bump frontend to 20210802.0 (#53876) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 84de9b92c97..f1dfa75864d 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210801.0" + "home-assistant-frontend==20210802.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index db6ffd1d071..983831baaa0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210801.0 +home-assistant-frontend==20210802.0 httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 36082ec2e74..1a1e9890b98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -783,7 +783,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210801.0 +home-assistant-frontend==20210802.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9e49a67606..698583861b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -449,7 +449,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210801.0 +home-assistant-frontend==20210802.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From d414b58769a7ed2bb1fa95fe64ee58d534946114 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 2 Aug 2021 18:47:11 +0100 Subject: [PATCH 109/903] Fix watts unit for homekit_controller power sensors (#53877) --- homeassistant/components/homekit_controller/sensor.py | 7 ++++--- .../specific_devices/test_koogeek_p1eu.py | 2 ++ .../specific_devices/test_koogeek_sw2.py | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 6bf8a7fc084..91b62b0d572 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, LIGHT_LUX, PERCENTAGE, + POWER_WATT, TEMP_CELSIUS, ) from homeassistant.core import callback @@ -29,19 +30,19 @@ SIMPLE_SENSOR = { "name": "Real Time Energy", "device_class": DEVICE_CLASS_POWER, "state_class": STATE_CLASS_MEASUREMENT, - "unit": "watts", + "unit": POWER_WATT, }, CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: { "name": "Real Time Energy", "device_class": DEVICE_CLASS_POWER, "state_class": STATE_CLASS_MEASUREMENT, - "unit": "watts", + "unit": POWER_WATT, }, CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: { "name": "Real Time Energy", "device_class": DEVICE_CLASS_POWER, "state_class": STATE_CLASS_MEASUREMENT, - "unit": "watts", + "unit": POWER_WATT, }, CharacteristicsTypes.get_uuid(CharacteristicsTypes.TEMPERATURE_CURRENT): { "name": "Current Temperature", diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py index 6302013223d..1761edb3c8c 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py @@ -1,5 +1,6 @@ """Make sure that existing Koogeek P1EU support isn't broken.""" +from homeassistant.const import POWER_WATT from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.components.homekit_controller.common import ( @@ -48,6 +49,7 @@ async def test_koogeek_p1eu_setup(hass): ) state = await helper.poll_and_get_state() assert state.attributes["friendly_name"] == "Koogeek-P1-A00AA0 - Real Time Energy" + assert state.attributes["unit_of_measurement"] == POWER_WATT # The sensor and switch should be part of the same device assert entry.device_id == device.id diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py index 1d46b633153..768959e0331 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py @@ -6,6 +6,7 @@ This Koogeek device has a custom power sensor that extra handling. It should have 2 entities - the actual switch and a sensor for power usage. """ +from homeassistant.const import POWER_WATT from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.components.homekit_controller.common import ( @@ -58,6 +59,7 @@ async def test_koogeek_ls1_setup(hass): # Assert that the friendly name is detected correctly assert state.attributes["friendly_name"] == "Koogeek-SW2-187A91 - Real Time Energy" + assert state.attributes["unit_of_measurement"] == POWER_WATT device_registry = dr.async_get(hass) From d4cb819e1facf3baab8fc953c5bc950b87290fb1 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 2 Aug 2021 22:07:37 +0200 Subject: [PATCH 110/903] ESPHome implement light color modes (#53854) --- homeassistant/components/esphome/light.py | 230 ++++++++++++++---- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 190 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index ba968900ef0..b89a75ab76a 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -1,35 +1,45 @@ """Support for ESPHome lights.""" from __future__ import annotations -from typing import Any +from typing import Any, cast -from aioesphomeapi import LightInfo, LightState +from aioesphomeapi import APIVersion, LightColorMode, LightInfo, LightState from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, - ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_TRANSITION, - ATTR_WHITE_VALUE, + ATTR_WHITE, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_ONOFF, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, + COLOR_MODE_UNKNOWN, + COLOR_MODE_WHITE, FLASH_LONG, FLASH_SHORT, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, - SUPPORT_WHITE_VALUE, LightEntity, ) 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 . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from . import ( + EsphomeEntity, + EsphomeEnumMapper, + esphome_state_property, + platform_async_setup_entry, +) FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -49,6 +59,22 @@ async def async_setup_entry( ) +_COLOR_MODES: EsphomeEnumMapper[LightColorMode, str] = EsphomeEnumMapper( + { + LightColorMode.UNKNOWN: COLOR_MODE_UNKNOWN, + LightColorMode.ON_OFF: COLOR_MODE_ONOFF, + LightColorMode.BRIGHTNESS: COLOR_MODE_BRIGHTNESS, + LightColorMode.WHITE: COLOR_MODE_WHITE, + LightColorMode.COLOR_TEMPERATURE: COLOR_MODE_COLOR_TEMP, + LightColorMode.COLD_WARM_WHITE: COLOR_MODE_COLOR_TEMP, + LightColorMode.RGB: COLOR_MODE_RGB, + LightColorMode.RGB_WHITE: COLOR_MODE_RGBW, + LightColorMode.RGB_COLOR_TEMPERATURE: COLOR_MODE_RGBWW, + LightColorMode.RGB_COLD_WARM_WHITE: COLOR_MODE_RGBWW, + } +) + + # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property # pylint: disable=invalid-overridden-method @@ -56,6 +82,11 @@ async def async_setup_entry( class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """A light implementation for ESPHome.""" + @property + def _supports_color_mode(self) -> bool: + """Return whether the client supports the new color mode system natively.""" + return self._api_version >= APIVersion(1, 6) + @esphome_state_property def is_on(self) -> bool | None: # type: ignore[override] """Return true if the light is on.""" @@ -64,22 +95,80 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" data: dict[str, Any] = {"key": self._static_info.key, "state": True} - if ATTR_HS_COLOR in kwargs: - hue, sat = kwargs[ATTR_HS_COLOR] - red, green, blue = color_util.color_hsv_to_RGB(hue, sat, 100) - data["rgb"] = (red / 255, green / 255, blue / 255) - if ATTR_FLASH in kwargs: - data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] - if ATTR_TRANSITION in kwargs: - data["transition_length"] = kwargs[ATTR_TRANSITION] - if ATTR_BRIGHTNESS in kwargs: - data["brightness"] = kwargs[ATTR_BRIGHTNESS] / 255 - if ATTR_COLOR_TEMP in kwargs: - data["color_temperature"] = kwargs[ATTR_COLOR_TEMP] - if ATTR_EFFECT in kwargs: - data["effect"] = kwargs[ATTR_EFFECT] - if ATTR_WHITE_VALUE in kwargs: - data["white"] = kwargs[ATTR_WHITE_VALUE] / 255 + # rgb/brightness input is in range 0-255, but esphome uses 0-1 + + if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None: + data["brightness"] = brightness_ha / 255 + + if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None: + rgb = tuple(x / 255 for x in rgb_ha) + color_bri = max(rgb) + # normalize rgb + data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) + if self._supports_color_mode: + data["color_brightness"] = color_bri + data["color_mode"] = LightColorMode.RGB + + if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: + # pylint: disable=invalid-name + *rgb, w = tuple(x / 255 for x in rgbw_ha) # type: ignore[assignment] + color_bri = max(rgb) + # normalize rgb + data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) + data["white"] = w + if self._supports_color_mode: + data["color_brightness"] = color_bri + data["color_mode"] = LightColorMode.RGB_WHITE + + if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: + # pylint: disable=invalid-name + *rgb, cw, ww = tuple(x / 255 for x in rgbww_ha) # type: ignore[assignment] + color_bri = max(rgb) + # normalize rgb + data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) + modes = self._native_supported_color_modes + if ( + self._supports_color_mode + and LightColorMode.RGB_COLD_WARM_WHITE in modes + ): + data["cold_white"] = cw + data["warm_white"] = ww + target_mode = LightColorMode.RGB_COLD_WARM_WHITE + else: + # need to convert cw+ww part to white+color_temp + white = data["white"] = max(cw, ww) + if white != 0: + min_ct = self.min_mireds + max_ct = self.max_mireds + ct_ratio = ww / (cw + ww) + data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) + target_mode = LightColorMode.RGB_COLOR_TEMPERATURE + + if self._supports_color_mode: + data["color_brightness"] = color_bri + data["color_mode"] = target_mode + + if (flash := kwargs.get(ATTR_FLASH)) is not None: + data["flash_length"] = FLASH_LENGTHS[flash] + + if (transition := kwargs.get(ATTR_TRANSITION)) is not None: + data["transition_length"] = transition + + if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: + data["color_temperature"] = color_temp + if self._supports_color_mode: + data["color_mode"] = LightColorMode.COLOR_TEMPERATURE + + if (effect := kwargs.get(ATTR_EFFECT)) is not None: + data["effect"] = effect + + if (white_ha := kwargs.get(ATTR_WHITE)) is not None: + # ESPHome multiplies brightness and white together for final brightness + # HA only sends `white` in turn_on, and reads total brightness through brightness property + data["brightness"] = white_ha / 255 + data["white"] = 1.0 + data["color_mode"] = LightColorMode.WHITE + await self._client.light_command(**data) async def async_turn_off(self, **kwargs: Any) -> None: @@ -97,10 +186,65 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return round(self._state.brightness * 255) @esphome_state_property - def hs_color(self) -> tuple[float, float] | None: - """Return the hue and saturation color value [float, float].""" - return color_util.color_RGB_to_hs( - self._state.red * 255, self._state.green * 255, self._state.blue * 255 + def color_mode(self) -> str | None: + """Return the color mode of the light.""" + if not self._supports_color_mode: + supported = self.supported_color_modes + if not supported: + return None + return next(iter(supported)) + + return _COLOR_MODES.from_esphome(self._state.color_mode) + + @esphome_state_property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the rgb color value [int, int, int].""" + if not self._supports_color_mode: + return ( + round(self._state.red * 255), + round(self._state.green * 255), + round(self._state.blue * 255), + ) + + return ( + round(self._state.red * self._state.color_brightness * 255), + round(self._state.green * self._state.color_brightness * 255), + round(self._state.blue * self._state.color_brightness * 255), + ) + + @esphome_state_property + def rgbw_color(self) -> tuple[int, int, int, int] | None: + """Return the rgbw color value [int, int, int, int].""" + white = round(self._state.white * 255) + rgb = cast("tuple[int, int, int]", self.rgb_color) + return (*rgb, white) + + @esphome_state_property + def rgbww_color(self) -> tuple[int, int, int, int, int] | None: + """Return the rgbww color value [int, int, int, int, int].""" + rgb = cast("tuple[int, int, int]", self.rgb_color) + if ( + not self._supports_color_mode + or self._state.color_mode != LightColorMode.RGB_COLD_WARM_WHITE + ): + # Try to reverse white + color temp to cwww + min_ct = self._static_info.min_mireds + max_ct = self._static_info.max_mireds + color_temp = self._state.color_temperature + white = self._state.white + + ww_frac = (color_temp - min_ct) / (max_ct - min_ct) + cw_frac = 1 - ww_frac + + return ( + *rgb, + round(white * cw_frac / max(cw_frac, ww_frac) * 255), + round(white * ww_frac / max(cw_frac, ww_frac) * 255), + ) + return ( + *rgb, + round(self._state.cold_white * 255), + round(self._state.warm_white * 255), ) @esphome_state_property @@ -108,33 +252,33 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """Return the CT color value in mireds.""" return self._state.color_temperature - @esphome_state_property - def white_value(self) -> int | None: - """Return the white value of this light between 0..255.""" - return round(self._state.white * 255) - @esphome_state_property def effect(self) -> str | None: """Return the current effect.""" return self._state.effect + @property + def _native_supported_color_modes(self) -> list[LightColorMode]: + return self._static_info.supported_color_modes_compat(self._api_version) + @property def supported_features(self) -> int: """Flag supported features.""" flags = SUPPORT_FLASH - if self._static_info.supports_brightness: - flags |= SUPPORT_BRIGHTNESS + + # All color modes except UNKNOWN,ON_OFF support transition + modes = self._native_supported_color_modes + if any(m not in (LightColorMode.UNKNOWN, LightColorMode.ON_OFF) for m in modes): flags |= SUPPORT_TRANSITION - if self._static_info.supports_rgb: - flags |= SUPPORT_COLOR - if self._static_info.supports_white_value: - flags |= SUPPORT_WHITE_VALUE - if self._static_info.supports_color_temperature: - flags |= SUPPORT_COLOR_TEMP if self._static_info.effects: flags |= SUPPORT_EFFECT return flags + @property + def supported_color_modes(self) -> set[str] | None: + """Flag supported color modes.""" + return set(map(_COLOR_MODES.from_esphome, self._native_supported_color_modes)) + @property def effect_list(self) -> list[str]: """Return the list of supported effects.""" diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 218c349e5a8..a6792808720 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==5.1.1"], + "requirements": ["aioesphomeapi==6.0.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index 1a1e9890b98..df6f90b8854 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==5.1.1 +aioesphomeapi==6.0.0 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 698583861b8..c5ef5035db0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -103,7 +103,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==5.1.1 +aioesphomeapi==6.0.0 # homeassistant.components.flo aioflo==0.4.1 From 938ec27a862dbb09c6843b364e0ae34925c81adc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 2 Aug 2021 22:08:37 +0200 Subject: [PATCH 111/903] Add support for MJJSQ humidifiers for Xiaomi MIIO integration (#53807) --- .../components/xiaomi_miio/__init__.py | 9 +- homeassistant/components/xiaomi_miio/const.py | 21 ++- .../components/xiaomi_miio/humidifier.py | 122 +++++++++++++-- .../components/xiaomi_miio/select.py | 33 ++-- .../components/xiaomi_miio/sensor.py | 11 +- .../components/xiaomi_miio/switch.py | 143 ++++++++++-------- 6 files changed, 234 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 36ee89ba7a0..89355ae309e 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging import async_timeout -from miio import AirHumidifier, AirHumidifierMiot, DeviceException +from miio import AirHumidifier, AirHumidifierMiot, AirHumidifierMjjsq, DeviceException from miio.gateway.gateway import GatewayException from homeassistant import config_entries, core @@ -25,6 +25,7 @@ from .const import ( MODELS_FAN, MODELS_HUMIDIFIER, MODELS_HUMIDIFIER_MIOT, + MODELS_HUMIDIFIER_MJJSQ, MODELS_LIGHT, MODELS_SWITCH, MODELS_VACUUM, @@ -107,6 +108,8 @@ async def async_create_miio_device_and_coordinator( if model in MODELS_HUMIDIFIER_MIOT: device = AirHumidifierMiot(host, token) + elif model in MODELS_HUMIDIFIER_MJJSQ: + device = AirHumidifierMjjsq(host, token, model=model) else: device = AirHumidifier(host, token, model=model) @@ -123,7 +126,9 @@ async def async_create_miio_device_and_coordinator( """Fetch data from the device using async_add_executor_job.""" try: async with async_timeout.timeout(10): - return await hass.async_add_executor_job(device.status) + state = await hass.async_add_executor_job(device.status) + _LOGGER.debug("Got new state: %s", state) + return state except DeviceException as ex: raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index a2f7679bf1b..c407e92a6ae 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -52,6 +52,9 @@ MODEL_AIRHUMIDIFIER_V1 = "zhimi.humidifier.v1" MODEL_AIRHUMIDIFIER_CA1 = "zhimi.humidifier.ca1" MODEL_AIRHUMIDIFIER_CA4 = "zhimi.humidifier.ca4" MODEL_AIRHUMIDIFIER_CB1 = "zhimi.humidifier.cb1" +MODEL_AIRHUMIDIFIER_JSQ = "deerma.humidifier.jsq" +MODEL_AIRHUMIDIFIER_JSQ1 = "deerma.humidifier.jsq1" +MODEL_AIRHUMIDIFIER_MJJSQ = "deerma.humidifier.mjjsq" MODEL_AIRFRESH_VA2 = "zhimi.airfresh.va2" @@ -60,7 +63,6 @@ MODELS_PURIFIER_MIOT = [ MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_PROH, ] -MODELS_HUMIDIFIER_MIOT = [MODEL_AIRHUMIDIFIER_CA4] MODELS_FAN_MIIO = [ MODEL_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V2, @@ -83,6 +85,12 @@ MODELS_HUMIDIFIER_MIIO = [ MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1, ] +MODELS_HUMIDIFIER_MIOT = [MODEL_AIRHUMIDIFIER_CA4] +MODELS_HUMIDIFIER_MJJSQ = [ + MODEL_AIRHUMIDIFIER_JSQ, + MODEL_AIRHUMIDIFIER_JSQ1, + MODEL_AIRHUMIDIFIER_MJJSQ, +] # AirQuality Models MODEL_AIRQUALITYMONITOR_V1 = "zhimi.airmonitor.v1" @@ -117,7 +125,9 @@ MODELS_SWITCH = [ "chuangmi.plug.hmi206", ] MODELS_FAN = MODELS_FAN_MIIO + MODELS_PURIFIER_MIOT -MODELS_HUMIDIFIER = MODELS_HUMIDIFIER_MIOT + MODELS_HUMIDIFIER_MIIO +MODELS_HUMIDIFIER = ( + MODELS_HUMIDIFIER_MIOT + MODELS_HUMIDIFIER_MIIO + MODELS_HUMIDIFIER_MJJSQ +) MODELS_LIGHT = ( MODELS_LIGHT_EYECARE + MODELS_LIGHT_CEILING @@ -146,15 +156,12 @@ MODELS_ALL = MODELS_ALL_DEVICES + MODELS_GATEWAY # Fan/Humidifier Services SERVICE_SET_BUZZER_ON = "fan_set_buzzer_on" SERVICE_SET_BUZZER_OFF = "fan_set_buzzer_off" -SERVICE_SET_BUZZER = "set_buzzer" -SERVICE_SET_CLEAN = "set_clean" SERVICE_SET_FAN_LED_ON = "fan_set_led_on" SERVICE_SET_FAN_LED_OFF = "fan_set_led_off" SERVICE_SET_FAN_LED = "fan_set_led" SERVICE_SET_LED_BRIGHTNESS = "set_led_brightness" SERVICE_SET_CHILD_LOCK_ON = "fan_set_child_lock_on" SERVICE_SET_CHILD_LOCK_OFF = "fan_set_child_lock_off" -SERVICE_SET_CHILD_LOCK = "set_child_lock" SERVICE_SET_LED_BRIGHTNESS = "fan_set_led_brightness" SERVICE_SET_FAVORITE_LEVEL = "fan_set_favorite_level" SERVICE_SET_FAN_LEVEL = "fan_set_fan_level" @@ -270,6 +277,10 @@ FEATURE_FLAGS_AIRHUMIDIFIER = ( FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB = FEATURE_FLAGS_AIRHUMIDIFIER | FEATURE_SET_DRY +FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ = ( + FEATURE_SET_BUZZER | FEATURE_SET_LED | FEATURE_SET_TARGET_HUMIDITY +) + FEATURE_FLAGS_AIRHUMIDIFIER_CA4 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index aee2c237066..aa26faae2b3 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -5,6 +5,7 @@ import math from miio.airhumidifier import OperationMode as AirhumidifierOperationMode from miio.airhumidifier_miot import OperationMode as AirhumidifierMiotOperationMode +from miio.airhumidifier_mjjsq import OperationMode as AirhumidifierMjjsqOperationMode from homeassistant.components.humidifier import HumidifierEntity from homeassistant.components.humidifier.const import ( @@ -28,6 +29,7 @@ from .const import ( MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, MODELS_HUMIDIFIER_MIOT, + MODELS_HUMIDIFIER_MJJSQ, ) from .device import XiaomiCoordinatedMiioEntity @@ -41,6 +43,23 @@ AVAILABLE_ATTRIBUTES = { ATTR_TARGET_HUMIDITY: "target_humidity", } +AVAILABLE_MODES_CA1_CB1 = [ + mode.name + for mode in AirhumidifierOperationMode + if mode is not AirhumidifierOperationMode.Strong +] +AVAILABLE_MODES_CA4 = [mode.name for mode in AirhumidifierMiotOperationMode] +AVAILABLE_MODES_MJJSQ = [ + mode.name + for mode in AirhumidifierMjjsqOperationMode + if mode is not AirhumidifierMjjsqOperationMode.WetAndProtect +] +AVAILABLE_MODES_OTHER = [ + mode.name + for mode in AirhumidifierOperationMode + if mode is not AirhumidifierOperationMode.Auto +] + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Humidifier from a config entry.""" @@ -62,6 +81,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): unique_id, coordinator, ) + elif model in MODELS_HUMIDIFIER_MJJSQ: + air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + entity = XiaomiAirHumidifierMjjsq( + name, + air_humidifier, + config_entry, + unique_id, + coordinator, + ) else: air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] entity = XiaomiAirHumidifier( @@ -169,28 +197,22 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): """Initialize the plug switch.""" super().__init__(name, device, entry, unique_id, coordinator) if self._model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: - self._available_modes = [] - self._available_modes = [ - mode.name - for mode in AirhumidifierOperationMode - if mode is not AirhumidifierOperationMode.Strong - ] + self._available_modes = AVAILABLE_MODES_CA1_CB1 self._min_humidity = 30 self._max_humidity = 80 self._humidity_steps = 10 elif self._model in [MODEL_AIRHUMIDIFIER_CA4]: - self._available_modes = [ - mode.name for mode in AirhumidifierMiotOperationMode - ] + self._available_modes = AVAILABLE_MODES_CA4 self._min_humidity = 30 self._max_humidity = 80 self._humidity_steps = 100 + elif self._model in MODELS_HUMIDIFIER_MJJSQ: + self._available_modes = AVAILABLE_MODES_MJJSQ + self._min_humidity = 30 + self._max_humidity = 80 + self._humidity_steps = 10 else: - self._available_modes = [ - mode.name - for mode in AirhumidifierOperationMode - if mode is not AirhumidifierOperationMode.Auto - ] + self._available_modes = AVAILABLE_MODES_OTHER self._min_humidity = 30 self._max_humidity = 80 self._humidity_steps = 10 @@ -364,3 +386,75 @@ class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): ): self._mode = self.REVERSE_MODE_MAPPING[mode].value self.async_write_ha_state() + + +class XiaomiAirHumidifierMjjsq(XiaomiAirHumidifier): + """Representation of a Xiaomi Air MJJSQ Humidifier.""" + + MODE_MAPPING = { + "Low": AirhumidifierMjjsqOperationMode.Low, + "Medium": AirhumidifierMjjsqOperationMode.Medium, + "High": AirhumidifierMjjsqOperationMode.High, + "Humidity": AirhumidifierMjjsqOperationMode.Humidity, + } + + @property + def mode(self): + """Return the current mode.""" + return AirhumidifierMjjsqOperationMode(self._mode).name + + @property + def target_humidity(self): + """Return the target humidity.""" + if self._state: + if ( + AirhumidifierMjjsqOperationMode(self._mode) + == AirhumidifierMjjsqOperationMode.Humidity + ): + return self._target_humidity + return None + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity of the humidifier and set the mode to Humidity.""" + target_humidity = self.translate_humidity(humidity) + if not target_humidity: + return + + _LOGGER.debug("Setting the humidity to: %s", target_humidity) + if await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_target_humidity, + target_humidity, + ): + self._target_humidity = target_humidity + if ( + self.supported_features & SUPPORT_MODES == 0 + or AirhumidifierMjjsqOperationMode(self._attributes[ATTR_MODE]) + == AirhumidifierMjjsqOperationMode.Humidity + ): + self.async_write_ha_state() + return + _LOGGER.debug("Setting the operation mode to: Humidity") + if await self._try_command( + "Setting operation mode of the miio device to MODE_HUMIDITY failed.", + self._device.set_mode, + AirhumidifierMjjsqOperationMode.Humidity, + ): + self._mode = 3 + self.async_write_ha_state() + + async def async_set_mode(self, mode: str) -> None: + """Set the mode of the fan.""" + if mode not in self.MODE_MAPPING: + _LOGGER.warning("Mode %s is not a valid operation mode", mode) + return + + _LOGGER.debug("Setting the operation mode to: %s", mode) + if self._state: + if await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, + self.MODE_MAPPING[mode], + ): + self._mode = self.MODE_MAPPING[mode].value + self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 77aba961244..23e43e4dbbd 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -16,10 +16,8 @@ from .const import ( FEATURE_SET_LED_BRIGHTNESS, KEY_COORDINATOR, KEY_DEVICE, - MODEL_AIRHUMIDIFIER_CA1, - MODEL_AIRHUMIDIFIER_CA4, - MODEL_AIRHUMIDIFIER_CB1, - MODELS_HUMIDIFIER, + MODELS_HUMIDIFIER_MIIO, + MODELS_HUMIDIFIER_MIOT, SERVICE_SET_LED_BRIGHTNESS, ) from .device import XiaomiCoordinatedMiioEntity @@ -65,28 +63,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] model = config_entry.data[CONF_MODEL] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - if model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: + if model in MODELS_HUMIDIFIER_MIIO: entity_class = XiaomiAirHumidifierSelector - elif model in [MODEL_AIRHUMIDIFIER_CA4]: + elif model in MODELS_HUMIDIFIER_MIOT: entity_class = XiaomiAirHumidifierMiotSelector - elif model in MODELS_HUMIDIFIER: - entity_class = XiaomiAirHumidifierSelector else: return - for selector in SELECTOR_TYPES.values(): - entities.append( - entity_class( - f"{config_entry.title} {selector.name}", - device, - config_entry, - f"{selector.short_name}_{config_entry.unique_id}", - selector, - coordinator, - ) + selector = SELECTOR_TYPES[FEATURE_SET_LED_BRIGHTNESS] + entities.append( + entity_class( + f"{config_entry.title} {selector.name}", + device, + config_entry, + f"{selector.short_name}_{config_entry.unique_id}", + selector, + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], ) + ) async_add_entities(entities) diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 413971aa880..4663813ab7c 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -46,6 +46,7 @@ from .const import ( KEY_COORDINATOR, KEY_DEVICE, MODELS_HUMIDIFIER_MIOT, + MODELS_HUMIDIFIER_MJJSQ, ) from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity from .gateway import XiaomiGatewayDevice @@ -135,6 +136,11 @@ HUMIDIFIER_SENSORS_MIOT = { ATTR_ACTUAL_MOTOR_SPEED: "actual_speed", } +HUMIDIFIER_SENSORS_MJJSQ = { + ATTR_HUMIDITY: "humidity", + ATTR_TEMPERATURE: "temperature", +} + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Import Miio configuration from YAML.""" @@ -191,11 +197,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [] if model in MODELS_HUMIDIFIER_MIOT: device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] sensors = HUMIDIFIER_SENSORS_MIOT + elif model in MODELS_HUMIDIFIER_MJJSQ: + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + sensors = HUMIDIFIER_SENSORS_MJJSQ elif model.startswith("zhimi.humidifier."): device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] sensors = HUMIDIFIER_SENSORS else: unique_id = config_entry.unique_id diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index bdf3085f236..a82d091dee8 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -1,4 +1,6 @@ """Support for Xiaomi Smart WiFi Socket and Smart Power Strip.""" +from __future__ import annotations + import asyncio from dataclasses import dataclass from enum import Enum @@ -13,6 +15,7 @@ from homeassistant.components.switch import ( DEVICE_CLASS_SWITCH, PLATFORM_SCHEMA, SwitchEntity, + SwitchEntityDescription, ) from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( @@ -35,20 +38,19 @@ from .const import ( FEATURE_FLAGS_AIRHUMIDIFIER, FEATURE_FLAGS_AIRHUMIDIFIER_CA4, FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ, FEATURE_SET_BUZZER, FEATURE_SET_CHILD_LOCK, FEATURE_SET_CLEAN, FEATURE_SET_DRY, + FEATURE_SET_LED, KEY_COORDINATOR, KEY_DEVICE, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, MODELS_HUMIDIFIER, - SERVICE_SET_BUZZER, - SERVICE_SET_CHILD_LOCK, - SERVICE_SET_CLEAN, - SERVICE_SET_DRY, + MODELS_HUMIDIFIER_MJJSQ, SERVICE_SET_POWER_MODE, SERVICE_SET_POWER_PRICE, SERVICE_SET_WIFI_LED_OFF, @@ -96,17 +98,18 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) -ATTR_POWER = "power" -ATTR_LOAD_POWER = "load_power" -ATTR_MODEL = "model" -ATTR_POWER_MODE = "power_mode" -ATTR_WIFI_LED = "wifi_led" -ATTR_POWER_PRICE = "power_price" -ATTR_PRICE = "price" ATTR_BUZZER = "buzzer" ATTR_CHILD_LOCK = "child_lock" -ATTR_DRY = "dry" ATTR_CLEAN = "clean_mode" +ATTR_DRY = "dry" +ATTR_LED = "led" +ATTR_LOAD_POWER = "load_power" +ATTR_MODEL = "model" +ATTR_POWER = "power" +ATTR_POWER_MODE = "power_mode" +ATTR_POWER_PRICE = "power_price" +ATTR_PRICE = "price" +ATTR_WIFI_LED = "wifi_led" FEATURE_SET_POWER_MODE = 1 FEATURE_SET_WIFI_LED = 2 @@ -143,63 +146,62 @@ SERVICE_TO_METHOD = { "method": "async_set_power_price", "schema": SERVICE_SCHEMA_POWER_PRICE, }, - SERVICE_SET_BUZZER: { - "method_on": "async_set_buzzer_on", - "method_off": "async_set_buzzer_off", - }, - SERVICE_SET_CHILD_LOCK: { - "method_on": "async_set_child_lock_on", - "method_off": "async_set_child_lock_off", - }, - SERVICE_SET_DRY: { - "method_on": "async_set_dry_on", - "method_off": "async_set_dry_off", - }, - SERVICE_SET_CLEAN: { - "method_on": "async_set_clean_on", - "method_off": "async_set_clean_off", - }, } @dataclass -class SwitchType: - """Class that holds device specific info for a xiaomi aqara or humidifiers.""" +class XiaomiMiioSwitchDescription(SwitchEntityDescription): + """A class that describes switch entities.""" - name: str = None - short_name: str = None - icon: str = None - service: str = None + feature: int | None = None + method_on: str | None = None + method_off: str | None = None available_with_device_off: bool = True -SWITCH_TYPES = { - FEATURE_SET_BUZZER: SwitchType( +SWITCH_TYPES = ( + XiaomiMiioSwitchDescription( + key=ATTR_BUZZER, + feature=FEATURE_SET_BUZZER, name="Buzzer", icon="mdi:volume-high", - short_name=ATTR_BUZZER, - service=SERVICE_SET_BUZZER, + method_on="async_set_buzzer_on", + method_off="async_set_buzzer_off", ), - FEATURE_SET_CHILD_LOCK: SwitchType( + XiaomiMiioSwitchDescription( + key=ATTR_CHILD_LOCK, + feature=FEATURE_SET_CHILD_LOCK, name="Child Lock", icon="mdi:lock", - short_name=ATTR_CHILD_LOCK, - service=SERVICE_SET_CHILD_LOCK, + method_on="async_set_child_lock_on", + method_off="async_set_child_lock_off", ), - FEATURE_SET_DRY: SwitchType( + XiaomiMiioSwitchDescription( + key=ATTR_DRY, + feature=FEATURE_SET_DRY, name="Dry Mode", icon="mdi:hair-dryer", - short_name=ATTR_DRY, - service=SERVICE_SET_DRY, + method_on="async_set_dry_on", + method_off="async_set_dry_off", ), - FEATURE_SET_CLEAN: SwitchType( + XiaomiMiioSwitchDescription( + key=ATTR_CLEAN, + feature=FEATURE_SET_CLEAN, name="Clean Mode", icon="mdi:sparkles", - short_name=ATTR_CLEAN, - service=SERVICE_SET_CLEAN, + method_on="async_set_clean_on", + method_off="async_set_clean_off", available_with_device_off=False, ), -} + XiaomiMiioSwitchDescription( + key=ATTR_LED, + feature=FEATURE_SET_LED, + name="Led", + icon="mdi:led-outline", + method_on="async_set_led_on", + method_off="async_set_led_off", + ), +) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -241,19 +243,21 @@ async def async_setup_coordinated_entry(hass, config_entry, async_add_entities): device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB elif model in [MODEL_AIRHUMIDIFIER_CA4]: device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA4 + elif model in MODELS_HUMIDIFIER_MJJSQ: + device_features = FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ elif model in MODELS_HUMIDIFIER: device_features = FEATURE_FLAGS_AIRHUMIDIFIER - for feature, switch in SWITCH_TYPES.items(): - if feature & device_features: + for description in SWITCH_TYPES: + if description.feature & device_features: entities.append( XiaomiGenericCoordinatedSwitch( - f"{config_entry.title} {switch.name}", + f"{config_entry.title} {description.name}", device, config_entry, - f"{switch.short_name}_{unique_id}", - switch, + f"{description.key}_{unique_id}", coordinator, + description, ) ) @@ -382,22 +386,21 @@ async def async_setup_other_entry(hass, config_entry, async_add_entities): class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Representation of a Xiaomi Plug Generic.""" - def __init__(self, name, device, entry, unique_id, switch, coordinator): + def __init__(self, name, device, entry, unique_id, coordinator, description): """Initialize the plug switch.""" super().__init__(name, device, entry, unique_id, coordinator) - self._attr_icon = switch.icon - self._controller = switch self._attr_is_on = self._extract_value_from_attribute( - self.coordinator.data, self._controller.short_name + self.coordinator.data, description.key ) + self.entity_description = description @callback def _handle_coordinator_update(self): """Fetch state from the device.""" # On state change the device doesn't provide the new state immediately. self._attr_is_on = self._extract_value_from_attribute( - self.coordinator.data, self._controller.short_name + self.coordinator.data, self.entity_description.key ) self.async_write_ha_state() @@ -407,7 +410,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): if ( super().available and not self.coordinator.data.is_on - and not self._controller.available_with_device_off + and not self.entity_description.available_with_device_off ): return False return super().available @@ -422,7 +425,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): async def async_turn_on(self, **kwargs) -> None: """Turn on an option of the miio device.""" - method = getattr(self, SERVICE_TO_METHOD[self._controller.service]["method_on"]) + method = getattr(self, self.entity_description.method_on) if await method(): # Write state back to avoid switch flips with a slow response self._attr_is_on = True @@ -430,9 +433,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): async def async_turn_off(self, **kwargs) -> None: """Turn off an option of the miio device.""" - method = getattr( - self, SERVICE_TO_METHOD[self._controller.service]["method_off"] - ) + method = getattr(self, self.entity_description.method_off) if await method(): # Write state back to avoid switch flips with a slow response self._attr_is_on = False @@ -502,6 +503,22 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): False, ) + async def async_set_led_on(self) -> bool: + """Turn the led on.""" + return await self._try_command( + "Turning the led of the miio device on failed.", + self._device.set_led, + True, + ) + + async def async_set_led_off(self) -> bool: + """Turn the led off.""" + return await self._try_command( + "Turning the led of the miio device off failed.", + self._device.set_led, + False, + ) + class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity): """Representation of a XiaomiGatewaySwitch.""" From 18f4d125c39b1e6c33f8d1ccb36a5134c3b96e9e Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 2 Aug 2021 21:11:26 +0100 Subject: [PATCH 112/903] System Bridge v2.3.0+ - Data from WebSocket (#53443) Co-authored-by: Martin Hjelmare --- .coveragerc | 3 +- .../components/system_bridge/__init__.py | 135 +++++++++-------- .../components/system_bridge/binary_sensor.py | 22 ++- .../components/system_bridge/config_flow.py | 16 +- .../components/system_bridge/coordinator.py | 139 +++++++++++++++++ .../components/system_bridge/manifest.json | 4 +- .../components/system_bridge/sensor.py | 109 ++++++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../system_bridge/test_config_flow.py | 140 ++++++------------ 10 files changed, 327 insertions(+), 245 deletions(-) create mode 100644 homeassistant/components/system_bridge/coordinator.py diff --git a/.coveragerc b/.coveragerc index 1f28a9a2aee..5ebef801c6d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1007,8 +1007,9 @@ omit = homeassistant/components/synology_srm/device_tracker.py homeassistant/components/syslog/notify.py homeassistant/components/system_bridge/__init__.py - homeassistant/components/system_bridge/const.py homeassistant/components/system_bridge/binary_sensor.py + homeassistant/components/system_bridge/const.py + homeassistant/components/system_bridge/coordinator.py homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/* diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 10ee4165295..f016cca798d 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from datetime import timedelta import logging import shlex @@ -22,20 +21,17 @@ from homeassistant.const import ( CONF_PORT, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_validation as cv, device_registry as dr, ) from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -61,51 +57,55 @@ SERVICE_OPEN_SCHEMA = vol.Schema( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up System Bridge from a config entry.""" - - client = Bridge( + bridge = Bridge( BridgeClient(aiohttp_client.async_get_clientsession(hass)), f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", entry.data[CONF_API_KEY], ) - async def async_update_data() -> Bridge: - """Fetch data from Bridge.""" - try: - async with async_timeout.timeout(60): - await asyncio.gather( - *[ - client.async_get_battery(), - client.async_get_cpu(), - client.async_get_filesystem(), - client.async_get_memory(), - client.async_get_network(), - client.async_get_os(), - client.async_get_processes(), - client.async_get_system(), - ] - ) - return client - except BridgeAuthenticationException as exception: - raise ConfigEntryAuthFailed from exception - except BRIDGE_CONNECTION_ERRORS as exception: - raise UpdateFailed("Could not connect to System Bridge.") from exception + try: + async with async_timeout.timeout(30): + await bridge.async_get_information() + except BridgeAuthenticationException as exception: + raise ConfigEntryAuthFailed( + f"Authentication failed for {entry.title} ({entry.data[CONF_HOST]})" + ) from exception + except BRIDGE_CONNECTION_ERRORS as exception: + raise ConfigEntryNotReady( + f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." + ) from exception - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name=f"{DOMAIN}_coordinator", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=60), - ) + coordinator = SystemBridgeDataUpdateCoordinator(hass, bridge, _LOGGER, entry=entry) + await coordinator.async_config_entry_first_refresh() + + # Wait for initial data + try: + async with async_timeout.timeout(60): + while ( + coordinator.bridge.battery is None + or coordinator.bridge.cpu is None + or coordinator.bridge.filesystem is None + or coordinator.bridge.information is None + or coordinator.bridge.memory is None + or coordinator.bridge.network is None + or coordinator.bridge.os is None + or coordinator.bridge.processes is None + or coordinator.bridge.system is None + ): + _LOGGER.debug( + "Waiting for initial data from %s (%s)", + entry.title, + entry.data[CONF_HOST], + ) + await asyncio.sleep(1) + except asyncio.TimeoutError as exception: + raise ConfigEntryNotReady( + f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." + ) from exception hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator - # Fetch initial data so we have data when entities subscribe - await coordinator.async_config_entry_first_refresh() - hass.config_entries.async_setup_platforms(entry, PLATFORMS) if hass.services.has_service(DOMAIN, SERVICE_SEND_COMMAND): @@ -128,8 +128,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for entry in hass.config_entries.async_entries(DOMAIN) if entry.entry_id in device_entry.config_entries ) - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry_id] - bridge: Bridge = coordinator.data + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + bridge: Bridge = coordinator.bridge _LOGGER.debug( "Command payload: %s", @@ -166,8 +166,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for entry in hass.config_entries.async_entries(DOMAIN) if entry.entry_id in device_entry.config_entries ) - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry_id] - bridge: Bridge = coordinator.data + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + bridge: Bridge = coordinator.bridge _LOGGER.debug("Open payload: %s", {CONF_PATH: path}) try: @@ -190,14 +190,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=SERVICE_OPEN_SCHEMA, ) + # Reload entry when its updated. + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + + # Ensure disconnected and cleanup stop sub + await coordinator.bridge.async_close_websocket() + if coordinator.unsub: + coordinator.unsub() + + del hass.data[DOMAIN][entry.entry_id] if not hass.data[DOMAIN]: hass.services.async_remove(DOMAIN, SERVICE_SEND_COMMAND) @@ -206,13 +218,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -class BridgeEntity(CoordinatorEntity): +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the config entry when it changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + +class SystemBridgeEntity(CoordinatorEntity): """Defines a base System Bridge entity.""" def __init__( self, - coordinator: DataUpdateCoordinator, - bridge: Bridge, + coordinator: SystemBridgeDataUpdateCoordinator, key: str, name: str, icon: str | None, @@ -220,14 +236,13 @@ class BridgeEntity(CoordinatorEntity): ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) - self._key = f"{bridge.os.hostname}_{key}" - self._name = f"{bridge.os.hostname} {name}" + bridge: Bridge = coordinator.data + self._key = f"{bridge.information.host}_{key}" + self._name = f"{bridge.information.host} {name}" self._icon = icon self._enabled_default = enabled_by_default - self._hostname = bridge.os.hostname - self._default_interface = bridge.network.interfaces[ - bridge.network.interfaceDefault - ] + self._hostname = bridge.information.host + self._mac = bridge.information.mac self._manufacturer = bridge.system.system.manufacturer self._model = bridge.system.system.model self._version = bridge.system.system.version @@ -253,16 +268,14 @@ class BridgeEntity(CoordinatorEntity): return self._enabled_default -class BridgeDeviceEntity(BridgeEntity): +class SystemBridgeDeviceEntity(SystemBridgeEntity): """Defines a System Bridge device entity.""" @property def device_info(self) -> DeviceInfo: """Return device information about this System Bridge instance.""" return { - "connections": { - (dr.CONNECTION_NETWORK_MAC, self._default_interface["mac"]) - }, + "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, "manufacturer": self._manufacturer, "model": self._model, "name": self._hostname, diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 488aca90bde..f6b765f8079 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -9,30 +9,29 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import BridgeDeviceEntity +from . import SystemBridgeDeviceEntity from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ) -> None: """Set up System Bridge binary sensor based on a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] bridge: Bridge = coordinator.data - if bridge.battery.hasBattery: - async_add_entities([BridgeBatteryIsChargingBinarySensor(coordinator, bridge)]) + if bridge.battery and bridge.battery.hasBattery: + async_add_entities([SystemBridgeBatteryIsChargingBinarySensor(coordinator)]) -class BridgeBinarySensor(BridgeDeviceEntity, BinarySensorEntity): +class SystemBridgeBinarySensor(SystemBridgeDeviceEntity, BinarySensorEntity): """Defines a System Bridge binary sensor.""" def __init__( self, - coordinator: DataUpdateCoordinator, - bridge: Bridge, + coordinator: SystemBridgeDataUpdateCoordinator, key: str, name: str, icon: str | None, @@ -42,7 +41,7 @@ class BridgeBinarySensor(BridgeDeviceEntity, BinarySensorEntity): """Initialize System Bridge binary sensor.""" self._device_class = device_class - super().__init__(coordinator, bridge, key, name, icon, enabled_by_default) + super().__init__(coordinator, key, name, icon, enabled_by_default) @property def device_class(self) -> str | None: @@ -50,14 +49,13 @@ class BridgeBinarySensor(BridgeDeviceEntity, BinarySensorEntity): return self._device_class -class BridgeBatteryIsChargingBinarySensor(BridgeBinarySensor): +class SystemBridgeBatteryIsChargingBinarySensor(SystemBridgeBinarySensor): """Defines a Battery is charging binary sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge binary sensor.""" super().__init__( coordinator, - bridge, "battery_is_charging", "Battery Is Charging", None, diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 8402a3c1d3e..4ff887c6389 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -8,8 +8,6 @@ import async_timeout from systembridge import Bridge from systembridge.client import BridgeClient from systembridge.exceptions import BridgeAuthenticationException -from systembridge.objects.os import Os -from systembridge.objects.system import System import voluptuous as vol from homeassistant import config_entries, exceptions @@ -47,10 +45,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, hostname = data[CONF_HOST] try: async with async_timeout.timeout(30): - bridge_os: Os = await bridge.async_get_os() - if bridge_os.hostname is not None: - hostname = bridge_os.hostname - bridge_system: System = await bridge.async_get_system() + await bridge.async_get_information() + if ( + bridge.information is not None + and bridge.information.host is not None + and bridge.information.uuid is not None + ): + hostname = bridge.information.host + uuid = bridge.information.uuid except BridgeAuthenticationException as exception: _LOGGER.info(exception) raise InvalidAuth from exception @@ -58,7 +60,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, _LOGGER.info(exception) raise CannotConnect from exception - return {"hostname": hostname, "uuid": bridge_system.uuid.os} + return {"hostname": hostname, "uuid": uuid} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py new file mode 100644 index 00000000000..d34e1019a0b --- /dev/null +++ b/homeassistant/components/system_bridge/coordinator.py @@ -0,0 +1,139 @@ +"""DataUpdateCoordinator for System Bridge.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +from typing import Callable + +from systembridge import Bridge +from systembridge.exceptions import ( + BridgeAuthenticationException, + BridgeConnectionClosedException, + BridgeException, +) +from systembridge.objects.events import Event + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN + + +class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[Bridge]): + """Class to manage fetching System Bridge data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + bridge: Bridge, + LOGGER: logging.Logger, + *, + entry: ConfigEntry, + ) -> None: + """Initialize global System Bridge data updater.""" + self.bridge = bridge + self.title = entry.title + self.host = entry.data[CONF_HOST] + self.unsub: Callable | None = None + + super().__init__( + hass, LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30) + ) + + def update_listeners(self) -> None: + """Call update on all listeners.""" + for update_callback in self._listeners: + update_callback() + + async def async_handle_event(self, event: Event): + """Handle System Bridge events from the WebSocket.""" + # No need to update anything, as everything is updated in the caller + self.logger.debug( + "New event from %s (%s): %s", self.title, self.host, event.name + ) + self.async_set_updated_data(self.bridge) + + async def _listen_for_events(self) -> None: + """Listen for events from the WebSocket.""" + try: + await self.bridge.async_send_event( + "get-data", + [ + "battery", + "cpu", + "filesystem", + "memory", + "network", + "os", + "processes", + "system", + ], + ) + await self.bridge.listen_for_events(callback=self.async_handle_event) + except BridgeConnectionClosedException as exception: + self.last_update_success = False + self.logger.info( + "Websocket Connection Closed for %s (%s). Will retry: %s", + self.title, + self.host, + exception, + ) + except BridgeException as exception: + self.last_update_success = False + self.update_listeners() + self.logger.warning( + "Exception occurred for %s (%s). Will retry: %s", + self.title, + self.host, + exception, + ) + + async def _setup_websocket(self) -> None: + """Use WebSocket for updates.""" + + try: + self.logger.debug( + "Connecting to ws://%s:%s", + self.host, + self.bridge.information.websocketPort, + ) + await self.bridge.async_connect_websocket( + self.host, self.bridge.information.websocketPort + ) + except BridgeAuthenticationException as exception: + if self.unsub: + self.unsub() + self.unsub = None + raise ConfigEntryAuthFailed() from exception + except (*BRIDGE_CONNECTION_ERRORS, ConnectionRefusedError) as exception: + if self.unsub: + self.unsub() + self.unsub = None + raise UpdateFailed( + f"Could not connect to {self.title} ({self.host})." + ) from exception + asyncio.create_task(self._listen_for_events()) + + async def close_websocket(_) -> None: + """Close WebSocket connection.""" + await self.bridge.async_close_websocket() + + # Clean disconnect WebSocket on Home Assistant shutdown + self.unsub = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, close_websocket + ) + + async def _async_update_data(self) -> Bridge: + """Update System Bridge data from WebSocket.""" + self.logger.debug( + "_async_update_data - WebSocket Connected: %s", + self.bridge.websocket_connected, + ) + if not self.bridge.websocket_connected: + await self._setup_websocket() + + return self.bridge diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 0a800657009..2f1ec0111cf 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -3,10 +3,10 @@ "name": "System Bridge", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/system_bridge", - "requirements": ["systembridge==1.1.5"], + "requirements": ["systembridge==2.0.6"], "codeowners": ["@timmo001"], "zeroconf": ["_system-bridge._udp.local."], "after_dependencies": ["zeroconf"], "quality_scale": "silver", - "iot_class": "local_polling" + "iot_class": "local_push" } diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index ea7fc628e76..ed3c569f10f 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -20,10 +20,10 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import BridgeDeviceEntity +from . import SystemBridgeDeviceEntity from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator ATTR_AVAILABLE = "available" ATTR_FILESYSTEM = "filesystem" @@ -41,40 +41,38 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ) -> None: """Set up System Bridge sensor based on a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - bridge: Bridge = coordinator.data + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [ - BridgeCpuSpeedSensor(coordinator, bridge), - BridgeCpuTemperatureSensor(coordinator, bridge), - BridgeCpuVoltageSensor(coordinator, bridge), + SystemBridgeCpuSpeedSensor(coordinator), + SystemBridgeCpuTemperatureSensor(coordinator), + SystemBridgeCpuVoltageSensor(coordinator), *( - BridgeFilesystemSensor(coordinator, bridge, key) - for key, _ in bridge.filesystem.fsSize.items() + SystemBridgeFilesystemSensor(coordinator, key) + for key, _ in coordinator.data.filesystem.fsSize.items() ), - BridgeMemoryFreeSensor(coordinator, bridge), - BridgeMemoryUsedSensor(coordinator, bridge), - BridgeMemoryUsedPercentageSensor(coordinator, bridge), - BridgeKernelSensor(coordinator, bridge), - BridgeOsSensor(coordinator, bridge), - BridgeProcessesLoadSensor(coordinator, bridge), - BridgeBiosVersionSensor(coordinator, bridge), + SystemBridgeMemoryFreeSensor(coordinator), + SystemBridgeMemoryUsedSensor(coordinator), + SystemBridgeMemoryUsedPercentageSensor(coordinator), + SystemBridgeKernelSensor(coordinator), + SystemBridgeOsSensor(coordinator), + SystemBridgeProcessesLoadSensor(coordinator), + SystemBridgeBiosVersionSensor(coordinator), ] - if bridge.battery.hasBattery: - entities.append(BridgeBatterySensor(coordinator, bridge)) - entities.append(BridgeBatteryTimeRemainingSensor(coordinator, bridge)) + if coordinator.data.battery.hasBattery: + entities.append(SystemBridgeBatterySensor(coordinator)) + entities.append(SystemBridgeBatteryTimeRemainingSensor(coordinator)) async_add_entities(entities) -class BridgeSensor(BridgeDeviceEntity, SensorEntity): +class SystemBridgeSensor(SystemBridgeDeviceEntity, SensorEntity): """Defines a System Bridge sensor.""" def __init__( self, - coordinator: DataUpdateCoordinator, - bridge: Bridge, + coordinator: SystemBridgeDataUpdateCoordinator, key: str, name: str, icon: str | None, @@ -86,7 +84,7 @@ class BridgeSensor(BridgeDeviceEntity, SensorEntity): self._device_class = device_class self._unit_of_measurement = unit_of_measurement - super().__init__(coordinator, bridge, key, name, icon, enabled_by_default) + super().__init__(coordinator, key, name, icon, enabled_by_default) @property def device_class(self) -> str | None: @@ -99,14 +97,13 @@ class BridgeSensor(BridgeDeviceEntity, SensorEntity): return self._unit_of_measurement -class BridgeBatterySensor(BridgeSensor): +class SystemBridgeBatterySensor(SystemBridgeSensor): """Defines a Battery sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "battery", "Battery", None, @@ -122,14 +119,13 @@ class BridgeBatterySensor(BridgeSensor): return bridge.battery.percent -class BridgeBatteryTimeRemainingSensor(BridgeSensor): +class SystemBridgeBatteryTimeRemainingSensor(SystemBridgeSensor): """Defines the Battery Time Remaining sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "battery_time_remaining", "Battery Time Remaining", None, @@ -147,14 +143,13 @@ class BridgeBatteryTimeRemainingSensor(BridgeSensor): return str(datetime.now() + timedelta(minutes=bridge.battery.timeRemaining)) -class BridgeCpuSpeedSensor(BridgeSensor): +class SystemBridgeCpuSpeedSensor(SystemBridgeSensor): """Defines a CPU speed sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "cpu_speed", "CPU Speed", "mdi:speedometer", @@ -170,14 +165,13 @@ class BridgeCpuSpeedSensor(BridgeSensor): return bridge.cpu.currentSpeed.avg -class BridgeCpuTemperatureSensor(BridgeSensor): +class SystemBridgeCpuTemperatureSensor(SystemBridgeSensor): """Defines a CPU temperature sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "cpu_temperature", "CPU Temperature", None, @@ -193,14 +187,13 @@ class BridgeCpuTemperatureSensor(BridgeSensor): return bridge.cpu.temperature.main -class BridgeCpuVoltageSensor(BridgeSensor): +class SystemBridgeCpuVoltageSensor(SystemBridgeSensor): """Defines a CPU voltage sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "cpu_voltage", "CPU Voltage", None, @@ -216,17 +209,16 @@ class BridgeCpuVoltageSensor(BridgeSensor): return bridge.cpu.cpu.voltage -class BridgeFilesystemSensor(BridgeSensor): +class SystemBridgeFilesystemSensor(SystemBridgeSensor): """Defines a filesystem sensor.""" def __init__( - self, coordinator: DataUpdateCoordinator, bridge: Bridge, key: str + self, coordinator: SystemBridgeDataUpdateCoordinator, key: str ) -> None: """Initialize System Bridge sensor.""" uid_key = key.replace(":", "") super().__init__( coordinator, - bridge, f"filesystem_{uid_key}", f"{key} Space Used", "mdi:harddisk", @@ -260,14 +252,13 @@ class BridgeFilesystemSensor(BridgeSensor): } -class BridgeMemoryFreeSensor(BridgeSensor): +class SystemBridgeMemoryFreeSensor(SystemBridgeSensor): """Defines a memory free sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "memory_free", "Memory Free", "mdi:memory", @@ -287,14 +278,13 @@ class BridgeMemoryFreeSensor(BridgeSensor): ) -class BridgeMemoryUsedSensor(BridgeSensor): +class SystemBridgeMemoryUsedSensor(SystemBridgeSensor): """Defines a memory used sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "memory_used", "Memory Used", "mdi:memory", @@ -314,14 +304,13 @@ class BridgeMemoryUsedSensor(BridgeSensor): ) -class BridgeMemoryUsedPercentageSensor(BridgeSensor): +class SystemBridgeMemoryUsedPercentageSensor(SystemBridgeSensor): """Defines a memory used percentage sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "memory_used_percentage", "Memory Used %", "mdi:memory", @@ -341,14 +330,13 @@ class BridgeMemoryUsedPercentageSensor(BridgeSensor): ) -class BridgeKernelSensor(BridgeSensor): +class SystemBridgeKernelSensor(SystemBridgeSensor): """Defines a kernel sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "kernel", "Kernel", "mdi:devices", @@ -364,14 +352,13 @@ class BridgeKernelSensor(BridgeSensor): return bridge.os.kernel -class BridgeOsSensor(BridgeSensor): +class SystemBridgeOsSensor(SystemBridgeSensor): """Defines an OS sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "os", "Operating System", "mdi:devices", @@ -387,14 +374,13 @@ class BridgeOsSensor(BridgeSensor): return f"{bridge.os.distro} {bridge.os.release}" -class BridgeProcessesLoadSensor(BridgeSensor): +class SystemBridgeProcessesLoadSensor(SystemBridgeSensor): """Defines a Processes Load sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "processes_load", "Load", "mdi:percent", @@ -429,14 +415,13 @@ class BridgeProcessesLoadSensor(BridgeSensor): return attrs -class BridgeBiosVersionSensor(BridgeSensor): +class SystemBridgeBiosVersionSensor(SystemBridgeSensor): """Defines a bios version sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "bios_version", "BIOS Version", "mdi:chip", diff --git a/requirements_all.txt b/requirements_all.txt index df6f90b8854..010c42b6e74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridge==1.1.5 +systembridge==2.0.6 # homeassistant.components.tahoma tahoma-api==0.0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c5ef5035db0..90bafb5efe5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1232,7 +1232,7 @@ sunwatcher==0.2.1 surepy==0.7.0 # homeassistant.components.system_bridge -systembridge==1.1.5 +systembridge==2.0.6 # homeassistant.components.tellduslive tellduslive==0.10.11 diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index 5cd7a77d911..96603c39bcd 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -55,79 +55,24 @@ FIXTURE_ZEROCONF_BAD = { }, } -FIXTURE_OS = { - "platform": "linux", - "distro": "Ubuntu", - "release": "20.10", - "codename": "Groovy Gorilla", - "kernel": "5.8.0-44-generic", - "arch": "x64", - "hostname": "test-bridge", - "fqdn": "test-bridge.local", - "codepage": "UTF-8", - "logofile": "ubuntu", - "serial": "abcdefghijklmnopqrstuvwxyz", - "build": "", - "servicepack": "", - "uefi": True, - "users": [], -} - -FIXTURE_NETWORK = { - "connections": [], - "gatewayDefault": "192.168.1.1", - "interfaceDefault": "wlp2s0", - "interfaces": { - "wlp2s0": { - "iface": "wlp2s0", - "ifaceName": "wlp2s0", - "ip4": "1.1.1.1", - "mac": FIXTURE_MAC_ADDRESS, - }, - }, - "stats": {}, -} - -FIXTURE_SYSTEM = { - "baseboard": { - "manufacturer": "System manufacturer", - "model": "Model", - "version": "Rev X.0x", - "serial": "1234567", - "assetTag": "", - "memMax": 134217728, - "memSlots": 4, - }, - "bios": { - "vendor": "System vendor", - "version": "12345", - "releaseDate": "2019-11-13", - "revision": "", - }, - "chassis": { - "manufacturer": "Default string", - "model": "", - "type": "Desktop", - "version": "Default string", - "serial": "Default string", - "assetTag": "", - "sku": "", - }, - "system": { - "manufacturer": "System manufacturer", - "model": "System Product Name", - "version": "System Version", - "serial": "System Serial Number", - "uuid": "abc123-def456", - "sku": "SKU", - "virtual": False, - }, - "uuid": { - "os": FIXTURE_UUID, - "hardware": "abc123-def456", - "macs": [FIXTURE_MAC_ADDRESS], +FIXTURE_INFORMATION = { + "address": "http://test-bridge:9170", + "apiPort": 9170, + "fqdn": "test-bridge", + "host": "test-bridge", + "ip": "1.1.1.1", + "mac": FIXTURE_MAC_ADDRESS, + "updates": { + "available": False, + "newer": False, + "url": "https://github.com/timmo001/system-bridge/releases/tag/v2.3.2", + "version": {"current": "2.3.2", "new": "2.3.2"}, }, + "uuid": FIXTURE_UUID, + "version": "2.3.2", + "websocketAddress": "ws://test-bridge:9172", + "websocketPort": 9172, } @@ -151,9 +96,11 @@ async def test_user_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", json=FIXTURE_OS) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", json=FIXTURE_NETWORK) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", json=FIXTURE_SYSTEM) + aioclient_mock.get( + f"{FIXTURE_BASE_URL}/information", + headers={"Content-Type": "application/json"}, + json=FIXTURE_INFORMATION, + ) with patch( "homeassistant.components.system_bridge.async_setup_entry", @@ -181,9 +128,9 @@ async def test_form_invalid_auth( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=BridgeAuthenticationException) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=BridgeAuthenticationException) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=BridgeAuthenticationException) + aioclient_mock.get( + f"{FIXTURE_BASE_URL}/information", exc=BridgeAuthenticationException + ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT @@ -206,9 +153,7 @@ async def test_form_cannot_connect( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=ClientConnectionError) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=ClientConnectionError) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=ClientConnectionError) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/information", exc=ClientConnectionError) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT @@ -220,7 +165,7 @@ async def test_form_cannot_connect( assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unknow_error( +async def test_form_unknown_error( hass, aiohttp_client, aioclient_mock, current_request_with_host ) -> None: """Test we handle unknown error.""" @@ -232,10 +177,9 @@ async def test_form_unknow_error( assert result["errors"] is None with patch( - "homeassistant.components.system_bridge.config_flow.Bridge.async_get_os", + "homeassistant.components.system_bridge.config_flow.Bridge.async_get_information", side_effect=Exception("Boom"), ): - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT ) @@ -257,9 +201,9 @@ async def test_reauth_authorization_error( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=BridgeAuthenticationException) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=BridgeAuthenticationException) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=BridgeAuthenticationException) + aioclient_mock.get( + f"{FIXTURE_BASE_URL}/information", exc=BridgeAuthenticationException + ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_AUTH_INPUT @@ -282,9 +226,7 @@ async def test_reauth_connection_error( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=ClientConnectionError) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=ClientConnectionError) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=ClientConnectionError) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/information", exc=ClientConnectionError) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_AUTH_INPUT @@ -312,9 +254,11 @@ async def test_reauth_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", json=FIXTURE_OS) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", json=FIXTURE_NETWORK) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", json=FIXTURE_SYSTEM) + aioclient_mock.get( + f"{FIXTURE_BASE_URL}/information", + headers={"Content-Type": "application/json"}, + json=FIXTURE_INFORMATION, + ) with patch( "homeassistant.components.system_bridge.async_setup_entry", @@ -345,9 +289,11 @@ async def test_zeroconf_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert not result["errors"] - aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/os", json=FIXTURE_OS) - aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/network", json=FIXTURE_NETWORK) - aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/system", json=FIXTURE_SYSTEM) + aioclient_mock.get( + f"{FIXTURE_ZEROCONF_BASE_URL}/information", + headers={"Content-Type": "application/json"}, + json=FIXTURE_INFORMATION, + ) with patch( "homeassistant.components.system_bridge.async_setup_entry", @@ -378,11 +324,9 @@ async def test_zeroconf_cannot_connect( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert not result["errors"] - aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/os", exc=ClientConnectionError) aioclient_mock.get( - f"{FIXTURE_ZEROCONF_BASE_URL}/network", exc=ClientConnectionError + f"{FIXTURE_ZEROCONF_BASE_URL}/information", exc=ClientConnectionError ) - aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/system", exc=ClientConnectionError) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_AUTH_INPUT From 91e55bdd14603d9ecc19d936c323c2391664da22 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Aug 2021 14:44:15 -0700 Subject: [PATCH 113/903] Bump aiohue to 2.6.1 (#53887) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 05e69948218..32b3cd4ee51 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==2.6.0"], + "requirements": ["aiohue==2.6.1"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 010c42b6e74..b60db2826d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ aiohomekit==0.6.0 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.0 +aiohue==2.6.1 # homeassistant.components.imap aioimaplib==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90bafb5efe5..3d88595badf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -122,7 +122,7 @@ aiohomekit==0.6.0 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.0 +aiohue==2.6.1 # homeassistant.components.apache_kafka aiokafka==0.6.0 From d6c3d0551793135337d1ba70cd0a609c2806a7c4 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 3 Aug 2021 00:10:03 +0000 Subject: [PATCH 114/903] [ci skip] Translation update --- .../components/coinbase/translations/cs.json | 5 +++++ .../components/energy/translations/fi.json | 3 +++ .../components/flunearyou/translations/fi.json | 7 +++++++ .../components/freedompro/translations/cs.json | 18 ++++++++++++++++++ .../components/homekit/translations/nl.json | 2 +- .../components/huawei_lte/translations/nl.json | 2 +- .../components/onvif/translations/cs.json | 9 +++++++++ .../components/prosegur/translations/cs.json | 1 + .../components/prosegur/translations/fi.json | 11 +++++++++++ .../components/simplisafe/translations/he.json | 2 +- .../components/solaredge/translations/fr.json | 4 ++-- .../components/solarlog/translations/fr.json | 2 +- .../switcher_kis/translations/cs.json | 13 +++++++++++++ .../synology_dsm/translations/fi.json | 3 +++ .../components/tasmota/translations/nl.json | 2 +- .../components/tesla/translations/fi.json | 11 +++++++++++ .../yale_smart_alarm/translations/ru.json | 4 ++-- 17 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/energy/translations/fi.json create mode 100644 homeassistant/components/flunearyou/translations/fi.json create mode 100644 homeassistant/components/freedompro/translations/cs.json create mode 100644 homeassistant/components/prosegur/translations/fi.json create mode 100644 homeassistant/components/switcher_kis/translations/cs.json create mode 100644 homeassistant/components/tesla/translations/fi.json diff --git a/homeassistant/components/coinbase/translations/cs.json b/homeassistant/components/coinbase/translations/cs.json index 24dc9ec4e14..32a69bfe33d 100644 --- a/homeassistant/components/coinbase/translations/cs.json +++ b/homeassistant/components/coinbase/translations/cs.json @@ -15,5 +15,10 @@ } } } + }, + "options": { + "error": { + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + } } } \ No newline at end of file diff --git a/homeassistant/components/energy/translations/fi.json b/homeassistant/components/energy/translations/fi.json new file mode 100644 index 00000000000..c8d85790fdd --- /dev/null +++ b/homeassistant/components/energy/translations/fi.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/fi.json b/homeassistant/components/flunearyou/translations/fi.json new file mode 100644 index 00000000000..b751fda5e4c --- /dev/null +++ b/homeassistant/components/flunearyou/translations/fi.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Odottamaton virhe" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/cs.json b/homeassistant/components/freedompro/translations/cs.json new file mode 100644 index 00000000000..24f35743b7b --- /dev/null +++ b/homeassistant/components/freedompro/translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/nl.json b/homeassistant/components/homekit/translations/nl.json index b11038cc806..368005985bf 100644 --- a/homeassistant/components/homekit/translations/nl.json +++ b/homeassistant/components/homekit/translations/nl.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domeinen om op te nemen" }, - "description": "Kies de domeinen die moeten worden opgenomen. Alle ondersteunde entiteiten in het domein zullen worden opgenomen. Voor elke tv-mediaspeler en camera wordt een afzonderlijke HomeKit-instantie in accessoiremodus aangemaakt.", + "description": "Kies de domeinen die moeten worden opgenomen. Alle ondersteunde entiteiten in het domein zullen worden opgenomen. Voor elke tv-mediaspeler, camera, activiteiten gebaseerde afstandsbediening en slot wordt een afzonderlijke HomeKit-instantie in accessoiremodus aangemaakt.", "title": "Selecteer domeinen die u wilt opnemen" } } diff --git a/homeassistant/components/huawei_lte/translations/nl.json b/homeassistant/components/huawei_lte/translations/nl.json index 715efbfd506..e65c261d62b 100644 --- a/homeassistant/components/huawei_lte/translations/nl.json +++ b/homeassistant/components/huawei_lte/translations/nl.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Gebruikersnaam" }, - "description": "Voer de toegangsgegevens van het apparaat in. Opgeven van gebruikersnaam en wachtwoord is optioneel, maar biedt ondersteuning voor meer integratiefuncties. Aan de andere kant kan het gebruik van een geautoriseerde verbinding problemen veroorzaken bij het openen van het webinterface van het apparaat buiten de Home Assitant, terwijl de integratie actief is en andersom.", + "description": "Voer de toegangsgegevens van het apparaat in.", "title": "Configureer Huawei LTE" } } diff --git a/homeassistant/components/onvif/translations/cs.json b/homeassistant/components/onvif/translations/cs.json index 49e7dde324a..4ddb2091cc3 100644 --- a/homeassistant/components/onvif/translations/cs.json +++ b/homeassistant/components/onvif/translations/cs.json @@ -18,6 +18,15 @@ }, "title": "Konfigurace ov\u011b\u0159ov\u00e1n\u00ed" }, + "configure": { + "data": { + "host": "Hostitel", + "name": "Jm\u00e9no", + "password": "Heslo", + "port": "Port", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + }, "configure_profile": { "data": { "include": "Vytvo\u0159it entitu kamery" diff --git a/homeassistant/components/prosegur/translations/cs.json b/homeassistant/components/prosegur/translations/cs.json index 13c0827ff40..e5bc2b914e1 100644 --- a/homeassistant/components/prosegur/translations/cs.json +++ b/homeassistant/components/prosegur/translations/cs.json @@ -18,6 +18,7 @@ }, "user": { "data": { + "country": "Zem\u011b", "password": "Heslo", "username": "U\u017eivatelsk\u00e9 jm\u00e9no" } diff --git a/homeassistant/components/prosegur/translations/fi.json b/homeassistant/components/prosegur/translations/fi.json new file mode 100644 index 00000000000..11191d88c1b --- /dev/null +++ b/homeassistant/components/prosegur/translations/fi.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "country": "Maa" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/translations/he.json b/homeassistant/components/simplisafe/translations/he.json index 6dcf2c0c07b..dda9553f48d 100644 --- a/homeassistant/components/simplisafe/translations/he.json +++ b/homeassistant/components/simplisafe/translations/he.json @@ -12,7 +12,7 @@ "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4" }, - "description": "\u05ea\u05d5\u05e7\u05e3 \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc\u05da \u05e4\u05d2 \u05d0\u05d5 \u05d1\u05d5\u05d8\u05dc. \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da.", + "description": "\u05ea\u05d5\u05e7\u05e3 \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc\u05da \u05e4\u05d2 \u05d0\u05d5 \u05d1\u05d5\u05d8\u05dc. \u05d9\u05e9 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da.", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" }, "user": { diff --git a/homeassistant/components/solaredge/translations/fr.json b/homeassistant/components/solaredge/translations/fr.json index fb1822f9a40..638e19a2a03 100644 --- a/homeassistant/components/solaredge/translations/fr.json +++ b/homeassistant/components/solaredge/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 " + "already_configured": "L'appareil est d\u00e9ja configur\u00e9" }, "error": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "already_configured": "L'appareil est d\u00e9ja configur\u00e9", "could_not_connect": "Impossible de se connecter \u00e0 l'API solaredge", "invalid_api_key": "Cl\u00e9 API invalide", "site_not_active": "The site n'est pas actif" diff --git a/homeassistant/components/solarlog/translations/fr.json b/homeassistant/components/solarlog/translations/fr.json index 3e950af8564..b327f58adf5 100644 --- a/homeassistant/components/solarlog/translations/fr.json +++ b/homeassistant/components/solarlog/translations/fr.json @@ -5,7 +5,7 @@ }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "cannot_connect": "\u00c9chec de la connexion, veuillez v\u00e9rifier l'adresse de l'h\u00f4te." + "cannot_connect": "\u00c9chec de la connexion" }, "step": { "user": { diff --git a/homeassistant/components/switcher_kis/translations/cs.json b/homeassistant/components/switcher_kis/translations/cs.json new file mode 100644 index 00000000000..d3f0e37a132 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/cs.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyla nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed", + "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." + }, + "step": { + "confirm": { + "description": "Chcete za\u010d\u00edt nastavovat?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/fi.json b/homeassistant/components/synology_dsm/translations/fi.json index 4f5f2cc19fa..8e1cb61abce 100644 --- a/homeassistant/components/synology_dsm/translations/fi.json +++ b/homeassistant/components/synology_dsm/translations/fi.json @@ -8,6 +8,9 @@ "data": { "otp_code": "Koodi" } + }, + "reauth": { + "description": "Syy: {details}" } } }, diff --git a/homeassistant/components/tasmota/translations/nl.json b/homeassistant/components/tasmota/translations/nl.json index c099d376920..da16eb72bc3 100644 --- a/homeassistant/components/tasmota/translations/nl.json +++ b/homeassistant/components/tasmota/translations/nl.json @@ -4,7 +4,7 @@ "single_instance_allowed": "Is al geconfigureerd. Er is maar een configuratie mogelijk" }, "error": { - "invalid_discovery_topic": "Ongeldig onderwerpvoorvoegsel voor ontdekken" + "invalid_discovery_topic": "Invalid discovery topic prefix." }, "step": { "config": { diff --git a/homeassistant/components/tesla/translations/fi.json b/homeassistant/components/tesla/translations/fi.json new file mode 100644 index 00000000000..b7ed0a4bd5c --- /dev/null +++ b/homeassistant/components/tesla/translations/fi.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mfa": "MFA-koodi (valinnainen)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/ru.json b/homeassistant/components/yale_smart_alarm/translations/ru.json index aedf07d030e..1f2410be1dc 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ru.json +++ b/homeassistant/components/yale_smart_alarm/translations/ru.json @@ -9,7 +9,7 @@ "step": { "reauth_confirm": { "data": { - "area_id": "ID \u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u044f", + "area_id": "ID \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" @@ -17,7 +17,7 @@ }, "user": { "data": { - "area_id": "ID \u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u044f", + "area_id": "ID \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" From cfc511156108c2cb9299d137e32317635df25124 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 2 Aug 2021 20:50:57 -0700 Subject: [PATCH 115/903] Handle powerConsumption reports with null value (#53888) --- .../components/smartthings/sensor.py | 11 +++++++++- tests/components/smartthings/test_sensor.py | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 5059bcc4403..cb8fa4bb6d2 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -554,6 +554,8 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): """Init the class.""" super().__init__(device) self.report_name = report_name + # This is an exception for STATE_CLASS_MEASUREMENT per @balloob + self._attr_state_class = STATE_CLASS_MEASUREMENT @property def name(self) -> str: @@ -569,7 +571,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): def state(self): """Return the state of the sensor.""" value = self._device.status.attributes[Attribute.power_consumption].value - if value.get(self.report_name) is None: + if value is None or value.get(self.report_name) is None: return None if self.report_name == "power": return value[self.report_name] @@ -588,3 +590,10 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): if self.report_name == "power": return POWER_WATT return ENERGY_KILO_WATT_HOUR + + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + if self.report_name != "power": + return utc_from_timestamp(0) + return None diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index fa849a3cc67..70103c3a837 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -187,6 +187,28 @@ async def test_power_consumption_sensor(hass, device_factory): assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" + device = device_factory( + "vacuum", + [Capability.power_consumption_report], + {Attribute.power_consumption: {}}, + ) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + # 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" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + async def test_update_from_signal(hass, device_factory): """Test the binary_sensor updates when receiving a signal.""" From b6de8626de2e6a6864ce186a3552922b71a5cf9f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 2 Aug 2021 21:52:44 -0600 Subject: [PATCH 116/903] Only show a SimpliSafe code entry when one exists (#53894) --- .../components/simplisafe/alarm_control_panel.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 5c50d6a343e..7e0b64a8c32 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -1,8 +1,6 @@ """Support for SimpliSafe alarm control panels.""" from __future__ import annotations -import re - from simplipy.errors import SimplipyError from simplipy.system import SystemStates from simplipy.system.v2 import SystemV2 @@ -72,12 +70,11 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): """Initialize the SimpliSafe alarm.""" super().__init__(simplisafe, system, "Alarm Control Panel") - if isinstance( - self._simplisafe.config_entry.options.get(CONF_CODE), str - ) and re.search("^\\d+$", self._simplisafe.config_entry.options[CONF_CODE]): - self._attr_code_format = FORMAT_NUMBER - else: - self._attr_code_format = FORMAT_TEXT + if CONF_CODE in self._simplisafe.config_entry.options: + if self._simplisafe.config_entry.options[CONF_CODE].isdigit(): + self._attr_code_format = FORMAT_NUMBER + else: + self._attr_code_format = FORMAT_TEXT self._attr_supported_features = SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY self._last_event = None From 27848720a4265b49b53fc896cb1e8a819ce43a90 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Aug 2021 21:39:53 -0700 Subject: [PATCH 117/903] Bump frontend to 20210803.0 (#53897) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index f1dfa75864d..a94bcf44327 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210802.0" + "home-assistant-frontend==20210803.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 983831baaa0..9dfcc96d6da 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210802.0 +home-assistant-frontend==20210803.0 httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index b60db2826d0..a0a6a24a429 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -783,7 +783,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210802.0 +home-assistant-frontend==20210803.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d88595badf..1db11ba2ced 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -449,7 +449,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210802.0 +home-assistant-frontend==20210803.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 3f2e18fe1787f2fe9ac4a42f1ac58b9eede58984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 3 Aug 2021 11:58:27 +0200 Subject: [PATCH 118/903] Add user to homeassistant system health (#53902) --- .../components/homeassistant/strings.json | 3 ++- .../components/homeassistant/system_health.py | 1 + homeassistant/helpers/system_info.py | 5 ++++- tests/helpers/test_system_info.py | 17 +++++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 7da4a5a9d8a..09be9283b5c 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -4,6 +4,7 @@ "arch": "CPU Architecture", "dev": "Development", "docker": "Docker", + "user": "User", "hassio": "Supervisor", "installation_type": "Installation Type", "os_name": "Operating System Family", @@ -14,4 +15,4 @@ "virtualenv": "Virtual Environment" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/system_health.py b/homeassistant/components/homeassistant/system_health.py index ff3562a24f9..f13278ddfeb 100644 --- a/homeassistant/components/homeassistant/system_health.py +++ b/homeassistant/components/homeassistant/system_health.py @@ -22,6 +22,7 @@ async def system_health_info(hass): "dev": info.get("dev"), "hassio": info.get("hassio"), "docker": info.get("docker"), + "user": info.get("user"), "virtualenv": info.get("virtualenv"), "python_version": info.get("python_version"), "os_name": info.get("os_name"), diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 6d6c912f8c9..766fa90af96 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -1,6 +1,7 @@ """Helper to gather system info.""" from __future__ import annotations +from getpass import getuser import os import platform from typing import Any @@ -22,6 +23,7 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: "virtualenv": is_virtual_env(), "python_version": platform.python_version(), "docker": False, + "user": getuser(), "arch": platform.machine(), "timezone": str(hass.config.time_zone), "os_name": platform.system(), @@ -37,7 +39,8 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: # Determine installation type on current data if info_object["docker"]: - info_object["installation_type"] = "Home Assistant Container" + if info_object["user"] == "root": + info_object["installation_type"] = "Home Assistant Container" elif is_virtual_env(): info_object["installation_type"] = "Home Assistant Core" diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index e27114c1a13..fd9d488596f 100644 --- a/tests/helpers/test_system_info.py +++ b/tests/helpers/test_system_info.py @@ -1,5 +1,6 @@ """Tests for the system info helper.""" import json +from unittest.mock import patch from homeassistant.const import __version__ as current_version @@ -9,4 +10,20 @@ async def test_get_system_info(hass): info = await hass.helpers.system_info.async_get_system_info() assert isinstance(info, dict) assert info["version"] == current_version + assert info["user"] is not None assert json.dumps(info) is not None + + +async def test_container_installationtype(hass): + """Test container installation type.""" + with patch("platform.system", return_value="Linux"), patch( + "os.path.isfile", return_value=True + ): + info = await hass.helpers.system_info.async_get_system_info() + assert info["installation_type"] == "Home Assistant Container" + + with patch("platform.system", return_value="Linux"), patch( + "os.path.isfile", return_value=True + ), patch("homeassistant.helpers.system_info.getuser", return_value="user"): + info = await hass.helpers.system_info.async_get_system_info() + assert info["installation_type"] == "Unknown" From c287fc180b079bd8adc954796b75fb29bf1f4d63 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 3 Aug 2021 12:56:31 +0200 Subject: [PATCH 119/903] Add meta container data to rootfs (#53903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add meta container data to rootfs * Update builder.yml * Update .github/workflows/builder.yml Co-authored-by: Joakim Sørensen * Update .github/workflows/builder.yml Co-authored-by: Joakim Sørensen Co-authored-by: Joakim Sørensen --- .github/workflows/builder.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 89a408c3b6a..9fd827588ec 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -47,6 +47,19 @@ jobs: with: ignore-dev: true + - name: Generate meta info + shell: bash + run: | + echo "${{ env.GITHUB_SHA }};${{ env.GITHUB_REF }};${{ env.GITHUB_EVENT_NAME }};${{ env.GITHUB_ACTOR }}" > OFFICIAL_IMAGE + + - name: Signing meta info file + uses: home-assistant/actions/helpers/codenotary@master + with: + source: file://${{ github.workspace }}/OFFICIAL_IMAGE + user: ${{ secrets.VCN_USER }} + password: ${{ secrets.VCN_PASSWORD }} + organisation: home-assistant.io + build_python: name: Build PyPi package needs: init @@ -101,6 +114,11 @@ jobs: python3 script/version_bump.py nightly version="$(python setup.py -V)" + - name: Write meta info file + shell: bash + run: | + echo "${{ env.GITHUB_SHA }};${{ env.GITHUB_REF }};${{ env.GITHUB_EVENT_NAME }};${{ env.GITHUB_ACTOR }}" > rootfs/OFFICIAL_IMAGE + - name: Login to DockerHub uses: docker/login-action@v1.10.0 with: From 15d36734b0335777b8b01abc0032fc5bf2fe98a8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 3 Aug 2021 23:39:35 +1200 Subject: [PATCH 120/903] Bump aioesphomeapi to 6.0.1 (#53905) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a6792808720..22fa33091fd 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==6.0.0"], + "requirements": ["aioesphomeapi==6.0.1"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index a0a6a24a429..4c39ad91a4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==6.0.0 +aioesphomeapi==6.0.1 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1db11ba2ced..43843664265 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -103,7 +103,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==6.0.0 +aioesphomeapi==6.0.1 # homeassistant.components.flo aioflo==0.4.1 From 7e63e12ece81842a554da8dec4dc631bb8f22af3 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Tue, 3 Aug 2021 12:52:59 +0100 Subject: [PATCH 121/903] Bump pyroon to 0.0.38 (#53906) --- homeassistant/components/roon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index 354117e8fe4..f4864571735 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -3,7 +3,7 @@ "name": "RoonLabs music player", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roon", - "requirements": ["roonapi==0.0.37"], + "requirements": ["roonapi==0.0.38"], "codeowners": ["@pavoni"], "iot_class": "local_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 4c39ad91a4f..8768b8a0851 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2044,7 +2044,7 @@ rokuecp==0.8.1 roombapy==1.6.3 # homeassistant.components.roon -roonapi==0.0.37 +roonapi==0.0.38 # homeassistant.components.rova rova==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43843664265..8668422683c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1125,7 +1125,7 @@ rokuecp==0.8.1 roombapy==1.6.3 # homeassistant.components.roon -roonapi==0.0.37 +roonapi==0.0.38 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 From 1286734ce90aba375255ab282c623107965b0384 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 3 Aug 2021 13:56:56 +0200 Subject: [PATCH 122/903] Use SensorEntityDescription class for Xiaomi Miio (#53890) --- .../components/xiaomi_miio/sensor.py | 213 ++++++++---------- 1 file changed, 96 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 4663813ab7c..476132aa8b2 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -1,4 +1,6 @@ """Support for Xiaomi Mi Air Quality Monitor (PM2.5) and Humidifier.""" +from __future__ import annotations + from dataclasses import dataclass from enum import Enum import logging @@ -17,6 +19,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( @@ -45,6 +48,7 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, ) @@ -64,79 +68,104 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) -ATTR_POWER = "power" +ATTR_ACTUAL_MOTOR_SPEED = "actual_speed" +ATTR_AIR_QUALITY = "air_quality" ATTR_CHARGING = "charging" ATTR_DISPLAY_CLOCK = "display_clock" +ATTR_HUMIDITY = "humidity" +ATTR_ILLUMINANCE = "illuminance" +ATTR_LOAD_POWER = "load_power" ATTR_NIGHT_MODE = "night_mode" ATTR_NIGHT_TIME_BEGIN = "night_time_begin" ATTR_NIGHT_TIME_END = "night_time_end" +ATTR_POWER = "power" +ATTR_PRESSURE = "pressure" ATTR_SENSOR_STATE = "sensor_state" ATTR_WATER_LEVEL = "water_level" -ATTR_HUMIDITY = "humidity" -ATTR_ACTUAL_MOTOR_SPEED = "actual_speed" @dataclass -class SensorType: +class XiaomiMiioSensorDescription(SensorEntityDescription): """Class that holds device specific info for a xiaomi aqara or humidifier sensor.""" - unit: str = None - icon: str = None - device_class: str = None - state_class: str = None - valid_min_value: float = None - valid_max_value: float = None + valid_min_value: float | None = None + valid_max_value: float | None = None SENSOR_TYPES = { - "temperature": SensorType( - unit=TEMP_CELSIUS, + ATTR_TEMPERATURE: XiaomiMiioSensorDescription( + key=ATTR_TEMPERATURE, + name="Temperature", + unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), - "humidity": SensorType( - unit=PERCENTAGE, + ATTR_HUMIDITY: XiaomiMiioSensorDescription( + key=ATTR_HUMIDITY, + name="Humidity", + unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), - "pressure": SensorType( - unit=PRESSURE_HPA, + ATTR_PRESSURE: XiaomiMiioSensorDescription( + key=ATTR_PRESSURE, + name="Pressure", + unit_of_measurement=PRESSURE_HPA, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), - "load_power": SensorType( - unit=POWER_WATT, + ATTR_LOAD_POWER: XiaomiMiioSensorDescription( + key=ATTR_LOAD_POWER, + name="Load Power", + unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), - "water_level": SensorType( - unit=PERCENTAGE, + ATTR_WATER_LEVEL: XiaomiMiioSensorDescription( + key=ATTR_WATER_LEVEL, + name="Water Level", + unit_of_measurement=PERCENTAGE, icon="mdi:water-check", state_class=STATE_CLASS_MEASUREMENT, valid_min_value=0.0, valid_max_value=100.0, ), - "actual_speed": SensorType( - unit="rpm", + ATTR_ACTUAL_MOTOR_SPEED: XiaomiMiioSensorDescription( + key=ATTR_ACTUAL_MOTOR_SPEED, + name="Actual Speed", + unit_of_measurement="rpm", icon="mdi:fast-forward", state_class=STATE_CLASS_MEASUREMENT, valid_min_value=200.0, valid_max_value=2000.0, ), + ATTR_ILLUMINANCE: XiaomiMiioSensorDescription( + key=ATTR_ILLUMINANCE, + name="Illuminance", + unit_of_measurement=UNIT_LUMEN, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_AIR_QUALITY: XiaomiMiioSensorDescription( + key=ATTR_AIR_QUALITY, + unit_of_measurement="AQI", + icon="mdi:cloud", + state_class=STATE_CLASS_MEASUREMENT, + ), } -HUMIDIFIER_SENSORS = { +HUMIDIFIER_MIIO_SENSORS = { ATTR_HUMIDITY: "humidity", ATTR_TEMPERATURE: "temperature", } -HUMIDIFIER_SENSORS_MIOT = { +HUMIDIFIER_MIOT_SENSORS = { ATTR_HUMIDITY: "humidity", ATTR_TEMPERATURE: "temperature", ATTR_WATER_LEVEL: "water_level", ATTR_ACTUAL_MOTOR_SPEED: "actual_speed", } -HUMIDIFIER_SENSORS_MJJSQ = { +HUMIDIFIER_MJJSQ_SENSORS = { ATTR_HUMIDITY: "humidity", ATTR_TEMPERATURE: "temperature", } @@ -170,24 +199,23 @@ async def async_setup_entry(hass, config_entry, async_add_entities): GATEWAY_MODEL_AC_V3, GATEWAY_MODEL_EU, ]: + description = SENSOR_TYPES[ATTR_ILLUMINANCE] entities.append( XiaomiGatewayIlluminanceSensor( - gateway, config_entry.title, config_entry.unique_id + gateway, config_entry.title, config_entry.unique_id, description ) ) # Gateway sub devices sub_devices = gateway.devices coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] for sub_device in sub_devices.values(): - sensor_variables = set(sub_device.status) & set(SENSOR_TYPES) - if sensor_variables: - entities.extend( - [ - XiaomiGatewaySensor( - coordinator, sub_device, config_entry, variable - ) - for variable in sensor_variables - ] + for sensor, description in SENSOR_TYPES.items(): + if sensor not in sub_device.status: + continue + entities.append( + XiaomiGatewaySensor( + coordinator, sub_device, config_entry, description + ) ) elif config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: host = config_entry.data[CONF_HOST] @@ -197,31 +225,36 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [] if model in MODELS_HUMIDIFIER_MIOT: device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - sensors = HUMIDIFIER_SENSORS_MIOT + sensors = HUMIDIFIER_MIOT_SENSORS elif model in MODELS_HUMIDIFIER_MJJSQ: device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - sensors = HUMIDIFIER_SENSORS_MJJSQ - elif model.startswith("zhimi.humidifier."): + sensors = HUMIDIFIER_MJJSQ_SENSORS + elif model in MODELS_HUMIDIFIER_MIIO: device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - sensors = HUMIDIFIER_SENSORS + sensors = HUMIDIFIER_MIIO_SENSORS else: unique_id = config_entry.unique_id name = config_entry.title _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) device = AirQualityMonitor(host, token) + description = SENSOR_TYPES[ATTR_AIR_QUALITY] entities.append( - XiaomiAirQualityMonitor(name, device, config_entry, unique_id) + XiaomiAirQualityMonitor( + name, device, config_entry, unique_id, description + ) ) - for sensor in sensors: + for sensor, description in SENSOR_TYPES.items(): + if sensor not in sensors: + continue entities.append( XiaomiGenericSensor( - f"{config_entry.title} {sensor.replace('_', ' ').title()}", + f"{config_entry.title} {description.name}", device, config_entry, f"{sensor}_{config_entry.unique_id}", - sensor, hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, ) ) @@ -231,34 +264,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): """Representation of a Xiaomi Humidifier sensor.""" - def __init__(self, name, device, entry, unique_id, attribute, coordinator): + def __init__(self, name, device, entry, unique_id, coordinator, description): """Initialize the entity.""" super().__init__(name, device, entry, unique_id, coordinator) - self._sensor_config = SENSOR_TYPES[attribute] - self._attr_device_class = self._sensor_config.device_class - self._attr_state_class = self._sensor_config.state_class - self._attr_icon = self._sensor_config.icon self._attr_name = name self._attr_unique_id = unique_id - self._attr_unit_of_measurement = self._sensor_config.unit - self._device = device - self._entry = entry - self._attribute = attribute self._state = None + self.entity_description = description @property def state(self): """Return the state of the device.""" self._state = self._extract_value_from_attribute( - self.coordinator.data, self._attribute + self.coordinator.data, self.entity_description.key ) if ( - self._sensor_config.valid_min_value - and self._state < self._sensor_config.valid_min_value + self.entity_description.valid_min_value + and self._state < self.entity_description.valid_min_value ) or ( - self._sensor_config.valid_max_value - and self._state > self._sensor_config.valid_max_value + self.entity_description.valid_max_value + and self._state > self.entity_description.valid_max_value ): return None return self._state @@ -275,12 +301,10 @@ class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): """Representation of a Xiaomi Air Quality Monitor.""" - def __init__(self, name, device, entry, unique_id): + def __init__(self, name, device, entry, unique_id, description): """Initialize the entity.""" super().__init__(name, device, entry, unique_id) - self._icon = "mdi:cloud" - self._unit_of_measurement = "AQI" self._available = None self._state = None self._state_attrs = { @@ -293,16 +317,7 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): ATTR_NIGHT_TIME_END: None, ATTR_SENSOR_STATE: None, } - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return self._icon + self.entity_description = description @property def available(self): @@ -349,69 +364,33 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): class XiaomiGatewaySensor(XiaomiGatewayDevice, SensorEntity): """Representation of a XiaomiGatewaySensor.""" - def __init__(self, coordinator, sub_device, entry, data_key): + def __init__(self, coordinator, sub_device, entry, description): """Initialize the XiaomiSensor.""" super().__init__(coordinator, sub_device, entry) - self._data_key = data_key - self._unique_id = f"{sub_device.sid}-{data_key}" - self._name = f"{data_key} ({sub_device.sid})".capitalize() - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return SENSOR_TYPES[self._data_key].icon - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self._data_key].unit - - @property - def device_class(self): - """Return the device class of this entity.""" - return SENSOR_TYPES[self._data_key].device_class - - @property - def state_class(self): - """Return the state class of this entity.""" - return SENSOR_TYPES[self._data_key].state_class + self._unique_id = f"{sub_device.sid}-{description.key}" + self._name = f"{description.key} ({sub_device.sid})".capitalize() + self.entity_description = description @property def state(self): """Return the state of the sensor.""" - return self._sub_device.status[self._data_key] + return self._sub_device.status[self.entity_description.key] class XiaomiGatewayIlluminanceSensor(SensorEntity): """Representation of the gateway device's illuminance sensor.""" - _attr_device_class = DEVICE_CLASS_ILLUMINANCE - _attr_unit_of_measurement = UNIT_LUMEN - - def __init__(self, gateway_device, gateway_name, gateway_device_id): + def __init__(self, gateway_device, gateway_name, gateway_device_id, description): """Initialize the entity.""" + + self._attr_name = f"{gateway_name} {description.name}" + self._attr_unique_id = f"{gateway_device_id}-{description.key}" + self._attr_device_info = {"identifiers": {(DOMAIN, gateway_device_id)}} self._gateway = gateway_device - self._name = f"{gateway_name} Illuminance" - self._gateway_device_id = gateway_device_id - self._unique_id = f"{gateway_device_id}-illuminance" + self.entity_description = description self._available = False self._state = None - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def device_info(self): - """Return the device info of the gateway.""" - return {"identifiers": {(DOMAIN, self._gateway_device_id)}} - - @property - def name(self): - """Return the name of this entity, if any.""" - return self._name - @property def available(self): """Return true when state is known.""" From 081b2d533b618769fb0d3dca83d3bdc6f858d245 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 3 Aug 2021 13:30:50 +0100 Subject: [PATCH 123/903] Add support for Eve Degree's air pressure sensor (#53891) --- .../components/homekit_controller/const.py | 2 + .../homekit_controller/manifest.json | 2 +- .../components/homekit_controller/number.py | 6 +- .../components/homekit_controller/sensor.py | 8 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../specific_devices/test_eve_degree.py | 74 ++++ .../homekit_controller/eve_degree.json | 382 ++++++++++++++++++ 8 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 tests/components/homekit_controller/specific_devices/test_eve_degree.py create mode 100644 tests/fixtures/homekit_controller/eve_degree.json diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 5b4c87f53e4..9e2ac1ce75b 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -45,6 +45,8 @@ HOMEKIT_ACCESSORY_DISPATCH = { CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.Vendor.EVE_ENERGY_WATT: "sensor", + CharacteristicsTypes.Vendor.EVE_DEGREE_AIR_PRESSURE: "sensor", + CharacteristicsTypes.Vendor.EVE_DEGREE_ELEVATION: "number", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: "sensor", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: "sensor", CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: "number", diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index a4644d0e34a..442db645c1f 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.6.0"], + "requirements": ["aiohomekit==0.6.2"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index 73d8cd6adbd..79130bfcef7 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -15,7 +15,11 @@ NUMBER_ENTITIES = { CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: { "name": "Spray Quantity", "icon": "mdi:water", - } + }, + CharacteristicsTypes.Vendor.EVE_DEGREE_ELEVATION: { + "name": "Elevation", + "icon": "mdi:elevation-rise", + }, } diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 91b62b0d572..2de80eefd7e 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -9,10 +9,12 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, LIGHT_LUX, PERCENTAGE, POWER_WATT, + PRESSURE_HPA, TEMP_CELSIUS, ) from homeassistant.core import callback @@ -44,6 +46,12 @@ SIMPLE_SENSOR = { "state_class": STATE_CLASS_MEASUREMENT, "unit": POWER_WATT, }, + CharacteristicsTypes.Vendor.EVE_DEGREE_AIR_PRESSURE: { + "name": "Air Pressure", + "device_class": DEVICE_CLASS_PRESSURE, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": PRESSURE_HPA, + }, CharacteristicsTypes.get_uuid(CharacteristicsTypes.TEMPERATURE_CURRENT): { "name": "Current Temperature", "device_class": DEVICE_CLASS_TEMPERATURE, diff --git a/requirements_all.txt b/requirements_all.txt index 8768b8a0851..d6a496a8a0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioguardian==1.0.8 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.6.0 +aiohomekit==0.6.2 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8668422683c..d5ef06dac87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -115,7 +115,7 @@ aioguardian==1.0.8 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.6.0 +aiohomekit==0.6.2 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/specific_devices/test_eve_degree.py b/tests/components/homekit_controller/specific_devices/test_eve_degree.py new file mode 100644 index 00000000000..e419b140e94 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_eve_degree.py @@ -0,0 +1,74 @@ +"""Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" + +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_eve_degree_setup(hass): + """Test that the accessory can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "eve_degree.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + sensors = [ + ( + "sensor.eve_degree_aa11_temperature", + "homekit-AA00A0A00000-22", + "Eve Degree AA11 Temperature", + ), + ( + "sensor.eve_degree_aa11_humidity", + "homekit-AA00A0A00000-27", + "Eve Degree AA11 Humidity", + ), + ( + "sensor.eve_degree_aa11_air_pressure", + "homekit-AA00A0A00000-aid:1-sid:30-cid:32", + "Eve Degree AA11 - Air Pressure", + ), + ( + "sensor.eve_degree_aa11_battery", + "homekit-AA00A0A00000-17", + "Eve Degree AA11 Battery", + ), + ( + "number.eve_degree_aa11", + "homekit-AA00A0A00000-aid:1-sid:30-cid:33", + "Eve Degree AA11", + ), + ] + + device_ids = set() + + for (entity_id, unique_id, friendly_name) in sensors: + entry = entity_registry.async_get(entity_id) + assert entry.unique_id == unique_id + + helper = Helper( + hass, + entity_id, + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == friendly_name + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == "Elgato" + assert device.name == "Eve Degree AA11" + assert device.model == "Eve Degree 00AAA0000" + assert device.sw_version == "1.2.8" + assert device.via_device_id is None + + device_ids.add(entry.device_id) + + # All entities should be part of same device + assert len(device_ids) == 1 diff --git a/tests/fixtures/homekit_controller/eve_degree.json b/tests/fixtures/homekit_controller/eve_degree.json new file mode 100644 index 00000000000..2a1217789c4 --- /dev/null +++ b/tests/fixtures/homekit_controller/eve_degree.json @@ -0,0 +1,382 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "format": "string", + "iid": 2, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Eve Degree AA11" + }, + { + "format": "bool", + "iid": 3, + "perms": [ + "pw" + ], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "format": "string", + "iid": 4, + "perms": [ + "pr" + ], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Elgato" + }, + { + "format": "string", + "iid": 5, + "perms": [ + "pr" + ], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Eve Degree 00AAA0000" + }, + { + "format": "string", + "iid": 6, + "perms": [ + "pr" + ], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "AA00A0A00000" + }, + { + "format": "string", + "iid": 7, + "perms": [ + "pr" + ], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "1.2.8" + }, + { + "format": "string", + "iid": 8, + "perms": [ + "pr" + ], + "type": "00000053-0000-1000-8000-0026BB765291", + "value": "1.0.0" + } + ], + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 18, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Battery" + }, + { + "format": "uint8", + "iid": 19, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "00000068-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 65 + }, + { + "format": "uint8", + "iid": 20, + "maxValue": 2, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "0000008F-0000-1000-8000-0026BB765291", + "value": 2 + }, + { + "format": "uint8", + "iid": 21, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "00000079-0000-1000-8000-0026BB765291", + "value": 0 + } + ], + "iid": 17, + "type": "00000096-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 23, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Eve Degree" + }, + { + "format": "float", + "iid": 24, + "maxValue": 100, + "minStep": 0.1, + "minValue": -30, + "perms": [ + "pr", + "ev" + ], + "type": "00000011-0000-1000-8000-0026BB765291", + "unit": "celsius", + "value": 22.77191162109375 + }, + { + "format": "uint8", + "iid": 25, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000036-0000-1000-8000-0026BB765291", + "value": 0 + } + ], + "iid": 22, + "type": "0000008A-0000-1000-8000-0026BB765291", + "primary": true + }, + { + "characteristics": [ + { + "format": "string", + "iid": 28, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Eve Degree" + }, + { + "format": "float", + "iid": 29, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "00000010-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 59.4818115234375 + } + ], + "iid": 27, + "type": "00000082-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 31, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Eve Degree" + }, + { + "format": "float", + "iid": 32, + "maxValue": 1100, + "minStep": 1, + "minValue": 870, + "perms": [ + "pr" + ], + "type": "E863F10F-079E-48FF-8F27-9C2605A29F52", + "value": 1005.7000122070312 + }, + { + "format": "float", + "iid": 33, + "maxValue": 9000, + "minStep": 1, + "minValue": -450, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E863F130-079E-48FF-8F27-9C2605A29F52", + "value": 0 + }, + { + "format": "uint8", + "iid": 34, + "maxValue": 4, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "E863F135-079E-48FF-8F27-9C2605A29F52", + "value": 0 + } + ], + "iid": 30, + "type": "E863F00A-079E-48FF-8F27-9C2605A29F52" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 36, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Logging" + }, + { + "format": "data", + "iid": 37, + "perms": [ + "pr", + "pw" + ], + "type": "E863F11E-079E-48FF-8F27-9C2605A29F52", + "value": "HwABDh4AeAQKAIDVzj5aDMB/" + }, + { + "format": "uint32", + "iid": 38, + "maxValue": 4294967295, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw" + ], + "type": "E863F112-079E-48FF-8F27-9C2605A29F52", + "value": 0 + }, + { + "format": "data", + "iid": 39, + "perms": [ + "pw" + ], + "type": "E863F11C-079E-48FF-8F27-9C2605A29F52" + }, + { + "format": "data", + "iid": 40, + "perms": [ + "pw" + ], + "type": "E863F121-079E-48FF-8F27-9C2605A29F52" + }, + { + "format": "data", + "iid": 41, + "perms": [ + "pr" + ], + "type": "E863F116-079E-48FF-8F27-9C2605A29F52", + "value": "/wkAAJEGAABnvbUmBQECAgIDAh4BJwEGAAAQuvIBAAEAAAABAA==" + }, + { + "format": "data", + "iid": 42, + "perms": [ + "pr" + ], + "type": "E863F117-079E-48FF-8F27-9C2605A29F52", + "value": "" + }, + { + "format": "tlv8", + "iid": 43, + "perms": [ + "pr" + ], + "type": "E863F131-079E-48FF-8F27-9C2605A29F52", + "value": "AAIeAAMCeAQEDFNVMTNHMUEwMDI4MAYCBgAHBLryAQALAgAABQEAAgTwKQAAXwQAAAAAGQIABRQBAw8EAAAAABoEAAAAACUE9griHtJHEAABQEJcLdwpUbihgRCESYX8bA7yLTF6IKhlxv5ohrqDkOEyRTNCM0VDNC1CNENCLTg0MjYtM0Q1QS0zMDJFNEIzRTZERDA=" + }, + { + "format": "tlv8", + "iid": 44, + "perms": [ + "pw" + ], + "type": "E863F11D-079E-48FF-8F27-9C2605A29F52" + } + ], + "iid": 35, + "type": "E863F007-079E-48FF-8F27-9C2605A29F52", + "hidden": true + }, + { + "characteristics": [ + { + "format": "string", + "iid": 100001, + "perms": [ + "pr" + ], + "type": "E863F155-079E-48FF-8F27-9C2605A29F52", + "value": "11:11:11:11:11:11" + }, + { + "format": "uint16", + "iid": 100002, + "perms": [ + "pr" + ], + "type": "E863F156-079E-48FF-8F27-9C2605A29F52", + "value": 10 + }, + { + "format": "uint8", + "iid": 100003, + "perms": [ + "pr", + "ev" + ], + "type": "E863F157-079E-48FF-8F27-9C2605A29F52", + "value": 1 + } + ], + "hidden": true, + "iid": 100000, + "type": "E863F00B-079E-48FF-8F27-9C2605A29F52" + } + ] + } +] \ No newline at end of file From 672a74fa371f117edfb37597ff6460edacbc208a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 4 Aug 2021 00:53:48 +1200 Subject: [PATCH 124/903] Allow esphome entities to be disabled by default (#53898) --- homeassistant/components/esphome/__init__.py | 5 +++++ homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 2efe005230f..2e33742b8e5 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -978,3 +978,8 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): def should_poll(self) -> bool: """Disable polling.""" return False + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return not self._static_info.disabled_by_default diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 22fa33091fd..f702b35e4c8 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==6.0.1"], + "requirements": ["aioesphomeapi==6.1.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index d6a496a8a0d..15ae09b74b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==6.0.1 +aioesphomeapi==6.1.0 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5ef06dac87..ac0d3e3d991 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -103,7 +103,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==6.0.1 +aioesphomeapi==6.1.0 # homeassistant.components.flo aioflo==0.4.1 From 2105419a4e54d925a19f2a993072354549316ae3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 3 Aug 2021 15:58:30 +0200 Subject: [PATCH 125/903] Use `SelectEntityDescription` for Xiaomi Miio integration (#53907) * Use SelectEntityDescription * Use SelectEntityDescription * Remove service field from XiaomiMiioSelectDescription class * Fix typo * Use lowercase for options --- .../components/xiaomi_miio/select.py | 54 +++++++++---------- .../xiaomi_miio/strings.select.json | 9 ++++ 2 files changed, 34 insertions(+), 29 deletions(-) create mode 100644 homeassistant/components/xiaomi_miio/strings.select.json diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 23e43e4dbbd..f07eec960fc 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -1,11 +1,13 @@ """Support led_brightness for Mi Air Humidifier.""" +from __future__ import annotations + from dataclasses import dataclass from enum import Enum from miio.airhumidifier import LedBrightness as AirhumidifierLedBrightness from miio.airhumidifier_miot import LedBrightness as AirhumidifierMiotLedBrightness -from homeassistant.components.select import SelectEntity +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import callback from .const import ( @@ -18,7 +20,6 @@ from .const import ( KEY_DEVICE, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, - SERVICE_SET_LED_BRIGHTNESS, ) from .device import XiaomiCoordinatedMiioEntity @@ -34,23 +35,19 @@ LED_BRIGHTNESS_REVERSE_MAP_MIOT = { @dataclass -class SelectorType: - """Class that holds device specific info for a xiaomi aqara or humidifier selectors.""" +class XiaomiMiioSelectDescription(SelectEntityDescription): + """A class that describes select entities.""" - name: str = None - icon: str = None - short_name: str = None - options: list = None - service: str = None + options: tuple = () SELECTOR_TYPES = { - FEATURE_SET_LED_BRIGHTNESS: SelectorType( - name="Led brightness", + FEATURE_SET_LED_BRIGHTNESS: XiaomiMiioSelectDescription( + key=ATTR_LED_BRIGHTNESS, + name="Led Brightness", icon="mdi:brightness-6", - short_name=ATTR_LED_BRIGHTNESS, - options=["Bright", "Dim", "Off"], - service=SERVICE_SET_LED_BRIGHTNESS, + device_class="xiaomi_miio__led_brightness", + options=("bright", "dim", "off"), ), } @@ -71,15 +68,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): else: return - selector = SELECTOR_TYPES[FEATURE_SET_LED_BRIGHTNESS] + description = SELECTOR_TYPES[FEATURE_SET_LED_BRIGHTNESS] entities.append( entity_class( - f"{config_entry.title} {selector.name}", + f"{config_entry.title} {description.name}", device, config_entry, - f"{selector.short_name}_{config_entry.unique_id}", - selector, + f"{description.key}_{config_entry.unique_id}", hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, ) ) @@ -89,12 +86,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class XiaomiSelector(XiaomiCoordinatedMiioEntity, SelectEntity): """Representation of a generic Xiaomi attribute selector.""" - def __init__(self, name, device, entry, unique_id, selector, coordinator): + def __init__(self, name, device, entry, unique_id, coordinator, description): """Initialize the generic Xiaomi attribute selector.""" super().__init__(name, device, entry, unique_id, coordinator) - self._attr_icon = selector.icon - self._controller = selector - self._attr_options = self._controller.options + self._attr_options = list(description.options) + self.entity_description = description @staticmethod def _extract_value_from_attribute(state, attribute): @@ -108,33 +104,33 @@ class XiaomiSelector(XiaomiCoordinatedMiioEntity, SelectEntity): class XiaomiAirHumidifierSelector(XiaomiSelector): """Representation of a Xiaomi Air Humidifier selector.""" - def __init__(self, name, device, entry, unique_id, controller, coordinator): + def __init__(self, name, device, entry, unique_id, coordinator, description): """Initialize the plug switch.""" - super().__init__(name, device, entry, unique_id, controller, coordinator) + super().__init__(name, device, entry, unique_id, coordinator, description) self._current_led_brightness = self._extract_value_from_attribute( - self.coordinator.data, self._controller.short_name + self.coordinator.data, self.entity_description.key ) @callback def _handle_coordinator_update(self): """Fetch state from the device.""" self._current_led_brightness = self._extract_value_from_attribute( - self.coordinator.data, self._controller.short_name + self.coordinator.data, self.entity_description.key ) self.async_write_ha_state() @property def current_option(self): """Return the current option.""" - return self.led_brightness + return self.led_brightness.lower() async def async_select_option(self, option: str) -> None: """Set an option of the miio device.""" if option not in self.options: raise ValueError( - f"Selection '{option}' is not a valid {self._controller.name}" + f"Selection '{option}' is not a valid {self.entity_description.name}" ) - await self.async_set_led_brightness(option) + await self.async_set_led_brightness(option.title()) @property def led_brightness(self): diff --git a/homeassistant/components/xiaomi_miio/strings.select.json b/homeassistant/components/xiaomi_miio/strings.select.json new file mode 100644 index 00000000000..80edde042ce --- /dev/null +++ b/homeassistant/components/xiaomi_miio/strings.select.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Bright", + "dim": "Dim", + "off": "Off" + } + } + } \ No newline at end of file From 56360feb9aa9e8ba62d4aef19b1cc418d9ae5cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 3 Aug 2021 16:48:22 +0200 Subject: [PATCH 126/903] Stream API requests to the supervisor (#53909) --- homeassistant/components/hassio/http.py | 69 +++++++++++++------------ tests/components/hassio/test_http.py | 19 ++++--- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 302cc00bb9f..4a0def62b4d 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -8,9 +8,14 @@ import re import aiohttp from aiohttp import web -from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE +from aiohttp.client import ClientTimeout +from aiohttp.hdrs import ( + CONTENT_ENCODING, + CONTENT_LENGTH, + CONTENT_TYPE, + TRANSFER_ENCODING, +) from aiohttp.web_exceptions import HTTPBadGateway -import async_timeout from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.onboarding import async_is_onboarded @@ -75,14 +80,11 @@ class HassIOView(HomeAssistantView): async def _command_proxy( self, path: str, request: web.Request - ) -> web.Response | web.StreamResponse: + ) -> web.StreamResponse: """Return a client request with proxy origin for Hass.io supervisor. This method is a coroutine. """ - read_timeout = _get_timeout(path) - client_timeout = 10 - data = None headers = _init_header(request) if path in ("snapshots/new/upload", "backups/new/upload"): # We need to reuse the full content type that includes the boundary @@ -90,34 +92,20 @@ class HassIOView(HomeAssistantView): "Content-Type" ] = request._stored_content_type # pylint: disable=protected-access - # Backups are big, so we need to adjust the allowed size - request._client_max_size = ( # pylint: disable=protected-access - MAX_UPLOAD_SIZE - ) - client_timeout = 300 - try: - with async_timeout.timeout(client_timeout): - data = await request.read() - - method = getattr(self._websession, request.method.lower()) - client = await method( - f"http://{self._host}/{path}", - data=data, + client = await self._websession.request( + method=request.method, + url=f"http://{self._host}/{path}", + params=request.query, + data=request.content, headers=headers, - timeout=read_timeout, + timeout=_get_timeout(path), ) - # Simple request - if int(client.headers.get(CONTENT_LENGTH, 0)) < 4194000: - # Return Response - body = await client.read() - return web.Response( - content_type=client.content_type, status=client.status, body=body - ) - # Stream response - response = web.StreamResponse(status=client.status, headers=client.headers) + response = web.StreamResponse( + status=client.status, headers=_response_header(client) + ) response.content_type = client.content_type await response.prepare(request) @@ -151,11 +139,28 @@ def _init_header(request: web.Request) -> dict[str, str]: return headers -def _get_timeout(path: str) -> int: +def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: + """Create response header.""" + headers = {} + + for name, value in response.headers.items(): + if name in ( + TRANSFER_ENCODING, + CONTENT_LENGTH, + CONTENT_TYPE, + CONTENT_ENCODING, + ): + continue + headers[name] = value + + return headers + + +def _get_timeout(path: str) -> ClientTimeout: """Return timeout for a URL path.""" if NO_TIMEOUT.match(path): - return 0 - return 300 + return ClientTimeout(connect=10, total=None) + return ClientTimeout(connect=10, total=300) def _need_auth(hass, path: str) -> bool: diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index fc4bb3e6a0d..f411b465774 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -1,7 +1,7 @@ """The tests for the hassio component.""" import asyncio -from unittest.mock import patch +from aiohttp import StreamReader import pytest from homeassistant.components.hassio.http import _need_auth @@ -106,13 +106,11 @@ async def test_forward_log_request(hassio_client, aioclient_mock): assert len(aioclient_mock.mock_calls) == 1 -async def test_bad_gateway_when_cannot_find_supervisor(hassio_client): +async def test_bad_gateway_when_cannot_find_supervisor(hassio_client, aioclient_mock): """Test we get a bad gateway error if we can't find supervisor.""" - with patch( - "homeassistant.components.hassio.http.async_timeout.timeout", - side_effect=asyncio.TimeoutError, - ): - resp = await hassio_client.get("/api/hassio/addons/test/info") + aioclient_mock.get("http://127.0.0.1/addons/test/info", exc=asyncio.TimeoutError) + + resp = await hassio_client.get("/api/hassio/addons/test/info") assert resp.status == 502 @@ -180,3 +178,10 @@ def test_need_auth(hass): hass.data["onboarding"] = False assert not _need_auth(hass, "backups/new/upload") assert not _need_auth(hass, "supervisor/logs") + + +async def test_stream(hassio_client, aioclient_mock): + """Verify that the request is a stream.""" + aioclient_mock.get("http://127.0.0.1/test") + await hassio_client.get("/api/hassio/test", data="test") + assert isinstance(aioclient_mock.mock_calls[-1][2], StreamReader) From 7518c58806deeedcc46ae3ba8a97d6973f41bc78 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 3 Aug 2021 08:56:15 -0600 Subject: [PATCH 127/903] Handle scenario where SimpliSafe code is falsey (#53912) --- homeassistant/components/simplisafe/alarm_control_panel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 7e0b64a8c32..8520cd2b50f 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -70,8 +70,8 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): """Initialize the SimpliSafe alarm.""" super().__init__(simplisafe, system, "Alarm Control Panel") - if CONF_CODE in self._simplisafe.config_entry.options: - if self._simplisafe.config_entry.options[CONF_CODE].isdigit(): + if code := self._simplisafe.config_entry.options.get(CONF_CODE): + if code.isdigit(): self._attr_code_format = FORMAT_NUMBER else: self._attr_code_format = FORMAT_TEXT From fa9ac71c3ac97a8d9cdec932257f5a954bece896 Mon Sep 17 00:00:00 2001 From: Jim Shank Date: Tue, 3 Aug 2021 09:26:21 -0700 Subject: [PATCH 128/903] Check for torrents in queue before calling the api stop_torrent() (#53895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- homeassistant/components/transmission/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index e0ced70f15e..40edc8aeab9 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -447,6 +447,8 @@ class TransmissionData: def stop_torrents(self): """Stop all active torrents.""" + if len(self._torrents) == 0: + return torrent_ids = [torrent.id for torrent in self._torrents] self._api.stop_torrent(torrent_ids) From df03cce471cb6368bd0d52ce95a4f13a97cb0bea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Aug 2021 12:09:10 -0500 Subject: [PATCH 129/903] Enforce maximum length for HomeKit characteristics (#53913) --- .../components/homekit/accessories.py | 17 ++++++--- homeassistant/components/homekit/const.py | 7 ++++ homeassistant/components/homekit/type_fans.py | 4 +- .../components/homekit/type_media_players.py | 3 +- .../components/homekit/type_remotes.py | 7 +++- tests/components/homekit/test_accessories.py | 37 +++++++++++++------ 6 files changed, 55 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index c13bb870551..e86135fb9a1 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -64,6 +64,11 @@ from .const import ( HK_NOT_CHARGABLE, HK_NOT_CHARGING, MANUFACTURER, + MAX_MANUFACTURER_LENGTH, + MAX_MODEL_LENGTH, + MAX_NAME_LENGTH, + MAX_SERIAL_LENGTH, + MAX_VERSION_LENGTH, SERV_BATTERY_SERVICE, SERVICE_HOMEKIT_RESET_ACCESSORY, TYPE_FAUCET, @@ -217,7 +222,9 @@ class HomeAccessory(Accessory): **kwargs, ): """Initialize a Accessory object.""" - super().__init__(driver=driver, display_name=name, aid=aid, *args, **kwargs) + super().__init__( + driver=driver, display_name=name[:MAX_NAME_LENGTH], aid=aid, *args, **kwargs + ) self.config = config or {} domain = split_entity_id(entity_id)[0].replace("_", " ") @@ -237,10 +244,10 @@ class HomeAccessory(Accessory): sw_version = __version__ self.set_info_service( - manufacturer=manufacturer, - model=model, - serial_number=entity_id, - firmware_revision=sw_version, + manufacturer=manufacturer[:MAX_MANUFACTURER_LENGTH], + model=model[:MAX_MODEL_LENGTH], + serial_number=entity_id[:MAX_SERIAL_LENGTH], + firmware_revision=sw_version[:MAX_VERSION_LENGTH], ) self.category = category diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index d2802ec8fb0..30b2a1c2597 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -293,3 +293,10 @@ CONFIG_OPTIONS = [ CONF_ENTITY_CONFIG, CONF_HOMEKIT_MODE, ] + +# ### Maximum Lengths ### +MAX_NAME_LENGTH = 64 +MAX_SERIAL_LENGTH = 64 +MAX_MODEL_LENGTH = 64 +MAX_VERSION_LENGTH = 64 +MAX_MANUFACTURER_LENGTH = 64 diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 1efb3b6c8be..1a0bb41774c 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -39,6 +39,7 @@ from .const import ( CHAR_ROTATION_DIRECTION, CHAR_ROTATION_SPEED, CHAR_SWING_MODE, + MAX_NAME_LENGTH, PROP_MIN_STEP, SERV_FANV2, SERV_SWITCH, @@ -100,7 +101,8 @@ class Fan(HomeAccessory): preset_serv = self.add_preload_service(SERV_SWITCH, CHAR_NAME) serv_fan.add_linked_service(preset_serv) preset_serv.configure_char( - CHAR_NAME, value=f"{self.display_name} {preset_mode}" + CHAR_NAME, + value=f"{self.display_name} {preset_mode}"[:MAX_NAME_LENGTH], ) self.preset_mode_chars[preset_mode] = preset_serv.configure_char( diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 5cd27109bd8..081053d2591 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -55,6 +55,7 @@ from .const import ( FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, KEY_PLAY_PAUSE, + MAX_NAME_LENGTH, SERV_SWITCH, SERV_TELEVISION_SPEAKER, ) @@ -134,7 +135,7 @@ class MediaPlayer(HomeAccessory): def generate_service_name(self, mode): """Generate name for individual service.""" - return f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}" + return f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}"[:MAX_NAME_LENGTH] def set_on_off(self, value): """Move switch state to value if call came from HomeKit.""" diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 718671dfd1d..9e54221430c 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -47,6 +47,7 @@ from .const import ( KEY_PREVIOUS_TRACK, KEY_REWIND, KEY_SELECT, + MAX_NAME_LENGTH, SERV_INPUT_SOURCE, SERV_TELEVISION, ) @@ -120,8 +121,10 @@ class RemoteInputSelectAccessory(HomeAccessory): SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME] ) serv_tv.add_linked_service(serv_input) - serv_input.configure_char(CHAR_CONFIGURED_NAME, value=source) - serv_input.configure_char(CHAR_NAME, value=source) + serv_input.configure_char( + CHAR_CONFIGURED_NAME, value=source[:MAX_NAME_LENGTH] + ) + serv_input.configure_char(CHAR_NAME, value=source[:MAX_NAME_LENGTH]) serv_input.configure_char(CHAR_IDENTIFIER, value=index) serv_input.configure_char(CHAR_IS_CONFIGURED, value=True) input_type = 3 if "hdmi" in source.lower() else 0 diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 987a90ce61a..9b70bf4830e 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -66,7 +66,7 @@ async def test_accessory_cancels_track_state_change_on_stop(hass, hk_driver): async def test_home_accessory(hass, hk_driver): """Test HomeAccessory class.""" entity_id = "sensor.accessory" - entity_id2 = "light.accessory" + entity_id2 = "light.accessory_that_exceeds_the_maximum_maximum_maximum_maximum_maximum_maximum_maximum_allowed_length" hass.states.async_set(entity_id, None) hass.states.async_set(entity_id2, STATE_UNAVAILABLE) @@ -94,27 +94,42 @@ async def test_home_accessory(hass, hk_driver): assert serv.get_characteristic(CHAR_NAME).value == "Home Accessory" assert serv.get_characteristic(CHAR_MANUFACTURER).value == f"{MANUFACTURER} Light" assert serv.get_characteristic(CHAR_MODEL).value == "Light" - assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == "light.accessory" + assert ( + serv.get_characteristic(CHAR_SERIAL_NUMBER).value + == "light.accessory_that_exceeds_the_maximum_maximum_maximum_maximum" + ) acc3 = HomeAccessory( hass, hk_driver, - "Home Accessory", + "Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length", entity_id2, 3, { - ATTR_MODEL: "Awesome", - ATTR_MANUFACTURER: "Lux Brands", - ATTR_SW_VERSION: "0.4.3", - ATTR_INTEGRATION: "luxe", + ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", + ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", + ATTR_SW_VERSION: "0.4.3 that exceeds the maximum maximum maximum maximum maximum maximum length", + ATTR_INTEGRATION: "luxe that exceeds the maximum maximum maximum maximum maximum maximum length", }, ) assert acc3.available is False serv = acc3.services[0] # SERV_ACCESSORY_INFO - assert serv.get_characteristic(CHAR_NAME).value == "Home Accessory" - assert serv.get_characteristic(CHAR_MANUFACTURER).value == "Lux Brands" - assert serv.get_characteristic(CHAR_MODEL).value == "Awesome" - assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == "light.accessory" + assert ( + serv.get_characteristic(CHAR_NAME).value + == "Home Accessory that exceeds the maximum maximum maximum maximum " + ) + assert ( + serv.get_characteristic(CHAR_MANUFACTURER).value + == "Lux Brands that exceeds the maximum maximum maximum maximum maxi" + ) + assert ( + serv.get_characteristic(CHAR_MODEL).value + == "Awesome Model that exceeds the maximum maximum maximum maximum m" + ) + assert ( + serv.get_characteristic(CHAR_SERIAL_NUMBER).value + == "light.accessory_that_exceeds_the_maximum_maximum_maximum_maximum" + ) hass.states.async_set(entity_id, "on") await hass.async_block_till_done() From 71375be54dcc08d7abfa7d1e7c635815cfe25fb8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 3 Aug 2021 11:16:00 -0700 Subject: [PATCH 130/903] Handle Shelly get name on uninitialized device (#53917) --- homeassistant/components/shelly/logbook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index 5b0ada6f166..deac3b5c05b 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -29,7 +29,7 @@ def async_describe_events( def async_describe_shelly_click_event(event: EventType) -> dict[str, str]: """Describe shelly.click logbook event.""" wrapper = get_device_wrapper(hass, event.data[ATTR_DEVICE_ID]) - if wrapper: + if wrapper and wrapper.device.initialized: device_name = get_device_name(wrapper.device) else: device_name = event.data[ATTR_DEVICE] From f02259eb2d68418893d0cac3c17e42e9f98fd0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 3 Aug 2021 20:20:12 +0200 Subject: [PATCH 131/903] Limit API usage for Uptime Robot (#53918) --- .../components/uptimerobot/binary_sensor.py | 137 +++++++++++------- 1 file changed, 85 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 6c0bb63c70f..dd7254fb1ca 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -1,6 +1,9 @@ """A platform that to monitor Uptime Robot monitors.""" +from dataclasses import dataclass +from datetime import timedelta import logging +import async_timeout from pyuptimerobot import UptimeRobot import voluptuous as vol @@ -8,9 +11,17 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, PLATFORM_SCHEMA, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) _LOGGER = logging.getLogger(__name__) @@ -21,69 +32,91 @@ ATTRIBUTION = "Data provided by Uptime Robot" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) -def setup_platform(hass, config, add_entities, discovery_info=None): +@dataclass +class UptimeRobotBinarySensorEntityDescription(BinarySensorEntityDescription): + """Entity description for UptimeRobotBinarySensor.""" + + target: str = "" + + +async def async_setup_platform( + hass: HomeAssistant, config, async_add_entities, discovery_info=None +): """Set up the Uptime Robot binary_sensors.""" + uptime_robot_api = UptimeRobot() + api_key = config[CONF_API_KEY] - up_robot = UptimeRobot() - api_key = config.get(CONF_API_KEY) - monitors = up_robot.getMonitors(api_key) + async def async_update_data(): + """Fetch data from API UptimeRobot API.""" - devices = [] - if not monitors or monitors.get("stat") != "ok": + def api_wrapper(): + return uptime_robot_api.getMonitors(api_key) + + async with async_timeout.timeout(10): + monitors = await hass.async_add_executor_job(api_wrapper) + if not monitors or monitors.get("stat") != "ok": + raise UpdateFailed("Error communicating with Uptime Robot API") + return monitors + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="uptimerobot", + update_method=async_update_data, + update_interval=timedelta(seconds=60), + ) + + await coordinator.async_refresh() + + if not coordinator.data or coordinator.data.get("stat") != "ok": _LOGGER.error("Error connecting to Uptime Robot") - return + raise PlatformNotReady() - for monitor in monitors["monitors"]: - devices.append( + async_add_entities( + [ UptimeRobotBinarySensor( - api_key, - up_robot, - monitor["id"], - monitor["friendly_name"], - monitor["url"], + coordinator, + UptimeRobotBinarySensorEntityDescription( + key=monitor["id"], + name=monitor["friendly_name"], + target=monitor["url"], + device_class=DEVICE_CLASS_CONNECTIVITY, + ), ) - ) - - add_entities(devices, True) + for monitor in coordinator.data["monitors"] + ], + True, + ) -class UptimeRobotBinarySensor(BinarySensorEntity): +class UptimeRobotBinarySensor(BinarySensorEntity, CoordinatorEntity): """Representation of a Uptime Robot binary sensor.""" - def __init__(self, api_key, up_robot, monitor_id, name, target): + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: UptimeRobotBinarySensorEntityDescription, + ) -> None: """Initialize Uptime Robot the binary sensor.""" - self._api_key = api_key - self._monitor_id = str(monitor_id) - self._name = name - self._target = target - self._up_robot = up_robot - self._state = None + super().__init__(coordinator) + self.coordinator = coordinator + self.entity_description = description + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_TARGET: self.entity_description.target, + } - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self._state - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_CONNECTIVITY - - @property - def extra_state_attributes(self): - """Return the state attributes of the binary sensor.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_TARGET: self._target} - - def update(self): + async def async_update(self): """Get the latest state of the binary sensor.""" - monitor = self._up_robot.getMonitors(self._api_key, self._monitor_id) - if not monitor or monitor.get("stat") != "ok": - _LOGGER.warning("Failed to get new state") - return - status = monitor["monitors"][0]["status"] - self._state = 1 if status == 2 else 0 + if monitor := get_monitor_by_id( + self.coordinator.data.get("monitors", []), self.entity_description.key + ): + self._attr_is_on = monitor["status"] == 2 + + +def get_monitor_by_id(monitors, monitor_id): + """Return the monitor object matching the id.""" + filtered = [monitor for monitor in monitors if monitor["id"] == monitor_id] + if len(filtered) == 0: + return + return filtered[0] From c959a0a484e085b4fbb9c30a822a309e58dba00a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 3 Aug 2021 22:50:14 +0200 Subject: [PATCH 132/903] Limit zwave_js meter sensor last reset (#53921) --- homeassistant/components/zwave_js/sensor.py | 20 ++++++++------ tests/components/zwave_js/common.py | 3 ++- tests/components/zwave_js/test_sensor.py | 29 ++++++++++++++++----- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 304a80f7940..7b491661e68 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -248,20 +248,20 @@ class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): # Entity class attributes self._attr_state_class = STATE_CLASS_MEASUREMENT - self._attr_last_reset = dt.utc_from_timestamp(0) + if self.device_class == DEVICE_CLASS_ENERGY: + self._attr_last_reset = dt.utc_from_timestamp(0) @callback def async_update_last_reset( self, node: ZwaveNode, endpoint: int, meter_type: int | None ) -> None: """Update last reset.""" - # If the signal is not for this node or is for a different endpoint, ignore it - if self.info.node != node or self.info.primary_value.endpoint != endpoint: - return - # If a meter type was specified and doesn't match this entity's meter type, - # ignore it + # If the signal is not for this node or is for a different endpoint, + # or a meter type was specified and doesn't match this entity's meter type: if ( - meter_type is not None + self.info.node != node + or self.info.primary_value.endpoint != endpoint + or meter_type is not None and self.info.primary_value.metadata.cc_specific.get("meterType") != meter_type ): @@ -274,6 +274,10 @@ class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): """Call when entity is added.""" await super().async_added_to_hass() + # If the meter is not an accumulating meter type, do not reset. + if self.device_class != DEVICE_CLASS_ENERGY: + return + # Restore the last reset time from stored state restored_state = await self.async_get_last_state() if restored_state and ATTR_LAST_RESET in restored_state.attributes: @@ -310,7 +314,7 @@ class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): primary_value.endpoint, options, ) - self._attr_last_reset = dt.utcnow() + # Notify meters that may have been reset async_dispatcher_send( self.hass, diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 8c8a3f2e576..44943fed9fb 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -33,7 +33,8 @@ ID_LOCK_CONFIG_PARAMETER_SENSOR = ( "sensor.z_wave_module_for_id_lock_150_and_101_config_parameter_door_lock_mode" ) ZEN_31_ENTITY = "light.kitchen_under_cabinet_lights" -METER_SENSOR = "sensor.smart_switch_6_electric_consumed_v" +METER_ENERGY_SENSOR = "sensor.smart_switch_6_electric_consumed_kwh" +METER_VOLTAGE_SENSOR = "sensor.smart_switch_6_electric_consumed_v" DATETIME_ZERO = datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc) DATETIME_LAST_RESET = datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 9fa4152ad6b..04583559421 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -36,7 +36,8 @@ from .common import ( HUMIDITY_SENSOR, ID_LOCK_CONFIG_PARAMETER_SENSOR, INDICATOR_SENSOR, - METER_SENSOR, + METER_ENERGY_SENSOR, + METER_VOLTAGE_SENSOR, NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, VOLTAGE_SENSOR, @@ -202,9 +203,13 @@ async def test_reset_meter( client.async_send_command.return_value = {} client.async_send_command_no_wait.return_value = {} + # Validate that non accumulating meter does not have a last reset attribute + + assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes + # Validate that the sensor last reset is starting from nothing assert ( - hass.states.get(METER_SENSOR).attributes[ATTR_LAST_RESET] + hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] == DATETIME_ZERO.isoformat() ) @@ -215,13 +220,13 @@ async def test_reset_meter( DOMAIN, SERVICE_RESET_METER, { - ATTR_ENTITY_ID: METER_SENSOR, + ATTR_ENTITY_ID: METER_ENERGY_SENSOR, }, blocking=True, ) assert ( - hass.states.get(METER_SENSOR).attributes[ATTR_LAST_RESET] + hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] == DATETIME_LAST_RESET.isoformat() ) @@ -232,6 +237,10 @@ async def test_reset_meter( assert args["endpoint"] == 0 assert args["args"] == [] + # Validate that non accumulating meter does not have a last reset attribute + + assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes + client.async_send_command_no_wait.reset_mock() # Test successful meter reset call with options @@ -239,7 +248,7 @@ async def test_reset_meter( DOMAIN, SERVICE_RESET_METER, { - ATTR_ENTITY_ID: METER_SENSOR, + ATTR_ENTITY_ID: METER_ENERGY_SENSOR, ATTR_METER_TYPE: 1, ATTR_VALUE: 2, }, @@ -253,6 +262,10 @@ async def test_reset_meter( assert args["endpoint"] == 0 assert args["args"] == [{"type": 1, "targetValue": 2}] + # Validate that non accumulating meter does not have a last reset attribute + + assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes + client.async_send_command_no_wait.reset_mock() @@ -265,6 +278,10 @@ async def test_restore_last_reset( ): """Test restoring last_reset on setup.""" assert ( - hass.states.get(METER_SENSOR).attributes[ATTR_LAST_RESET] + hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] == DATETIME_LAST_RESET.isoformat() ) + + # Validate that non accumulating meter does not have a last reset attribute + + assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes From 57356696932131ed5ef957054ddca94d890e40de Mon Sep 17 00:00:00 2001 From: Adam Ernst Date: Tue, 3 Aug 2021 16:51:52 -0500 Subject: [PATCH 133/903] Add "stop watering" service to rachio (#53764) Co-authored-by: Brian Rogers Co-authored-by: J. Nick Koston --- homeassistant/components/rachio/const.py | 1 + homeassistant/components/rachio/device.py | 17 +++++++++++++++++ homeassistant/components/rachio/services.yaml | 10 ++++++++++ 3 files changed, 28 insertions(+) diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 721fb36fd36..9dbf14e3907 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -54,6 +54,7 @@ SCHEDULE_TYPE_FIXED = "FIXED" SCHEDULE_TYPE_FLEX = "FLEX" SERVICE_PAUSE_WATERING = "pause_watering" SERVICE_RESUME_WATERING = "resume_watering" +SERVICE_STOP_WATERING = "stop_watering" SERVICE_SET_ZONE_MOISTURE = "set_zone_moisture_percent" SERVICE_START_MULTIPLE_ZONES = "start_multiple_zone_schedule" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index ac2fea20bcf..6669a353094 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -26,6 +26,7 @@ from .const import ( MODEL_GENERATION_1, SERVICE_PAUSE_WATERING, SERVICE_RESUME_WATERING, + SERVICE_STOP_WATERING, ) from .webhooks import LISTEN_EVENT_TYPES, WEBHOOK_CONST_ID @@ -44,6 +45,8 @@ PAUSE_SERVICE_SCHEMA = vol.Schema( RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) +STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) + class RachioPerson: """Represent a Rachio user.""" @@ -87,6 +90,13 @@ class RachioPerson: if iro.name in devices: iro.resume_watering() + def stop_water(service): + """Service to stop watering on all or specific controllers.""" + devices = service.data.get(ATTR_DEVICES, all_devices) + for iro in self._controllers: + if iro.name in devices: + iro.stop_watering() + hass.services.async_register( DOMAIN, SERVICE_PAUSE_WATERING, @@ -101,6 +111,13 @@ class RachioPerson: schema=RESUME_SERVICE_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_STOP_WATERING, + stop_water, + schema=STOP_SERVICE_SCHEMA, + ) + def _setup(self, hass): """Rachio device setup.""" rachio = self.rachio diff --git a/homeassistant/components/rachio/services.yaml b/homeassistant/components/rachio/services.yaml index bcd853b3ded..67463a22172 100644 --- a/homeassistant/components/rachio/services.yaml +++ b/homeassistant/components/rachio/services.yaml @@ -59,3 +59,13 @@ resume_watering: example: "Main House" selector: text: +stop_watering: + name: Stop watering + description: Stop any currently running zones or schedules. + fields: + devices: + name: Devices + description: Name of controllers to stop. Defaults to all controllers on the account if not provided. + example: "Main House" + selector: + text: From c682d5d5e430de52e3da7e06026cd8b4087e864f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 4 Aug 2021 02:10:33 +0200 Subject: [PATCH 134/903] Update frontend to 20210803.2 (#53923) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a94bcf44327..fbf676687cb 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210803.0" + "home-assistant-frontend==20210803.2" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9dfcc96d6da..7b9fe917279 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210803.0 +home-assistant-frontend==20210803.2 httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 15ae09b74b0..237966f8253 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -783,7 +783,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210803.0 +home-assistant-frontend==20210803.2 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac0d3e3d991..7674e89328f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -449,7 +449,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210803.0 +home-assistant-frontend==20210803.2 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 515a47212ea562371d68fca184d2a87c18526289 Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Tue, 3 Aug 2021 22:42:47 -0700 Subject: [PATCH 135/903] Add target high/low temperatures to prometheus integration (#50071) * add target high/low temperatures to prometheus integration * use labels * Revert "use labels" This reverts commit 09c56d6359a553967546376a760c9398593acf24. * fix naming * tests * cleanup * use three separate metrics * fix descriptions --- .../components/prometheus/__init__.py | 45 +++++++++++++------ tests/components/prometheus/test_init.py | 18 ++++++++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index f158d2506d1..39ac4c28415 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -10,6 +10,8 @@ from homeassistant import core as hacore from homeassistant.components.climate.const import ( ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_ACTION, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, CURRENT_HVAC_ACTIONS, ) from homeassistant.components.http import HomeAssistantView @@ -315,28 +317,43 @@ class PrometheusMetrics: value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_climate(self, state): - temp = state.attributes.get(ATTR_TEMPERATURE) + def _handle_climate_temp(self, state, attr, metric_name, metric_description): + temp = state.attributes.get(attr) if temp: if self._climate_units == TEMP_FAHRENHEIT: temp = fahrenheit_to_celsius(temp) metric = self._metric( - "climate_target_temperature_celsius", + metric_name, self.prometheus_cli.Gauge, - "Target temperature in degrees Celsius", + metric_description, ) metric.labels(**self._labels(state)).set(temp) - current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) - if current_temp: - if self._climate_units == TEMP_FAHRENHEIT: - current_temp = fahrenheit_to_celsius(current_temp) - metric = self._metric( - "climate_current_temperature_celsius", - self.prometheus_cli.Gauge, - "Current temperature in degrees Celsius", - ) - metric.labels(**self._labels(state)).set(current_temp) + def _handle_climate(self, state): + self._handle_climate_temp( + state, + ATTR_TEMPERATURE, + "climate_target_temperature_celsius", + "Target temperature in degrees Celsius", + ) + self._handle_climate_temp( + state, + ATTR_TARGET_TEMP_HIGH, + "climate_target_temperature_high_celsius", + "Target high temperature in degrees Celsius", + ) + self._handle_climate_temp( + state, + ATTR_TARGET_TEMP_LOW, + "climate_target_temperature_low_celsius", + "Target low temperature in degrees Celsius", + ) + self._handle_climate_temp( + state, + ATTR_CURRENT_TEMPERATURE, + "climate_current_temperature_celsius", + "Current temperature in degrees Celsius", + ) current_action = state.attributes.get(ATTR_HVAC_ACTION) if current_action: diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index f8fcdd4561a..6f89c91a245 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -137,6 +137,24 @@ async def test_view_empty_namespace(hass, hass_client): 'friendly_name="HeatPump"} 25.0' in body ) + assert ( + 'climate_target_temperature_celsius{domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 20.0' in body + ) + + assert ( + 'climate_target_temperature_low_celsius{domain="climate",' + 'entity="climate.ecobee",' + 'friendly_name="Ecobee"} 21.0' in body + ) + + assert ( + 'climate_target_temperature_high_celsius{domain="climate",' + 'entity="climate.ecobee",' + 'friendly_name="Ecobee"} 24.0' in body + ) + assert ( 'humidifier_target_humidity_percent{domain="humidifier",' 'entity="humidifier.humidifier",' From 083868ac01ca0c87b5480efca59f7b790bae6a66 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 4 Aug 2021 08:47:28 +0200 Subject: [PATCH 136/903] Enable mypy for Yamaha (#53920) --- homeassistant/components/yamaha/media_player.py | 5 +++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index 147a983b298..720f38a12ae 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -30,6 +30,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( CURSOR_TYPE_DOWN, @@ -96,7 +97,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( class YamahaConfigInfo: """Configuration Info for Yamaha Receivers.""" - def __init__(self, config: None, discovery_info: None) -> None: + def __init__(self, config: ConfigType, discovery_info: DiscoveryInfoType) -> None: """Initialize the Configuration Info for Yamaha Receiver.""" self.name = config.get(CONF_NAME) self.host = config.get(CONF_HOST) @@ -109,7 +110,7 @@ class YamahaConfigInfo: if discovery_info is not None: self.name = discovery_info.get("name") self.model = discovery_info.get("model_name") - self.ctrl_url = discovery_info.get("control_url") + self.ctrl_url = str(discovery_info.get("control_url")) self.desc_url = discovery_info.get("description_url") self.zone_ignore = [] self.from_discovery = True diff --git a/mypy.ini b/mypy.ini index 9e87e1466bf..2f4b20747f9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1756,9 +1756,6 @@ ignore_errors = true [mypy-homeassistant.components.xiaomi_miio.*] ignore_errors = true -[mypy-homeassistant.components.yamaha.*] -ignore_errors = true - [mypy-homeassistant.components.yeelight.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 09c1280c472..a0e80af2a2e 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -185,7 +185,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xbox.*", "homeassistant.components.xiaomi_aqara.*", "homeassistant.components.xiaomi_miio.*", - "homeassistant.components.yamaha.*", "homeassistant.components.yeelight.*", "homeassistant.components.zha.*", "homeassistant.components.zwave.*", From f9071a40de301436d51c6a78aa8869611d9f1f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 4 Aug 2021 09:29:51 +0200 Subject: [PATCH 137/903] Address review comments for 53918 (#53927) --- .../components/uptimerobot/binary_sensor.py | 52 ++++++++----------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index dd7254fb1ca..e1684d64924 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -1,5 +1,4 @@ """A platform that to monitor Uptime Robot monitors.""" -from dataclasses import dataclass from datetime import timedelta import logging @@ -32,13 +31,6 @@ ATTRIBUTION = "Data provided by Uptime Robot" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) -@dataclass -class UptimeRobotBinarySensorEntityDescription(BinarySensorEntityDescription): - """Entity description for UptimeRobotBinarySensor.""" - - target: str = "" - - async def async_setup_platform( hass: HomeAssistant, config, async_add_entities, discovery_info=None ): @@ -46,12 +38,11 @@ async def async_setup_platform( uptime_robot_api = UptimeRobot() api_key = config[CONF_API_KEY] + def api_wrapper(): + return uptime_robot_api.getMonitors(api_key) + async def async_update_data(): """Fetch data from API UptimeRobot API.""" - - def api_wrapper(): - return uptime_robot_api.getMonitors(api_key) - async with async_timeout.timeout(10): monitors = await hass.async_add_executor_job(api_wrapper) if not monitors or monitors.get("stat") != "ok": @@ -76,16 +67,15 @@ async def async_setup_platform( [ UptimeRobotBinarySensor( coordinator, - UptimeRobotBinarySensorEntityDescription( + BinarySensorEntityDescription( key=monitor["id"], name=monitor["friendly_name"], - target=monitor["url"], device_class=DEVICE_CLASS_CONNECTIVITY, ), + target=monitor["url"], ) for monitor in coordinator.data["monitors"] ], - True, ) @@ -95,28 +85,28 @@ class UptimeRobotBinarySensor(BinarySensorEntity, CoordinatorEntity): def __init__( self, coordinator: DataUpdateCoordinator, - description: UptimeRobotBinarySensorEntityDescription, + description: BinarySensorEntityDescription, + target: str, ) -> None: """Initialize Uptime Robot the binary sensor.""" super().__init__(coordinator) - self.coordinator = coordinator self.entity_description = description + self._target = target self._attr_extra_state_attributes = { ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_TARGET: self.entity_description.target, + ATTR_TARGET: self._target, } - async def async_update(self): - """Get the latest state of the binary sensor.""" - if monitor := get_monitor_by_id( - self.coordinator.data.get("monitors", []), self.entity_description.key + @property + def is_on(self) -> bool: + """Return True if the entity is on.""" + if monitor := next( + ( + monitor + for monitor in self.coordinator.data.get("monitors", []) + if monitor["id"] == self.entity_description.key + ), + None, ): - self._attr_is_on = monitor["status"] == 2 - - -def get_monitor_by_id(monitors, monitor_id): - """Return the monitor object matching the id.""" - filtered = [monitor for monitor in monitors if monitor["id"] == monitor_id] - if len(filtered) == 0: - return - return filtered[0] + return monitor["status"] == 2 + return False From ff307a802ea0fa203cded92bc477431465793603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 4 Aug 2021 11:03:41 +0200 Subject: [PATCH 138/903] Use the github context when writing metafile (#53928) --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 9fd827588ec..abe0cfcb63e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -50,7 +50,7 @@ jobs: - name: Generate meta info shell: bash run: | - echo "${{ env.GITHUB_SHA }};${{ env.GITHUB_REF }};${{ env.GITHUB_EVENT_NAME }};${{ env.GITHUB_ACTOR }}" > OFFICIAL_IMAGE + echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > OFFICIAL_IMAGE - name: Signing meta info file uses: home-assistant/actions/helpers/codenotary@master @@ -117,7 +117,7 @@ jobs: - name: Write meta info file shell: bash run: | - echo "${{ env.GITHUB_SHA }};${{ env.GITHUB_REF }};${{ env.GITHUB_EVENT_NAME }};${{ env.GITHUB_ACTOR }}" > rootfs/OFFICIAL_IMAGE + echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to DockerHub uses: docker/login-action@v1.10.0 From 8299d0a7c3172a6913a751c02b4838b3adcd29ca Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 4 Aug 2021 11:12:42 +0200 Subject: [PATCH 139/903] Validate Select option before calling entity method (#52352) Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/demo/select.py | 3 - homeassistant/components/select/__init__.py | 12 ++- tests/components/select/test_init.py | 86 ++++++++++++++++++- tests/components/wled/test_select.py | 12 +-- .../custom_components/test/select.py | 63 ++++++++++++++ 5 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 tests/testing_config/custom_components/test/select.py diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index dcc0c12a9b4..8d499c7a258 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -73,8 +73,5 @@ class DemoSelect(SelectEntity): async def async_select_option(self, option: str) -> None: """Update the current selected option.""" - if option not in self.options: - raise ValueError(f"Invalid option for {self.entity_id}: {option}") - self._attr_current_option = option self.async_write_ha_state() diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index d5c70c76cd0..9a7bfa62cdf 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -9,7 +9,7 @@ from typing import Any, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -40,12 +40,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SELECT_OPTION, {vol.Required(ATTR_OPTION): cv.string}, - "async_select_option", + async_select_option, ) return True +async def async_select_option(entity: SelectEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new value.""" + option = service_call.data[ATTR_OPTION] + if option not in entity.options: + raise ValueError(f"Option {option} not valid for {entity.name}") + await entity.async_select_option(option) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent = hass.data[DOMAIN] diff --git a/tests/components/select/test_init.py b/tests/components/select/test_init.py index 188099164c2..21745694d38 100644 --- a/tests/components/select/test_init.py +++ b/tests/components/select/test_init.py @@ -1,6 +1,18 @@ """The tests for the Select component.""" -from homeassistant.components.select import SelectEntity +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.select import ATTR_OPTIONS, DOMAIN, SelectEntity +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + CONF_PLATFORM, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component class MockSelectEntity(SelectEntity): @@ -26,3 +38,75 @@ async def test_select(hass: HomeAssistant) -> None: select._attr_current_option = "option_four" assert select.current_option == "option_four" assert select.state is None + + select.hass = hass + + with pytest.raises(NotImplementedError): + await select.async_select_option("option_one") + + select.select_option = MagicMock() + await select.async_select_option("option_one") + + assert select.select_option.called + assert select.select_option.call_args[0][0] == "option_one" + + assert select.capability_attributes[ATTR_OPTIONS] == [ + "option_one", + "option_two", + "option_three", + ] + + +async def test_custom_integration_and_validation(hass, enable_custom_integrations): + """Test we can only select valid options.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert hass.states.get("select.select_1").state == "option 1" + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "option 2", ATTR_ENTITY_ID: "select.select_1"}, + blocking=True, + ) + + hass.states.async_set("select.select_1", "option 2") + await hass.async_block_till_done() + assert hass.states.get("select.select_1").state == "option 2" + + # test ValueError trigger + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "option invalid", ATTR_ENTITY_ID: "select.select_1"}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get("select.select_1").state == "option 2" + + assert hass.states.get("select.select_2").state == STATE_UNKNOWN + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "option invalid", ATTR_ENTITY_ID: "select.select_2"}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get("select.select_2").state == STATE_UNKNOWN + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "option 3", ATTR_ENTITY_ID: "select.select_2"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("select.select_2").state == "option 3" diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index dbc1bf7c970..1d68879d510 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -126,7 +126,7 @@ async def test_color_palette_segment_change_state( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Some Other Palette", + ATTR_OPTION: "Icefire", }, blocking=True, ) @@ -134,7 +134,7 @@ async def test_color_palette_segment_change_state( assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with( segment_id=1, - palette="Some Other Palette", + palette="Icefire", ) @@ -195,7 +195,7 @@ async def test_color_palette_select_error( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Whatever", + ATTR_OPTION: "Icefire", }, blocking=True, ) @@ -206,7 +206,7 @@ async def test_color_palette_select_error( assert state.state == "Random Cycle" assert "Invalid response from API" in caplog.text assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, palette="Whatever") + mock_wled.segment.assert_called_with(segment_id=1, palette="Icefire") async def test_color_palette_select_connection_error( @@ -224,7 +224,7 @@ async def test_color_palette_select_connection_error( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Whatever", + ATTR_OPTION: "Icefire", }, blocking=True, ) @@ -235,7 +235,7 @@ async def test_color_palette_select_connection_error( assert state.state == STATE_UNAVAILABLE assert "Error communicating with API" in caplog.text assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, palette="Whatever") + mock_wled.segment.assert_called_with(segment_id=1, palette="Icefire") async def test_preset_unavailable_without_presets( diff --git a/tests/testing_config/custom_components/test/select.py b/tests/testing_config/custom_components/test/select.py new file mode 100644 index 00000000000..375191983b5 --- /dev/null +++ b/tests/testing_config/custom_components/test/select.py @@ -0,0 +1,63 @@ +""" +Provide a mock select platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.select import SelectEntity + +from tests.common import MockEntity + +UNIQUE_SELECT_1 = "unique_select_1" +UNIQUE_SELECT_2 = "unique_select_2" + +ENTITIES = [] + + +class MockSelectEntity(MockEntity, SelectEntity): + """Mock Select class.""" + + _attr_current_option = None + + @property + def current_option(self): + """Return the current option of this select.""" + return self._handle("current_option") + + @property + def options(self) -> list: + """Return the list of available options of this select.""" + return self._handle("options") + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self._attr_current_option = option + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockSelectEntity( + name="select 1", + unique_id="unique_select_1", + options=["option 1", "option 2", "option 3"], + current_option="option 1", + ), + MockSelectEntity( + name="select 2", + unique_id="unique_select_2", + options=["option 1", "option 2", "option 3"], + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) From af38ff1ec1a2ba20e24a3e04c18e5e25b8771bc8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 4 Aug 2021 11:23:21 +0200 Subject: [PATCH 140/903] Add xiaomi miio lumi.gateway.aqhm01 support (#53929) --- homeassistant/components/xiaomi_miio/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 476132aa8b2..852adfcc071 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -10,6 +10,7 @@ from miio.gateway.gateway import ( GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, GATEWAY_MODEL_AC_V3, + GATEWAY_MODEL_AQARA, GATEWAY_MODEL_EU, GatewayException, ) @@ -197,6 +198,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, GATEWAY_MODEL_AC_V3, + GATEWAY_MODEL_AQARA, GATEWAY_MODEL_EU, ]: description = SENSOR_TYPES[ATTR_ILLUMINANCE] From 8f014361d49b6f1c4724556e73c658dae4d9d0da Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 4 Aug 2021 11:57:26 +0200 Subject: [PATCH 141/903] Validate Number value before calling entity method (#52343) Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- homeassistant/components/demo/number.py | 11 +--- homeassistant/components/number/__init__.py | 14 ++++- tests/components/demo/test_number.py | 2 +- tests/components/number/test_init.py | 57 ++++++++++++++++++- .../custom_components/test/number.py | 51 +++++++++++++++++ 5 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 tests/testing_config/custom_components/test/number.py diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index a6842d2ca43..cad2255806e 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -1,8 +1,6 @@ """Demo platform that offers a fake Number entity.""" from __future__ import annotations -import voluptuous as vol - from homeassistant.components.number import NumberEntity from homeassistant.const import DEVICE_DEFAULT_NAME @@ -82,12 +80,5 @@ class DemoNumber(NumberEntity): async def async_set_value(self, value): """Update the current value.""" - num_value = float(value) - - if num_value < self.min_value or num_value > self.max_value: - raise vol.Invalid( - f"Invalid value for {self.entity_id}: {value} (range {self.min_value} - {self.max_value})" - ) - - self._attr_value = num_value + self._attr_value = value self.async_write_ha_state() diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 88ba5cf8b41..ac727288b07 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -9,7 +9,7 @@ from typing import Any, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -49,12 +49,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_VALUE, {vol.Required(ATTR_VALUE): vol.Coerce(float)}, - "async_set_value", + async_set_value, ) return True +async def async_set_value(entity: NumberEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new value.""" + value = service_call.data["value"] + if value < entity.min_value or value > entity.max_value: + raise ValueError( + f"Value {value} for {entity.name} is outside valid range {entity.min_value} - {entity.max_value}" + ) + await entity.async_set_value(value) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent = hass.data[DOMAIN] diff --git a/tests/components/demo/test_number.py b/tests/components/demo/test_number.py index 711332b7817..82536b0d2f8 100644 --- a/tests/components/demo/test_number.py +++ b/tests/components/demo/test_number.py @@ -67,7 +67,7 @@ async def test_set_value_bad_range(hass): state = hass.states.get(ENTITY_VOLUME) assert state.state == "42.0" - with pytest.raises(vol.Invalid): + with pytest.raises(ValueError): await hass.services.async_call( DOMAIN, SERVICE_SET_VALUE, diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index f1154581fdc..8fdf03a7d7b 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -1,7 +1,18 @@ """The tests for the Number component.""" from unittest.mock import MagicMock -from homeassistant.components.number import NumberEntity +import pytest + +from homeassistant.components.number import ( + ATTR_STEP, + ATTR_VALUE, + DOMAIN, + SERVICE_SET_VALUE, + NumberEntity, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component class MockDefaultNumberEntity(NumberEntity): @@ -27,7 +38,7 @@ class MockNumberEntity(NumberEntity): return 0.5 -async def test_step(hass): +async def test_step(hass: HomeAssistant) -> None: """Test the step calculation.""" number = MockDefaultNumberEntity() assert number.step == 1.0 @@ -36,7 +47,7 @@ async def test_step(hass): assert number_2.step == 0.1 -async def test_sync_set_value(hass): +async def test_sync_set_value(hass: HomeAssistant) -> None: """Test if async set_value calls sync set_value.""" number = MockDefaultNumberEntity() number.hass = hass @@ -46,3 +57,43 @@ async def test_sync_set_value(hass): assert number.set_value.called assert number.set_value.call_args[0][0] == 42 + + +async def test_custom_integration_and_validation( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test we can only set valid values.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "50.0" + assert state.attributes.get(ATTR_STEP) == 1.0 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 60.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + + hass.states.async_set("number.test", 60.0) + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state.state == "60.0" + + # test ValueError trigger + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 110.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state.state == "60.0" diff --git a/tests/testing_config/custom_components/test/number.py b/tests/testing_config/custom_components/test/number.py new file mode 100644 index 00000000000..93d7783d684 --- /dev/null +++ b/tests/testing_config/custom_components/test/number.py @@ -0,0 +1,51 @@ +""" +Provide a mock number platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.number import NumberEntity + +from tests.common import MockEntity + +UNIQUE_NUMBER = "unique_number" + +ENTITIES = [] + + +class MockNumberEntity(MockEntity, NumberEntity): + """Mock Select class.""" + + _attr_value = 50.0 + _attr_step = 1.0 + + @property + def value(self): + """Return the current value.""" + return self._handle("value") + + def set_value(self, value: float) -> None: + """Change the selected option.""" + self._attr_value = value + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockNumberEntity( + name="test", + unique_id=UNIQUE_NUMBER, + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) From 3b212b9109817929da3971ebd0d57849a83d7878 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 4 Aug 2021 12:03:18 +0200 Subject: [PATCH 142/903] Use `NumberEntityDescription` for Xiaomi Miio (#53911) --- .../components/xiaomi_miio/number.py | 69 +++++++++---------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 6855faa6391..83c336d101e 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -1,8 +1,10 @@ """Motor speed support for Xiaomi Mi Air Humidifier.""" +from __future__ import annotations + from dataclasses import dataclass from enum import Enum -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.core import callback from .const import ( @@ -21,28 +23,23 @@ ATTR_MOTOR_SPEED = "motor_speed" @dataclass -class NumberType: - """Class that holds device specific info for a xiaomi aqara or humidifier number controller types.""" +class XiaomiMiioNumberDescription(NumberEntityDescription): + """A class that describes number entities.""" - name: str = None - short_name: str = None - unit_of_measurement: str = None - icon: str = None - device_class: str = None - min: float = None - max: float = None - step: float = None + min_value: float | None = None + max_value: float | None = None + step: float | None = None available_with_device_off: bool = True NUMBER_TYPES = { - FEATURE_SET_MOTOR_SPEED: NumberType( + FEATURE_SET_MOTOR_SPEED: XiaomiMiioNumberDescription( + key=ATTR_MOTOR_SPEED, name="Motor Speed", icon="mdi:fast-forward-outline", - short_name=ATTR_MOTOR_SPEED, unit_of_measurement="rpm", - min=200, - max=2000, + min_value=200, + max_value=2000, step=10, available_with_device_off=False, ), @@ -56,22 +53,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): return model = config_entry.data[CONF_MODEL] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - if model not in [MODEL_AIRHUMIDIFIER_CA4]: + if model != MODEL_AIRHUMIDIFIER_CA4: return - for number in NUMBER_TYPES.values(): - entities.append( - XiaomiAirHumidifierNumber( - f"{config_entry.title} {number.name}", - device, - config_entry, - f"{number.short_name}_{config_entry.unique_id}", - number, - coordinator, - ) + description = NUMBER_TYPES[FEATURE_SET_MOTOR_SPEED] + entities.append( + XiaomiAirHumidifierNumber( + f"{config_entry.title} {description.name}", + device, + config_entry, + f"{description.key}_{config_entry.unique_id}", + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, ) + ) async_add_entities(entities) @@ -79,18 +75,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): """Representation of a generic Xiaomi attribute selector.""" - def __init__(self, name, device, entry, unique_id, number, coordinator): + def __init__(self, name, device, entry, unique_id, coordinator, description): """Initialize the generic Xiaomi attribute selector.""" super().__init__(name, device, entry, unique_id, coordinator) - self._attr_icon = number.icon - self._attr_unit_of_measurement = number.unit_of_measurement - self._attr_min_value = number.min - self._attr_max_value = number.max - self._attr_step = number.step - self._controller = number + + self._attr_min_value = description.min_value + self._attr_max_value = description.max_value + self._attr_step = description.step self._attr_value = self._extract_value_from_attribute( - self.coordinator.data, self._controller.short_name + coordinator.data, description.key ) + self.entity_description = description @property def available(self): @@ -98,7 +93,7 @@ class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): if ( super().available and not self.coordinator.data.is_on - and not self._controller.available_with_device_off + and not self.entity_description.available_with_device_off ): return False return super().available @@ -131,7 +126,7 @@ class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): """Fetch state from the device.""" # On state change the device doesn't provide the new state immediately. self._attr_value = self._extract_value_from_attribute( - self.coordinator.data, self._controller.short_name + self.coordinator.data, self.entity_description.key ) self.async_write_ha_state() From 129cdda932b3e130fbdcb93a649f6757091e1df4 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 4 Aug 2021 12:34:04 +0200 Subject: [PATCH 143/903] Late review. (#53933) --- homeassistant/components/yamaha/media_player.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index 720f38a12ae..4bf830ed68d 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -1,4 +1,6 @@ """Support for Yamaha Receivers.""" +from __future__ import annotations + import logging import requests @@ -101,7 +103,7 @@ class YamahaConfigInfo: """Initialize the Configuration Info for Yamaha Receiver.""" self.name = config.get(CONF_NAME) self.host = config.get(CONF_HOST) - self.ctrl_url = f"http://{self.host}:80/YamahaRemoteControl/ctrl" + self.ctrl_url: str | None = f"http://{self.host}:80/YamahaRemoteControl/ctrl" self.source_ignore = config.get(CONF_SOURCE_IGNORE) self.source_names = config.get(CONF_SOURCE_NAMES) self.zone_ignore = config.get(CONF_ZONE_IGNORE) @@ -110,7 +112,7 @@ class YamahaConfigInfo: if discovery_info is not None: self.name = discovery_info.get("name") self.model = discovery_info.get("model_name") - self.ctrl_url = str(discovery_info.get("control_url")) + self.ctrl_url = discovery_info.get("control_url") self.desc_url = discovery_info.get("description_url") self.zone_ignore = [] self.from_discovery = True From fe957b74be25c6a91e9e590aa063e6a4e0e4e61d Mon Sep 17 00:00:00 2001 From: Alex Henry Date: Wed, 4 Aug 2021 22:43:30 +1200 Subject: [PATCH 144/903] Upgrade anthemav dependency to 1.2.0 (#53931) --- homeassistant/components/anthemav/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/anthemav/manifest.json b/homeassistant/components/anthemav/manifest.json index 3e11675fa1f..078ecaae0da 100644 --- a/homeassistant/components/anthemav/manifest.json +++ b/homeassistant/components/anthemav/manifest.json @@ -2,7 +2,7 @@ "domain": "anthemav", "name": "Anthem A/V Receivers", "documentation": "https://www.home-assistant.io/integrations/anthemav", - "requirements": ["anthemav==1.1.10"], + "requirements": ["anthemav==1.2.0"], "codeowners": [], "iot_class": "local_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 237966f8253..2ac357f0990 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ androidtv[async]==0.0.60 anel_pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.anthemav -anthemav==1.1.10 +anthemav==1.2.0 # homeassistant.components.apcupsd apcaccess==0.0.13 From b77335d6f9f5395293942e5031f7dbaf24163b01 Mon Sep 17 00:00:00 2001 From: Alex Henry Date: Wed, 4 Aug 2021 22:44:16 +1200 Subject: [PATCH 145/903] Fix Panasonic Viera TV going unavailable when turned off (#53788) --- .../components/panasonic_viera/__init__.py | 8 ++-- tests/components/panasonic_viera/conftest.py | 37 ++++++++++----- .../panasonic_viera/test_media_player.py | 47 +++++++++++++++++++ .../components/panasonic_viera/test_remote.py | 7 ++- 4 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 tests/components/panasonic_viera/test_media_player.py diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index b217be4d4b6..e187b7c18a5 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -1,7 +1,7 @@ """The Panasonic Viera integration.""" from functools import partial import logging -from urllib.request import URLError +from urllib.request import HTTPError, URLError from panasonic_viera import EncryptionRequired, Keys, RemoteControl, SOAPError import voluptuous as vol @@ -247,11 +247,13 @@ class Remote: "The connection couldn't be encrypted. Please reconfigure your TV" ) self.available = False - except (SOAPError): + except (SOAPError, HTTPError) as err: + _LOGGER.debug("An error occurred: %s", err) self.state = STATE_OFF self.available = True await self.async_create_remote_control() - except (URLError, OSError): + except (URLError, OSError) as err: + _LOGGER.debug("An error occurred: %s", err) self.state = STATE_OFF self.available = self._on_action is not None await self.async_create_remote_control() diff --git a/tests/components/panasonic_viera/conftest.py b/tests/components/panasonic_viera/conftest.py index d1444f01477..e30c0f41e92 100644 --- a/tests/components/panasonic_viera/conftest.py +++ b/tests/components/panasonic_viera/conftest.py @@ -17,8 +17,12 @@ from homeassistant.components.panasonic_viera.const import ( DEFAULT_MODEL_NUMBER, DEFAULT_NAME, DEFAULT_PORT, + DOMAIN, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry MOCK_BASIC_DATA = { CONF_HOST: "0.0.0.0", @@ -74,20 +78,11 @@ def get_mock_remote( mock_remote.authorize_pin_code = authorize_pin_code - def get_device_info(): - return device_info + mock_remote.get_device_info = Mock(return_value=device_info) - mock_remote.get_device_info = get_device_info + mock_remote.send_key = Mock() - def send_key(key): - return - - mock_remote.send_key = Mock(send_key) - - def get_volume(key): - return 100 - - mock_remote.get_volume = Mock(get_volume) + mock_remote.get_volume = Mock(return_value=100) return mock_remote @@ -102,3 +97,21 @@ def mock_remote_fixture(): return_value=mock_remote, ): yield mock_remote + + +@pytest.fixture +async def init_integration(hass: HomeAssistant, mock_remote: Mock) -> MockConfigEntry: + """Set up the Panasonic Viera integration for testing.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_DEVICE_INFO[ATTR_UDN], + data={**MOCK_CONFIG_DATA, **MOCK_ENCRYPTION_DATA, **MOCK_DEVICE_INFO}, + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/panasonic_viera/test_media_player.py b/tests/components/panasonic_viera/test_media_player.py new file mode 100644 index 00000000000..1203bf1ed51 --- /dev/null +++ b/tests/components/panasonic_viera/test_media_player.py @@ -0,0 +1,47 @@ +"""Test the Panasonic Viera media player entity.""" + +from datetime import timedelta +from unittest.mock import Mock +from urllib.error import HTTPError, URLError + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_media_player_handle_URLerror( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_remote: Mock +) -> None: + """Test remote handle URLError as Unavailable.""" + + state_tv = hass.states.get("media_player.panasonic_viera_tv") + assert state_tv.state == STATE_ON + + # simulate timeout error + mock_remote.get_mute = Mock(side_effect=URLError(None, None)) + + async_fire_time_changed(hass, utcnow() + timedelta(minutes=2)) + await hass.async_block_till_done() + + state_tv = hass.states.get("media_player.panasonic_viera_tv") + assert state_tv.state == STATE_UNAVAILABLE + + +async def test_media_player_handle_HTTPError( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_remote: Mock +) -> None: + """Test remote handle HTTPError as Off.""" + + state_tv = hass.states.get("media_player.panasonic_viera_tv") + assert state_tv.state == STATE_ON + + # simulate http badrequest + mock_remote.get_mute = Mock(side_effect=HTTPError(None, 400, None, None, None)) + + async_fire_time_changed(hass, utcnow() + timedelta(minutes=2)) + await hass.async_block_till_done() + + state_tv = hass.states.get("media_player.panasonic_viera_tv") + assert state_tv.state == STATE_OFF diff --git a/tests/components/panasonic_viera/test_remote.py b/tests/components/panasonic_viera/test_remote.py index 6bfd7dee8eb..0cf80853351 100644 --- a/tests/components/panasonic_viera/test_remote.py +++ b/tests/components/panasonic_viera/test_remote.py @@ -1,8 +1,8 @@ """Test the Panasonic Viera remote entity.""" -from unittest.mock import call +from unittest.mock import Mock, call -from panasonic_viera import Keys +from panasonic_viera import Keys, SOAPError from homeassistant.components.panasonic_viera.const import ATTR_UDN, DOMAIN from homeassistant.components.remote import ( @@ -38,6 +38,9 @@ async def test_onoff(hass, mock_remote): data = {ATTR_ENTITY_ID: "remote.panasonic_viera_tv"} + # simulate tv off when async_update + mock_remote.get_mute = Mock(side_effect=SOAPError) + await hass.services.async_call(REMOTE_DOMAIN, SERVICE_TURN_OFF, data) await hass.services.async_call(REMOTE_DOMAIN, SERVICE_TURN_ON, data) await hass.async_block_till_done() From 10544194989e7c753f37c6f10bf4aff5d8b8ed20 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 4 Aug 2021 12:50:08 +0200 Subject: [PATCH 146/903] Remove Xiaomi_miio number value validation (#53934) --- homeassistant/components/xiaomi_miio/number.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 83c336d101e..9a4961bfdf0 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -108,15 +108,6 @@ class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): async def async_set_value(self, value): """Set an option of the miio device.""" - if ( - self.min_value - and value < self.min_value - or self.max_value - and value > self.max_value - ): - raise ValueError( - f"Value {value} not a valid {self.name} within the range {self.min_value} - {self.max_value}" - ) if await self.async_set_motor_speed(value): self._attr_value = value self.async_write_ha_state() From 1f9331f9dbf88c2ded607a4c977d4da122ec20ce Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 4 Aug 2021 14:00:18 +0200 Subject: [PATCH 147/903] Remove Xiaomi_miio select option validation (#53936) --- homeassistant/components/xiaomi_miio/select.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index f07eec960fc..63fa4e069bf 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -126,10 +126,6 @@ class XiaomiAirHumidifierSelector(XiaomiSelector): async def async_select_option(self, option: str) -> None: """Set an option of the miio device.""" - if option not in self.options: - raise ValueError( - f"Selection '{option}' is not a valid {self.entity_description.name}" - ) await self.async_set_led_brightness(option.title()) @property From 3f6282eb7a964faede929a3df9443f362fdbb0c8 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Wed, 4 Aug 2021 18:31:24 +0200 Subject: [PATCH 148/903] Activate mypy for LG webOS Smart TV (#53958) --- homeassistant/components/webostv/media_player.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index d94ab8a7c26..c451645e013 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -127,7 +127,7 @@ class LgWebOSMediaPlayerEntity(MediaPlayerEntity): self._paused = False self._current_source = None - self._source_list = {} + self._source_list: dict = {} async def async_added_to_hass(self): """Connect and subscribe to dispatcher signals and state updates.""" diff --git a/mypy.ini b/mypy.ini index 2f4b20747f9..1fd92182a49 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1735,9 +1735,6 @@ ignore_errors = true [mypy-homeassistant.components.volumio.*] ignore_errors = true -[mypy-homeassistant.components.webostv.*] -ignore_errors = true - [mypy-homeassistant.components.wemo.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index a0e80af2a2e..b795e6c03c8 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -178,7 +178,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.verisure.*", "homeassistant.components.vizio.*", "homeassistant.components.volumio.*", - "homeassistant.components.webostv.*", "homeassistant.components.wemo.*", "homeassistant.components.wink.*", "homeassistant.components.withings.*", From f7fb4ad7823bd984d61af0eb8d19179ff1508bea Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 4 Aug 2021 21:24:19 +0200 Subject: [PATCH 149/903] Fix attr_unit_of_measurement in update of apcupsd entity (#53947) --- homeassistant/components/apcupsd/sensor.py | 93 +++++++++++----------- 1 file changed, 46 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index d30625ee793..bf1b8bf6db5 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -26,72 +26,72 @@ _LOGGER = logging.getLogger(__name__) SENSOR_PREFIX = "UPS " SENSOR_TYPES = { - "alarmdel": ["Alarm Delay", "", "mdi:alarm", None], - "ambtemp": ["Ambient Temperature", "", "mdi:thermometer", None], - "apc": ["Status Data", "", "mdi:information-outline", None], - "apcmodel": ["Model", "", "mdi:information-outline", None], - "badbatts": ["Bad Batteries", "", "mdi:information-outline", None], - "battdate": ["Battery Replaced", "", "mdi:calendar-clock", None], - "battstat": ["Battery Status", "", "mdi:information-outline", None], + "alarmdel": ["Alarm Delay", None, "mdi:alarm", None], + "ambtemp": ["Ambient Temperature", None, "mdi:thermometer", None], + "apc": ["Status Data", None, "mdi:information-outline", None], + "apcmodel": ["Model", None, "mdi:information-outline", None], + "badbatts": ["Bad Batteries", None, "mdi:information-outline", None], + "battdate": ["Battery Replaced", None, "mdi:calendar-clock", None], + "battstat": ["Battery Status", None, "mdi:information-outline", None], "battv": ["Battery Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], "bcharge": ["Battery", PERCENTAGE, "mdi:battery", None], - "cable": ["Cable Type", "", "mdi:ethernet-cable", None], - "cumonbatt": ["Total Time on Battery", "", "mdi:timer-outline", None], - "date": ["Status Date", "", "mdi:calendar-clock", None], - "dipsw": ["Dip Switch Settings", "", "mdi:information-outline", None], - "dlowbatt": ["Low Battery Signal", "", "mdi:clock-alert", None], - "driver": ["Driver", "", "mdi:information-outline", None], - "dshutd": ["Shutdown Delay", "", "mdi:timer-outline", None], - "dwake": ["Wake Delay", "", "mdi:timer-outline", None], - "endapc": ["Date and Time", "", "mdi:calendar-clock", None], - "extbatts": ["External Batteries", "", "mdi:information-outline", None], - "firmware": ["Firmware Version", "", "mdi:information-outline", None], + "cable": ["Cable Type", None, "mdi:ethernet-cable", None], + "cumonbatt": ["Total Time on Battery", None, "mdi:timer-outline", None], + "date": ["Status Date", None, "mdi:calendar-clock", None], + "dipsw": ["Dip Switch Settings", None, "mdi:information-outline", None], + "dlowbatt": ["Low Battery Signal", None, "mdi:clock-alert", None], + "driver": ["Driver", None, "mdi:information-outline", None], + "dshutd": ["Shutdown Delay", None, "mdi:timer-outline", None], + "dwake": ["Wake Delay", None, "mdi:timer-outline", None], + "endapc": ["Date and Time", None, "mdi:calendar-clock", None], + "extbatts": ["External Batteries", None, "mdi:information-outline", None], + "firmware": ["Firmware Version", None, "mdi:information-outline", None], "hitrans": ["Transfer High", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "hostname": ["Hostname", "", "mdi:information-outline", None], + "hostname": ["Hostname", None, "mdi:information-outline", None], "humidity": ["Ambient Humidity", PERCENTAGE, "mdi:water-percent", None], "itemp": ["Internal Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "lastxfer": ["Last Transfer", "", "mdi:transfer", None], - "linefail": ["Input Voltage Status", "", "mdi:information-outline", None], + "lastxfer": ["Last Transfer", None, "mdi:transfer", None], + "linefail": ["Input Voltage Status", None, "mdi:information-outline", None], "linefreq": ["Line Frequency", FREQUENCY_HERTZ, "mdi:information-outline", None], "linev": ["Input Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], "loadpct": ["Load", PERCENTAGE, "mdi:gauge", None], "loadapnt": ["Load Apparent Power", PERCENTAGE, "mdi:gauge", None], "lotrans": ["Transfer Low", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "mandate": ["Manufacture Date", "", "mdi:calendar", None], - "masterupd": ["Master Update", "", "mdi:information-outline", None], + "mandate": ["Manufacture Date", None, "mdi:calendar", None], + "masterupd": ["Master Update", None, "mdi:information-outline", None], "maxlinev": ["Input Voltage High", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "maxtime": ["Battery Timeout", "", "mdi:timer-off-outline", None], + "maxtime": ["Battery Timeout", None, "mdi:timer-off-outline", None], "mbattchg": ["Battery Shutdown", PERCENTAGE, "mdi:battery-alert", None], "minlinev": ["Input Voltage Low", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "mintimel": ["Shutdown Time", "", "mdi:timer-outline", None], - "model": ["Model", "", "mdi:information-outline", None], + "mintimel": ["Shutdown Time", None, "mdi:timer-outline", None], + "model": ["Model", None, "mdi:information-outline", None], "nombattv": ["Battery Nominal Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], "nominv": ["Nominal Input Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], "nomoutv": ["Nominal Output Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], "nompower": ["Nominal Output Power", POWER_WATT, "mdi:flash", None], "nomapnt": ["Nominal Apparent Power", POWER_VOLT_AMPERE, "mdi:flash", None], - "numxfers": ["Transfer Count", "", "mdi:counter", None], + "numxfers": ["Transfer Count", None, "mdi:counter", None], "outcurnt": ["Output Current", ELECTRIC_CURRENT_AMPERE, "mdi:flash", None], "outputv": ["Output Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "reg1": ["Register 1 Fault", "", "mdi:information-outline", None], - "reg2": ["Register 2 Fault", "", "mdi:information-outline", None], - "reg3": ["Register 3 Fault", "", "mdi:information-outline", None], + "reg1": ["Register 1 Fault", None, "mdi:information-outline", None], + "reg2": ["Register 2 Fault", None, "mdi:information-outline", None], + "reg3": ["Register 3 Fault", None, "mdi:information-outline", None], "retpct": ["Restore Requirement", PERCENTAGE, "mdi:battery-alert", None], - "selftest": ["Last Self Test", "", "mdi:calendar-clock", None], - "sense": ["Sensitivity", "", "mdi:information-outline", None], - "serialno": ["Serial Number", "", "mdi:information-outline", None], - "starttime": ["Startup Time", "", "mdi:calendar-clock", None], - "statflag": ["Status Flag", "", "mdi:information-outline", None], - "status": ["Status", "", "mdi:information-outline", None], - "stesti": ["Self Test Interval", "", "mdi:information-outline", None], - "timeleft": ["Time Left", "", "mdi:clock-alert", None], - "tonbatt": ["Time on Battery", "", "mdi:timer-outline", None], - "upsmode": ["Mode", "", "mdi:information-outline", None], - "upsname": ["Name", "", "mdi:information-outline", None], - "version": ["Daemon Info", "", "mdi:information-outline", None], - "xoffbat": ["Transfer from Battery", "", "mdi:transfer", None], - "xoffbatt": ["Transfer from Battery", "", "mdi:transfer", None], - "xonbatt": ["Transfer to Battery", "", "mdi:transfer", None], + "selftest": ["Last Self Test", None, "mdi:calendar-clock", None], + "sense": ["Sensitivity", None, "mdi:information-outline", None], + "serialno": ["Serial Number", None, "mdi:information-outline", None], + "starttime": ["Startup Time", None, "mdi:calendar-clock", None], + "statflag": ["Status Flag", None, "mdi:information-outline", None], + "status": ["Status", None, "mdi:information-outline", None], + "stesti": ["Self Test Interval", None, "mdi:information-outline", None], + "timeleft": ["Time Left", None, "mdi:clock-alert", None], + "tonbatt": ["Time on Battery", None, "mdi:timer-outline", None], + "upsmode": ["Mode", None, "mdi:information-outline", None], + "upsname": ["Name", None, "mdi:information-outline", None], + "version": ["Daemon Info", None, "mdi:information-outline", None], + "xoffbat": ["Transfer from Battery", None, "mdi:transfer", None], + "xoffbatt": ["Transfer from Battery", None, "mdi:transfer", None], + "xonbatt": ["Transfer to Battery", None, "mdi:transfer", None], } SPECIFIC_UNITS = {"ITEMP": TEMP_CELSIUS} @@ -165,8 +165,7 @@ class APCUPSdSensor(SensorEntity): self.type = sensor_type self._attr_name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] self._attr_icon = SENSOR_TYPES[self.type][2] - if SENSOR_TYPES[sensor_type][1]: - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._attr_device_class = SENSOR_TYPES[sensor_type][3] def update(self): From 316c2baa1277e9e9ba5fa739b2e3b17dcf88b248 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 4 Aug 2021 21:53:07 +0200 Subject: [PATCH 150/903] Fix divider for Fritz sensors (#53980) --- homeassistant/components/fritz/sensor.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 482d5b1d688..faf2be23164 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -61,32 +61,32 @@ def _retrieve_external_ip_state(status: FritzStatus, last_value: str) -> str: def _retrieve_kb_s_sent_state(status: FritzStatus, last_value: str) -> float: """Return upload transmission rate.""" - return round(status.transmission_rate[0] / 1024, 1) # type: ignore[no-any-return] + return round(status.transmission_rate[0] / 1000, 1) # type: ignore[no-any-return] def _retrieve_kb_s_received_state(status: FritzStatus, last_value: str) -> float: """Return download transmission rate.""" - return round(status.transmission_rate[1] / 1024, 1) # type: ignore[no-any-return] + return round(status.transmission_rate[1] / 1000, 1) # type: ignore[no-any-return] def _retrieve_max_kb_s_sent_state(status: FritzStatus, last_value: str) -> float: """Return upload max transmission rate.""" - return round(status.max_bit_rate[0] / 1024, 1) # type: ignore[no-any-return] + return round(status.max_bit_rate[0] / 1000, 1) # type: ignore[no-any-return] def _retrieve_max_kb_s_received_state(status: FritzStatus, last_value: str) -> float: """Return download max transmission rate.""" - return round(status.max_bit_rate[1] / 1024, 1) # type: ignore[no-any-return] + return round(status.max_bit_rate[1] / 1000, 1) # type: ignore[no-any-return] def _retrieve_gb_sent_state(status: FritzStatus, last_value: str) -> float: """Return upload total data.""" - return round(status.bytes_sent * 8 / 1024 / 1024 / 1024, 1) # type: ignore[no-any-return] + return round(status.bytes_sent * 8 / 1000 / 1000 / 1000, 1) # type: ignore[no-any-return] def _retrieve_gb_received_state(status: FritzStatus, last_value: str) -> float: """Return download total data.""" - return round(status.bytes_received * 8 / 1024 / 1024 / 1024, 1) # type: ignore[no-any-return] + return round(status.bytes_received * 8 / 1000 / 1000 / 1000, 1) # type: ignore[no-any-return] class SensorData(TypedDict, total=False): From 6eae5231f1f17760d9a4d3108ca5708beecf4a08 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 4 Aug 2021 14:57:19 -0500 Subject: [PATCH 151/903] Fix empty sonos_group entity attribute on startup (#53985) --- homeassistant/components/sonos/speaker.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 2ebef334873..434717f7a85 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -654,7 +654,11 @@ class SonosSpeaker: @callback def _async_regroup(group: list[str]) -> None: """Rebuild internal group layout.""" - if group == [self.soco.uid] and self.sonos_group == [self]: + if ( + group == [self.soco.uid] + and self.sonos_group == [self] + and self.sonos_group_entities + ): # Skip updating existing single speakers in polling mode return From 4ef859a9a967c1e918c6462b71ebf075fdcc79e3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 4 Aug 2021 22:16:39 +0200 Subject: [PATCH 152/903] Fix coordinator not defined in yale_smart_alarm (#53973) * Bugfix coordinator not defined * Apply suggestions from code review Co-authored-by: Martin Hjelmare --- .../yale_smart_alarm/alarm_control_panel.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index f450895f5c3..ae5596ee2e1 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -86,11 +86,12 @@ async def async_setup_entry( class YaleAlarmDevice(CoordinatorEntity, AlarmControlPanelEntity): """Represent a Yale Smart Alarm.""" - coordinator: YaleDataUpdateCoordinator - - _attr_name: str = coordinator.entry.data[CONF_NAME] - _attr_unique_id: str = coordinator.entry.entry_id - _identifier: str = coordinator.entry.data[CONF_USERNAME] + def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None: + """Initialize the Yale Alarm Device.""" + super().__init__(coordinator) + self._attr_name: str = coordinator.entry.data[CONF_NAME] + self._attr_unique_id = coordinator.entry.entry_id + self._identifier: str = coordinator.entry.data[CONF_USERNAME] @property def device_info(self) -> DeviceInfo: From caf0bdd5b940eaa978c03597d634d1096d244fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 4 Aug 2021 22:20:03 +0200 Subject: [PATCH 153/903] Add config flow to uptimerobot (#53938) --- .coveragerc | 2 + .strict-typing | 1 + .../components/uptimerobot/__init__.py | 65 ++++++++++- .../components/uptimerobot/binary_sensor.py | 109 ++++++------------ .../components/uptimerobot/config_flow.py | 74 ++++++++++++ homeassistant/components/uptimerobot/const.py | 55 +++++++++ .../components/uptimerobot/entity.py | 75 ++++++++++++ .../components/uptimerobot/manifest.json | 13 ++- .../components/uptimerobot/strings.json | 18 +++ .../uptimerobot/translations/en.json | 19 +++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 ++ requirements_test_all.txt | 3 + tests/components/uptimerobot/__init__.py | 1 + .../uptimerobot/test_config_flow.py | 98 ++++++++++++++++ 15 files changed, 464 insertions(+), 81 deletions(-) create mode 100644 homeassistant/components/uptimerobot/config_flow.py create mode 100644 homeassistant/components/uptimerobot/const.py create mode 100644 homeassistant/components/uptimerobot/entity.py create mode 100644 homeassistant/components/uptimerobot/strings.json create mode 100644 homeassistant/components/uptimerobot/translations/en.json create mode 100644 tests/components/uptimerobot/__init__.py create mode 100644 tests/components/uptimerobot/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 5ebef801c6d..39055879a8d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1113,6 +1113,8 @@ omit = homeassistant/components/upnp/* homeassistant/components/upc_connect/* homeassistant/components/uptimerobot/binary_sensor.py + homeassistant/components/uptimerobot/const.py + homeassistant/components/uptimerobot/entity.py homeassistant/components/uscis/sensor.py homeassistant/components/vallox/* homeassistant/components/vasttrafik/sensor.py diff --git a/.strict-typing b/.strict-typing index 6066c158b99..915ac50d6a1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -103,6 +103,7 @@ homeassistant.components.tile.* homeassistant.components.tts.* homeassistant.components.upcloud.* homeassistant.components.uptime.* +homeassistant.components.uptimerobot.* homeassistant.components.vacuum.* homeassistant.components.water_heater.* homeassistant.components.weather.* diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 3dad1b00fff..b4d606ca637 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -1 +1,64 @@ -"""The uptimerobot component.""" +"""The Uptime Robot integration.""" +from __future__ import annotations + +import async_timeout +from pyuptimerobot import UptimeRobot + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + API_ATTR_MONITORS, + API_ATTR_OK, + API_ATTR_STAT, + CONNECTION_ERROR, + COORDINATOR_UPDATE_INTERVAL, + DOMAIN, + LOGGER, + PLATFORMS, + MonitorData, +) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Uptime Robot from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + uptime_robot_api = UptimeRobot() + + async def async_update_data() -> list[MonitorData]: + """Fetch data from API UptimeRobot API.""" + async with async_timeout.timeout(10): + monitors = await hass.async_add_executor_job( + uptime_robot_api.getMonitors, entry.data[CONF_API_KEY] + ) + if not monitors or monitors.get(API_ATTR_STAT) != API_ATTR_OK: + raise UpdateFailed(CONNECTION_ERROR) + return [ + MonitorData.from_dict(monitor) + for monitor in monitors.get(API_ATTR_MONITORS, []) + ] + + hass.data[DOMAIN][entry.entry_id] = coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name=DOMAIN, + update_method=async_update_data, + update_interval=COORDINATOR_UPDATE_INTERVAL, + ) + + await coordinator.async_config_entry_first_refresh() + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index e1684d64924..69daeaea7c8 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -1,9 +1,6 @@ """A platform that to monitor Uptime Robot monitors.""" -from datetime import timedelta -import logging +from __future__ import annotations -import async_timeout -from pyuptimerobot import UptimeRobot import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -12,101 +9,61 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN +from .entity import UptimeRobotEntity + +PLATFORM_SCHEMA = cv.deprecated( + vol.All(PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string})) ) -_LOGGER = logging.getLogger(__name__) - -ATTR_TARGET = "target" - -ATTRIBUTION = "Data provided by Uptime Robot" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) - async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None -): - """Set up the Uptime Robot binary_sensors.""" - uptime_robot_api = UptimeRobot() - api_key = config[CONF_API_KEY] - - def api_wrapper(): - return uptime_robot_api.getMonitors(api_key) - - async def async_update_data(): - """Fetch data from API UptimeRobot API.""" - async with async_timeout.timeout(10): - monitors = await hass.async_add_executor_job(api_wrapper) - if not monitors or monitors.get("stat") != "ok": - raise UpdateFailed("Error communicating with Uptime Robot API") - return monitors - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="uptimerobot", - update_method=async_update_data, - update_interval=timedelta(seconds=60), + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Uptime Robot binary_sensor platform.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) ) - await coordinator.async_refresh() - - if not coordinator.data or coordinator.data.get("stat") != "ok": - _LOGGER.error("Error connecting to Uptime Robot") - raise PlatformNotReady() +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Uptime Robot binary_sensors.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ UptimeRobotBinarySensor( coordinator, BinarySensorEntityDescription( - key=monitor["id"], - name=monitor["friendly_name"], + key=str(monitor.id), + name=monitor.name, device_class=DEVICE_CLASS_CONNECTIVITY, ), - target=monitor["url"], + target=monitor.url, ) - for monitor in coordinator.data["monitors"] + for monitor in coordinator.data ], ) -class UptimeRobotBinarySensor(BinarySensorEntity, CoordinatorEntity): +class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity): """Representation of a Uptime Robot binary sensor.""" - def __init__( - self, - coordinator: DataUpdateCoordinator, - description: BinarySensorEntityDescription, - target: str, - ) -> None: - """Initialize Uptime Robot the binary sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._target = target - self._attr_extra_state_attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_TARGET: self._target, - } - @property def is_on(self) -> bool: """Return True if the entity is on.""" - if monitor := next( - ( - monitor - for monitor in self.coordinator.data.get("monitors", []) - if monitor["id"] == self.entity_description.key - ), - None, - ): - return monitor["status"] == 2 - return False + return self.monitor_available diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py new file mode 100644 index 00000000000..ad0d382061a --- /dev/null +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Uptime Robot integration.""" +from __future__ import annotations + +from pyuptimerobot import UptimeRobot +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import ConfigType + +from .const import API_ATTR_OK, API_ATTR_STAT, DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +async def validate_input(hass: HomeAssistant, data: ConfigType) -> None: + """Validate the user input allows us to connect.""" + + uptime_robot_api = UptimeRobot() + + monitors = await hass.async_add_executor_job( + uptime_robot_api.getMonitors, data[CONF_API_KEY] + ) + + if not monitors or monitors.get(API_ATTR_STAT) != API_ATTR_OK: + raise CannotConnect("Error communicating with Uptime Robot API") + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Uptime Robot.""" + + VERSION = 1 + + async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Import a config entry from configuration.yaml.""" + for entry in self._async_current_entries(): + if entry.data[CONF_API_KEY] == import_config[CONF_API_KEY]: + LOGGER.warning( + "Already configured. This YAML configuration has already been imported. Please remove it" + ) + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title="", data={CONF_API_KEY: import_config[CONF_API_KEY]} + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/uptimerobot/const.py b/homeassistant/components/uptimerobot/const.py new file mode 100644 index 00000000000..f0bc0699290 --- /dev/null +++ b/homeassistant/components/uptimerobot/const.py @@ -0,0 +1,55 @@ +"""Constants for the Uptime Robot integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from enum import Enum +from logging import Logger, getLogger +from typing import Final + +LOGGER: Logger = getLogger(__package__) + +COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=60) + +DOMAIN: Final = "uptimerobot" +PLATFORMS: Final = ["binary_sensor"] + +CONNECTION_ERROR: Final = "Error connecting to the Uptime Robot API" + +ATTRIBUTION: Final = "Data provided by Uptime Robot" + +ATTR_TARGET: Final = "target" + +API_ATTR_STAT: Final = "stat" +API_ATTR_OK: Final = "ok" +API_ATTR_MONITORS: Final = "monitors" + + +class MonitorType(Enum): + """Monitors type.""" + + HTTP = 1 + keyword = 2 + ping = 3 + + +@dataclass +class MonitorData: + """Dataclass for monitors.""" + + id: int + status: int + url: str + name: str + type: MonitorType + + @staticmethod + def from_dict(monitor: dict) -> MonitorData: + """Create a new monitor from a dict.""" + return MonitorData( + id=monitor["id"], + status=monitor["status"], + url=monitor["url"], + name=monitor["friendly_name"], + type=MonitorType(monitor["type"]), + ) diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py new file mode 100644 index 00000000000..ed9d6b2a2f9 --- /dev/null +++ b/homeassistant/components/uptimerobot/entity.py @@ -0,0 +1,75 @@ +"""Base UptimeRobot entity.""" +from __future__ import annotations + +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ATTR_TARGET, ATTRIBUTION, DOMAIN, MonitorData + + +class UptimeRobotEntity(CoordinatorEntity): + """Base UptimeRobot entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: EntityDescription, + target: str, + ) -> None: + """Initialize Uptime Robot entities.""" + super().__init__(coordinator) + self.entity_description = description + self._target = target + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_TARGET: self._target, + } + + @property + def unique_id(self) -> str | None: + """Return the unique_id of the entity.""" + return str(self.monitor.id) if self.monitor else None + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this AdGuard Home instance.""" + if self.monitor: + return { + "identifiers": {(DOMAIN, str(self.monitor.id))}, + "name": "Uptime Robot", + "manufacturer": "Uptime Robot Team", + "entry_type": "service", + "model": self.monitor.type.name, + } + return {} + + @property + def monitors(self) -> list[MonitorData]: + """Return all monitors.""" + return self.coordinator.data or [] + + @property + def monitor(self) -> MonitorData | None: + """Return the monitor for this entity.""" + return next( + ( + monitor + for monitor in self.monitors + if str(monitor.id) == self.entity_description.key + ), + None, + ) + + @property + def monitor_available(self) -> bool: + """Returtn if the monitor is available.""" + return self.monitor.status == 2 if self.monitor else False + + @property + def available(self) -> bool: + """Returtn if entity is available.""" + return self.monitor is not None diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 414defd5571..c0f880facb1 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -2,7 +2,12 @@ "domain": "uptimerobot", "name": "Uptime Robot", "documentation": "https://www.home-assistant.io/integrations/uptimerobot", - "requirements": ["pyuptimerobot==0.0.5"], - "codeowners": ["@ludeeus"], - "iot_class": "cloud_polling" -} + "requirements": [ + "pyuptimerobot==0.0.5" + ], + "codeowners": [ + "@ludeeus" + ], + "iot_class": "cloud_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json new file mode 100644 index 00000000000..817d79e57cc --- /dev/null +++ b/homeassistant/components/uptimerobot/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/en.json b/homeassistant/components/uptimerobot/translations/en.json new file mode 100644 index 00000000000..cec1753b367 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "API Key" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4cb9e2e3c4b..3d6730fe65a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -282,6 +282,7 @@ FLOWS = [ "upb", "upcloud", "upnp", + "uptimerobot", "velbus", "vera", "verisure", diff --git a/mypy.ini b/mypy.ini index 1fd92182a49..07e56f29409 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1144,6 +1144,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.uptimerobot.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.vacuum.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7674e89328f..530a605ea47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1076,6 +1076,9 @@ pytraccar==0.9.0 # homeassistant.components.tradfri pytradfri[async]==7.0.6 +# homeassistant.components.uptimerobot +pyuptimerobot==0.0.5 + # homeassistant.components.vera pyvera==0.3.13 diff --git a/tests/components/uptimerobot/__init__.py b/tests/components/uptimerobot/__init__.py new file mode 100644 index 00000000000..b8f18655820 --- /dev/null +++ b/tests/components/uptimerobot/__init__.py @@ -0,0 +1 @@ +"""Tests for the Uptime Robot integration.""" diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py new file mode 100644 index 00000000000..2c918204838 --- /dev/null +++ b/tests/components/uptimerobot/test_config_flow.py @@ -0,0 +1,98 @@ +"""Test the Uptime Robot config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, setup +from homeassistant.components.uptimerobot.const import ( + API_ATTR_MONITORS, + API_ATTR_OK, + API_ATTR_STAT, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "pyuptimerobot.UptimeRobot.getMonitors", + return_value={API_ATTR_STAT: API_ATTR_OK, API_ATTR_MONITORS: []}, + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "" + assert result2["data"] == {"api_key": "1234"} + assert len(mock_setup_entry.mock_calls) == 1 + + +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("pyuptimerobot.UptimeRobot.getMonitors", return_value=None): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_flow_import(hass): + """Test an import flow.""" + with patch( + "pyuptimerobot.UptimeRobot.getMonitors", + return_value={API_ATTR_STAT: API_ATTR_OK, API_ATTR_MONITORS: []}, + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"platform": DOMAIN, "api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {"api_key": "1234"} + + with patch( + "pyuptimerobot.UptimeRobot.getMonitors", + return_value={API_ATTR_STAT: API_ATTR_OK, API_ATTR_MONITORS: []}, + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"platform": DOMAIN, "api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" From a8e4482594f4bee23de8b035738a05ff45f221cf Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 5 Aug 2021 02:03:04 +0200 Subject: [PATCH 154/903] Update frontend to 20210804.0 (#53997) --- homeassistant/components/frontend/manifest.json | 10 +++------- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index fbf676687cb..b9a84cbec02 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,9 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": [ - "home-assistant-frontend==20210803.2" - ], + "requirements": ["home-assistant-frontend==20210804.0"], "dependencies": [ "api", "auth", @@ -17,8 +15,6 @@ "system_log", "websocket_api" ], - "codeowners": [ - "@home-assistant/frontend" - ], + "codeowners": ["@home-assistant/frontend"], "quality_scale": "internal" -} \ No newline at end of file +} diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7b9fe917279..cc4bb9d72ac 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210803.2 +home-assistant-frontend==20210804.0 httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 2ac357f0990..4b66d19b803 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -783,7 +783,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210803.2 +home-assistant-frontend==20210804.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 530a605ea47..85e8790a1d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -449,7 +449,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210803.2 +home-assistant-frontend==20210804.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From b2fffdd13e55afedad774820576fecee3ea2e0a9 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 5 Aug 2021 02:03:31 +0200 Subject: [PATCH 155/903] Add temporary fix to modbus to solve upstream problem (#53857) --- homeassistant/components/modbus/base_platform.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index b1d51a285be..f767201496c 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -60,7 +60,11 @@ class BasePlatform(Entity): def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: """Initialize the Modbus binary sensor.""" self._hub = hub - self._slave = entry.get(CONF_SLAVE) + # temporary fix, + # make sure slave is always defined to avoid an error in pymodbus + # attr(in_waiting) not defined. + # see issue #657 and PR #660 in riptideio/pymodbus + self._slave = entry.get(CONF_SLAVE, 0) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] self._value = None From ba93bda3ad0c96751e2daa27534aba4de8f53a7d Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 5 Aug 2021 00:34:46 +0000 Subject: [PATCH 156/903] [ci skip] Translation update --- .../advantage_air/translations/es-419.json | 1 + .../components/aemet/translations/es-419.json | 10 ++++ .../airvisual/translations/es-419.json | 12 +++++ .../airvisual/translations/sensor.es-419.json | 20 +++++++ .../alarm_control_panel/translations/cs.json | 4 ++ .../alarmdecoder/translations/es-419.json | 15 ++++++ .../components/ambee/translations/es-419.json | 11 ++++ .../ambee/translations/sensor.es-419.json | 10 ++++ .../apple_tv/translations/es-419.json | 52 +++++++++++++++++++ .../arcam_fmj/translations/es-419.json | 12 +++++ .../asuswrt/translations/es-419.json | 33 ++++++++++++ .../components/atag/translations/es-419.json | 3 ++ .../components/awair/translations/es-419.json | 12 +++++ .../components/axis/translations/es-419.json | 10 ++++ .../azure_devops/translations/es-419.json | 23 ++++++++ .../binary_sensor/translations/es-419.json | 16 ++++++ .../blebox/translations/es-419.json | 3 +- .../components/blink/translations/es-419.json | 27 ++++++++++ .../translations/es-419.json | 20 +++++++ .../bosch_shc/translations/es-419.json | 22 ++++++++ .../braviatv/translations/es-419.json | 3 +- .../broadlink/translations/es-419.json | 27 ++++++++++ .../canary/translations/es-419.json | 19 +++++++ .../components/cast/translations/es-419.json | 32 ++++++++++++ .../climacell/translations/es-419.json | 24 +++++++++ .../components/cloud/translations/pl.json | 2 +- .../cloudflare/translations/es-419.json | 27 ++++++++++ .../co2signal/translations/es-419.json | 16 ++++++ .../coinbase/translations/es-419.json | 14 +++++ .../coolmaster/translations/es-419.json | 5 ++ .../components/cover/translations/es-419.json | 3 +- .../deconz/translations/es-419.json | 9 +++- .../components/demo/translations/es-419.json | 1 + .../demo/translations/select.es-419.json | 8 +++ .../denonavr/translations/es-419.json | 15 ++++++ .../device_tracker/translations/es-419.json | 4 ++ .../translations/es-419.json | 7 +++ .../dexcom/translations/es-419.json | 22 ++++++++ .../components/dsmr/translations/es-419.json | 28 ++++++++++ .../dunehd/translations/es-419.json | 10 ++++ .../components/eafm/translations/es-419.json | 16 ++++++ .../econet/translations/es-419.json | 9 ++++ .../energy/translations/es-419.json | 3 ++ .../enocean/translations/es-419.json | 24 +++++++++ .../enphase_envoy/translations/es-419.json | 5 ++ .../components/epson/translations/es-419.json | 7 +++ .../components/ezviz/translations/es-419.json | 8 +++ .../faa_delays/translations/es-419.json | 19 +++++++ .../fireservicerota/translations/es-419.json | 14 +++++ .../flick_electric/translations/es-419.json | 13 +++++ .../components/flume/translations/es-419.json | 4 ++ .../forecast_solar/translations/es-419.json | 28 ++++++++++ .../forked_daapd/translations/es-419.json | 33 ++++++++++++ .../foscam/translations/es-419.json | 16 ++++++ .../freedompro/translations/es-419.json | 10 ++++ .../components/fritz/translations/es-419.json | 19 +++++++ .../fritzbox/translations/es-419.json | 3 ++ .../translations/es-419.json | 28 ++++++++++ .../translations/es-419.json | 12 +++++ .../goalzero/translations/es-419.json | 12 +++++ .../homeassistant/translations/ca.json | 1 + .../homeassistant/translations/cs.json | 1 + .../homeassistant/translations/de.json | 1 + .../homeassistant/translations/en.json | 1 + .../homeassistant/translations/et.json | 1 + .../homeassistant/translations/fr.json | 1 + .../homeassistant/translations/it.json | 1 + .../homeassistant/translations/lt.json | 7 +++ .../homeassistant/translations/nl.json | 1 + .../homeassistant/translations/pl.json | 1 + .../homeassistant/translations/ru.json | 1 + .../homeassistant/translations/zh-Hant.json | 1 + .../homematicip_cloud/translations/lt.json | 11 ++++ .../components/hyperion/translations/nl.json | 2 +- .../components/luftdaten/translations/lt.json | 11 ++++ .../components/nest/translations/lt.json | 11 ++++ .../components/point/translations/lt.json | 11 ++++ .../components/prosegur/translations/lt.json | 13 +++++ .../components/renault/translations/lt.json | 12 +++++ .../components/tradfri/translations/lt.json | 11 ++++ .../components/tuya/translations/nl.json | 2 +- .../uptimerobot/translations/cs.json | 18 +++++++ .../uptimerobot/translations/en.json | 1 - .../uptimerobot/translations/nl.json | 18 +++++++ .../xiaomi_miio/translations/select.de.json | 9 ++++ .../xiaomi_miio/translations/select.en.json | 9 ++++ .../xiaomi_miio/translations/select.et.json | 9 ++++ .../xiaomi_miio/translations/select.fr.json | 9 ++++ .../xiaomi_miio/translations/select.he.json | 7 +++ .../xiaomi_miio/translations/select.nl.json | 9 ++++ .../xiaomi_miio/translations/select.pl.json | 9 ++++ .../xiaomi_miio/translations/select.ru.json | 9 ++++ .../translations/select.zh-Hant.json | 9 ++++ .../yale_smart_alarm/translations/cs.json | 8 +-- .../components/zone/translations/lt.json | 12 +++++ 95 files changed, 1101 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/airvisual/translations/sensor.es-419.json create mode 100644 homeassistant/components/ambee/translations/es-419.json create mode 100644 homeassistant/components/ambee/translations/sensor.es-419.json create mode 100644 homeassistant/components/apple_tv/translations/es-419.json create mode 100644 homeassistant/components/arcam_fmj/translations/es-419.json create mode 100644 homeassistant/components/asuswrt/translations/es-419.json create mode 100644 homeassistant/components/awair/translations/es-419.json create mode 100644 homeassistant/components/azure_devops/translations/es-419.json create mode 100644 homeassistant/components/blink/translations/es-419.json create mode 100644 homeassistant/components/bmw_connected_drive/translations/es-419.json create mode 100644 homeassistant/components/bosch_shc/translations/es-419.json create mode 100644 homeassistant/components/broadlink/translations/es-419.json create mode 100644 homeassistant/components/canary/translations/es-419.json create mode 100644 homeassistant/components/climacell/translations/es-419.json create mode 100644 homeassistant/components/cloudflare/translations/es-419.json create mode 100644 homeassistant/components/co2signal/translations/es-419.json create mode 100644 homeassistant/components/coinbase/translations/es-419.json create mode 100644 homeassistant/components/demo/translations/select.es-419.json create mode 100644 homeassistant/components/denonavr/translations/es-419.json create mode 100644 homeassistant/components/devolo_home_control/translations/es-419.json create mode 100644 homeassistant/components/dexcom/translations/es-419.json create mode 100644 homeassistant/components/dsmr/translations/es-419.json create mode 100644 homeassistant/components/dunehd/translations/es-419.json create mode 100644 homeassistant/components/eafm/translations/es-419.json create mode 100644 homeassistant/components/econet/translations/es-419.json create mode 100644 homeassistant/components/energy/translations/es-419.json create mode 100644 homeassistant/components/enocean/translations/es-419.json create mode 100644 homeassistant/components/enphase_envoy/translations/es-419.json create mode 100644 homeassistant/components/epson/translations/es-419.json create mode 100644 homeassistant/components/ezviz/translations/es-419.json create mode 100644 homeassistant/components/faa_delays/translations/es-419.json create mode 100644 homeassistant/components/fireservicerota/translations/es-419.json create mode 100644 homeassistant/components/flick_electric/translations/es-419.json create mode 100644 homeassistant/components/forecast_solar/translations/es-419.json create mode 100644 homeassistant/components/forked_daapd/translations/es-419.json create mode 100644 homeassistant/components/foscam/translations/es-419.json create mode 100644 homeassistant/components/freedompro/translations/es-419.json create mode 100644 homeassistant/components/fritz/translations/es-419.json create mode 100644 homeassistant/components/fritzbox_callmonitor/translations/es-419.json create mode 100644 homeassistant/components/garages_amsterdam/translations/es-419.json create mode 100644 homeassistant/components/goalzero/translations/es-419.json create mode 100644 homeassistant/components/homeassistant/translations/lt.json create mode 100644 homeassistant/components/homematicip_cloud/translations/lt.json create mode 100644 homeassistant/components/luftdaten/translations/lt.json create mode 100644 homeassistant/components/nest/translations/lt.json create mode 100644 homeassistant/components/point/translations/lt.json create mode 100644 homeassistant/components/prosegur/translations/lt.json create mode 100644 homeassistant/components/renault/translations/lt.json create mode 100644 homeassistant/components/tradfri/translations/lt.json create mode 100644 homeassistant/components/uptimerobot/translations/cs.json create mode 100644 homeassistant/components/uptimerobot/translations/nl.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.de.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.en.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.et.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.fr.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.he.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.nl.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.pl.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.ru.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json create mode 100644 homeassistant/components/zone/translations/lt.json diff --git a/homeassistant/components/advantage_air/translations/es-419.json b/homeassistant/components/advantage_air/translations/es-419.json index f2f9a463527..502e9e00ddb 100644 --- a/homeassistant/components/advantage_air/translations/es-419.json +++ b/homeassistant/components/advantage_air/translations/es-419.json @@ -2,6 +2,7 @@ "config": { "step": { "user": { + "description": "Con\u00e9ctese a la API de su tableta de pared Advantage Air.", "title": "Conectar" } } diff --git a/homeassistant/components/aemet/translations/es-419.json b/homeassistant/components/aemet/translations/es-419.json index 4b3db0a8833..3a02d682f34 100644 --- a/homeassistant/components/aemet/translations/es-419.json +++ b/homeassistant/components/aemet/translations/es-419.json @@ -5,8 +5,18 @@ "data": { "name": "Nombre de la integraci\u00f3n" }, + "description": "Configure la integraci\u00f3n de AEMET OpenData. Para generar la clave API vaya a https://opendata.aemet.es/centrodedescargas/altaUsuario", "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Recopile datos de las estaciones meteorol\u00f3gicas de AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/es-419.json b/homeassistant/components/airvisual/translations/es-419.json index b0022391e62..6e26be959f9 100644 --- a/homeassistant/components/airvisual/translations/es-419.json +++ b/homeassistant/components/airvisual/translations/es-419.json @@ -13,6 +13,15 @@ "description": "Utilice la API en la nube de AirVisual para monitorear una latitud / longitud.", "title": "Configurar una geograf\u00eda" }, + "geography_by_name": { + "data": { + "city": "Ciudad", + "country": "Pa\u00eds", + "state": "estado" + }, + "description": "Utilice la API en la nube de AirVisual para monitorear una ciudad/estado/pa\u00eds.", + "title": "Configurar una geograf\u00eda" + }, "node_pro": { "data": { "ip_address": "Direcci\u00f3n IP/nombre de host de la unidad", @@ -21,6 +30,9 @@ "description": "Monitoree una unidad AirVisual personal. La contrase\u00f1a se puede recuperar de la interfaz de usuario de la unidad.", "title": "Configurar un AirVisual Node/Pro" }, + "reauth_confirm": { + "title": "Vuelva a autenticar AirVisual" + }, "user": { "description": "Monitoree la calidad del aire en una ubicaci\u00f3n geogr\u00e1fica.", "title": "Configurar AirVisual" diff --git a/homeassistant/components/airvisual/translations/sensor.es-419.json b/homeassistant/components/airvisual/translations/sensor.es-419.json new file mode 100644 index 00000000000..7af0e1465aa --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.es-419.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Mon\u00f3xido de carbono", + "n2": "Dioxido de nitrogeno", + "o3": "Ozono", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Di\u00f3xido de azufre" + }, + "airvisual__pollutant_level": { + "good": "Bueno", + "hazardous": "Peligroso", + "moderate": "Moderado", + "unhealthy": "Insalubre", + "unhealthy_sensitive": "Insalubre para grupos sensibles", + "very_unhealthy": "Muy insalubre" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/cs.json b/homeassistant/components/alarm_control_panel/translations/cs.json index 66786dfc0e2..7a831a2e2e6 100644 --- a/homeassistant/components/alarm_control_panel/translations/cs.json +++ b/homeassistant/components/alarm_control_panel/translations/cs.json @@ -4,6 +4,7 @@ "arm_away": "Aktivovat {entity_name} v re\u017eimu nep\u0159\u00edtomnost", "arm_home": "Aktivovat {entity_name} v re\u017eimu domov", "arm_night": "Aktivovat {entity_name} v no\u010dn\u00edm re\u017eimu", + "arm_vacation": "Aktivovat {entity_name} v re\u017eimu dovolen\u00e1", "disarm": "Odbezpe\u010dit {entity_name}", "trigger": "Spustit {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} je v re\u017eimu nep\u0159\u00edtomnost", "is_armed_home": "{entity_name} je v re\u017eimu domov", "is_armed_night": "{entity_name} je v no\u010dn\u00edm re\u017eimu", + "is_armed_vacation": "{entity_name} je v re\u017eimu dovolen\u00e1", "is_disarmed": "{entity_name} nen\u00ed zabezpe\u010den", "is_triggered": "{entity_name} je spu\u0161t\u011bn" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} v re\u017eimu nep\u0159\u00edtomnost", "armed_home": "{entity_name} v re\u017eimu domov", "armed_night": "{entity_name} v no\u010dn\u00edm re\u017eimu", + "armed_vacation": "{entity_name} v re\u017eimu dovolen\u00e1", "disarmed": "{entity_name} nezabezpe\u010den", "triggered": "{entity_name} spu\u0161t\u011bn" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Zabezpe\u010deno u\u017eivatelsk\u00fdm obejit\u00edm", "armed_home": "Re\u017eim domov", "armed_night": "No\u010dn\u00ed re\u017eim", + "armed_vacation": "V re\u017eimu dovolen\u00e1", "arming": "Zabezpe\u010dov\u00e1n\u00ed", "disarmed": "Nezabezpe\u010deno", "disarming": "Odbezpe\u010dov\u00e1n\u00ed", diff --git a/homeassistant/components/alarmdecoder/translations/es-419.json b/homeassistant/components/alarmdecoder/translations/es-419.json index 2152084ea56..c4cfbdf82ef 100644 --- a/homeassistant/components/alarmdecoder/translations/es-419.json +++ b/homeassistant/components/alarmdecoder/translations/es-419.json @@ -20,6 +20,10 @@ } }, "options": { + "error": { + "int": "El campo siguiente debe ser un n\u00famero entero.", + "loop_range": "El bucle de RF debe ser un n\u00famero entero entre 1 y 4." + }, "step": { "arm_settings": { "data": { @@ -30,6 +34,17 @@ "data": { "edit_select": "Editar" } + }, + "zone_details": { + "data": { + "zone_name": "Nombre de zona", + "zone_rfid": "Serie RF" + } + }, + "zone_select": { + "data": { + "zone_number": "N\u00famero de zona" + } } } } diff --git a/homeassistant/components/ambee/translations/es-419.json b/homeassistant/components/ambee/translations/es-419.json new file mode 100644 index 00000000000..dee7d514b48 --- /dev/null +++ b/homeassistant/components/ambee/translations/es-419.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "description": "Vuelva a autenticarse con su cuenta de Ambee." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.es-419.json b/homeassistant/components/ambee/translations/sensor.es-419.json new file mode 100644 index 00000000000..a676ca7aa5e --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.es-419.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Alto", + "low": "Bajo", + "moderate": "Moderado", + "very high": "Muy alto" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/es-419.json b/homeassistant/components/apple_tv/translations/es-419.json new file mode 100644 index 00000000000..75e6fb43ff2 --- /dev/null +++ b/homeassistant/components/apple_tv/translations/es-419.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "backoff": "El dispositivo no acepta solicitudes de emparejamiento en este momento (es posible que haya ingresado un c\u00f3digo PIN no v\u00e1lido demasiadas veces), vuelva a intentarlo m\u00e1s tarde.", + "device_did_not_pair": "No se intent\u00f3 finalizar el proceso de emparejamiento desde el dispositivo.", + "invalid_config": "La configuraci\u00f3n de este dispositivo est\u00e1 incompleta. Intente agregarlo nuevamente." + }, + "error": { + "no_usable_service": "Se encontr\u00f3 un dispositivo, pero no se pudo identificar ninguna forma de establecer una conexi\u00f3n con \u00e9l. Si sigue viendo este mensaje, intente especificar su direcci\u00f3n IP o reinicie su Apple TV." + }, + "step": { + "confirm": { + "description": "Est\u00e1 a punto de agregar el Apple TV llamado `{name} ` a Home Assistant. \n\n** Para completar el proceso, es posible que deba ingresar varios c\u00f3digos PIN. ** \n\nTenga en cuenta que *no* podr\u00e1 apagar su Apple TV con esta integraci\u00f3n. \u00a1Solo se apagar\u00e1 el reproductor multimedia en Home Assistant!", + "title": "Confirma la adici\u00f3n de Apple TV" + }, + "pair_no_pin": { + "description": "El emparejamiento es necesario para el servicio `{protocol}`. Ingresa el PIN {pin} en tu Apple TV para continuar.", + "title": "Emparejamiento" + }, + "pair_with_pin": { + "description": "El emparejamiento es necesario para el `{protocol}`. Ingrese el c\u00f3digo PIN que se muestra en la pantalla. Se omitir\u00e1n los ceros iniciales, es decir, introduzca 123 si el c\u00f3digo que se muestra es 0123.", + "title": "Emparejamiento" + }, + "reconfigure": { + "description": "Este Apple TV est\u00e1 experimentando algunas dificultades de conexi\u00f3n y debe reconfigurarse.", + "title": "Reconfiguraci\u00f3n del dispositivo" + }, + "service_problem": { + "description": "Ocurri\u00f3 un problema al emparejar el protocolo \" {protocol} \". Ser\u00e1 ignorado.", + "title": "No se pudo agregar el servicio" + }, + "user": { + "data": { + "device_input": "Dispositivo" + }, + "description": "Comience ingresando el nombre del dispositivo (por ejemplo, cocina o dormitorio) o la direcci\u00f3n IP del Apple TV que desea agregar. Si se encontraron dispositivos autom\u00e1ticamente en su red, se muestran a continuaci\u00f3n. \n\nSi no puede ver su dispositivo o experimenta alg\u00fan problema, intente especificar la direcci\u00f3n IP del dispositivo. \n\n{devices}", + "title": "Configurar un nuevo Apple TV" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "start_off": "No encienda el dispositivo al iniciar Home Assistant" + }, + "description": "Configurar los ajustes generales del dispositivo" + } + } + }, + "title": "Apple TV" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/es-419.json b/homeassistant/components/arcam_fmj/translations/es-419.json new file mode 100644 index 00000000000..a69b353354b --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "confirm": { + "description": "\u00bfDesea agregar Arcam FMJ en `{host}` a Home Assistant?" + }, + "user": { + "description": "Ingrese el nombre de host o la direcci\u00f3n IP del dispositivo." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/es-419.json b/homeassistant/components/asuswrt/translations/es-419.json new file mode 100644 index 00000000000..1c6b80588cd --- /dev/null +++ b/homeassistant/components/asuswrt/translations/es-419.json @@ -0,0 +1,33 @@ +{ + "config": { + "error": { + "pwd_and_ssh": "Solo proporcione la contrase\u00f1a o el archivo de clave SSH", + "pwd_or_ssh": "Proporcione la contrase\u00f1a o el archivo de clave SSH", + "ssh_not_file": "No se encontr\u00f3 el archivo de clave SSH" + }, + "step": { + "user": { + "data": { + "protocol": "Protocolo de comunicaci\u00f3n a utilizar", + "ssh_key": "Ruta a su archivo de clave SSH (en lugar de contrase\u00f1a)" + }, + "description": "Establezca el par\u00e1metro requerido para conectarse a su enrutador", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Segundos de espera antes de considerar un dispositivo ausente", + "dnsmasq": "La ubicaci\u00f3n en el enrutador de los archivos dnsmasq.leases", + "interface": "La interfaz de la que desea obtener estad\u00edsticas (por ejemplo, eth0, eth1, etc.)", + "require_ip": "Los dispositivos deben tener IP (para el modo de punto de acceso)", + "track_unknown": "Seguimiento de dispositivos desconocidos / sin nombre" + }, + "title": "Opciones de AsusWRT" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/es-419.json b/homeassistant/components/atag/translations/es-419.json index 92e7fae8703..358bc754c97 100644 --- a/homeassistant/components/atag/translations/es-419.json +++ b/homeassistant/components/atag/translations/es-419.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "Solo se puede agregar un dispositivo Atag a Home Assistant" }, + "error": { + "unauthorized": "Emparejamiento denegado, verifique el dispositivo para obtener una solicitud de autenticaci\u00f3n" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/awair/translations/es-419.json b/homeassistant/components/awair/translations/es-419.json new file mode 100644 index 00000000000..f487cd397c4 --- /dev/null +++ b/homeassistant/components/awair/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "reauth": { + "description": "Vuelva a ingresar su token de acceso de desarrollador de Awair." + }, + "user": { + "description": "Debe registrarse para obtener un token de acceso de desarrollador de Awair en: https://developer.getawair.com/onboard/login" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/translations/es-419.json b/homeassistant/components/axis/translations/es-419.json index 0e1c1e99b36..39d216dd475 100644 --- a/homeassistant/components/axis/translations/es-419.json +++ b/homeassistant/components/axis/translations/es-419.json @@ -21,5 +21,15 @@ "title": "Configurar dispositivo Axis" } } + }, + "options": { + "step": { + "configure_stream": { + "data": { + "stream_profile": "Seleccionar perfil de transmisi\u00f3n para usar" + }, + "title": "Opciones de transmisi\u00f3n de video del dispositivo Axis" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/es-419.json b/homeassistant/components/azure_devops/translations/es-419.json new file mode 100644 index 00000000000..7ac7d2a930d --- /dev/null +++ b/homeassistant/components/azure_devops/translations/es-419.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "project_error": "No se pudo obtener la informaci\u00f3n del proyecto." + }, + "flow_title": "{project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Token de acceso personal (PAT)" + }, + "description": "Error de autenticaci\u00f3n para {project_url}. Ingrese sus credenciales actuales.", + "title": "Reautenticaci\u00f3n" + }, + "user": { + "data": { + "organization": "Organizaci\u00f3n", + "personal_access_token": "Token de acceso personal (PAT)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/es-419.json b/homeassistant/components/binary_sensor/translations/es-419.json index d8cc4219097..dad07f9b771 100644 --- a/homeassistant/components/binary_sensor/translations/es-419.json +++ b/homeassistant/components/binary_sensor/translations/es-419.json @@ -98,6 +98,10 @@ "off": "Normal", "on": "Baja" }, + "battery_charging": { + "off": "No esta cargando", + "on": "Cargando" + }, "cold": { "off": "Normal", "on": "Fr\u00edo" @@ -122,6 +126,10 @@ "off": "Normal", "on": "Caliente" }, + "light": { + "off": "Sin luz", + "on": "Luz detectada" + }, "lock": { "off": "Bloqueado", "on": "Desbloqueado" @@ -134,6 +142,10 @@ "off": "Despejado", "on": "Detectado" }, + "moving": { + "off": "Sin movimiento", + "on": "Movimiento" + }, "occupancy": { "off": "Despejado", "on": "Detectado" @@ -142,6 +154,10 @@ "off": "Cerrado", "on": "Abierto" }, + "plug": { + "off": "Desenchufado", + "on": "Enchufado" + }, "presence": { "off": "Fuera de casa", "on": "En Casa" diff --git a/homeassistant/components/blebox/translations/es-419.json b/homeassistant/components/blebox/translations/es-419.json index eb0545e4fa4..89bafe049f2 100644 --- a/homeassistant/components/blebox/translations/es-419.json +++ b/homeassistant/components/blebox/translations/es-419.json @@ -16,7 +16,8 @@ "host": "Direcci\u00f3n IP", "port": "Puerto" }, - "description": "Configure su BleBox para integrarse con Home Assistant." + "description": "Configure su BleBox para integrarse con Home Assistant.", + "title": "Configure su dispositivo BleBox" } } } diff --git a/homeassistant/components/blink/translations/es-419.json b/homeassistant/components/blink/translations/es-419.json new file mode 100644 index 00000000000..d44527dd7ca --- /dev/null +++ b/homeassistant/components/blink/translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "2fa": { + "data": { + "2fa": "C\u00f3digo de dos factores" + }, + "description": "Ingrese el PIN enviado a su correo electr\u00f3nico", + "title": "Autenticaci\u00f3n de dos factores" + }, + "user": { + "title": "Iniciar sesi\u00f3n con cuenta Blink" + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "Intervalo de escaneo (segundos)" + }, + "description": "Configurar la integraci\u00f3n de Blink", + "title": "Opciones de Blink" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/es-419.json b/homeassistant/components/bmw_connected_drive/translations/es-419.json new file mode 100644 index 00000000000..0bce46abd97 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/translations/es-419.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "region": "Regi\u00f3n de ConnectedDrive" + } + } + } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "Solo lectura (solo sensores y notificaci\u00f3n, sin ejecuci\u00f3n de servicios, sin bloqueo)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/es-419.json b/homeassistant/components/bosch_shc/translations/es-419.json new file mode 100644 index 00000000000..fdb8903a318 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "confirm_discovery": { + "description": "Presione el bot\u00f3n frontal del Controlador de Hogar Inteligente de Bosch hasta que el LED comience a parpadear.\n\u00bfListo para continuar configurando {model} @ {host} con Home Assistant?" + }, + "credentials": { + "data": { + "password": "Contrase\u00f1a del controlador Smart Home" + } + }, + "reauth_confirm": { + "description": "La integraci\u00f3n bosch_shc necesita volver a autenticar su cuenta" + }, + "user": { + "description": "Configure su controlador de hogar inteligente de Bosch para permitir la supervisi\u00f3n y el control con Home Assistant.", + "title": "Par\u00e1metros de autenticaci\u00f3n SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/es-419.json b/homeassistant/components/braviatv/translations/es-419.json index 6a2a0da982e..319eff13b98 100644 --- a/homeassistant/components/braviatv/translations/es-419.json +++ b/homeassistant/components/braviatv/translations/es-419.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Esta televisi\u00f3n ya est\u00e1 configurada." + "already_configured": "Esta televisi\u00f3n ya est\u00e1 configurada.", + "no_ip_control": "El control de IP est\u00e1 desactivado en su televisor o el televisor no es compatible." }, "error": { "cannot_connect": "No se pudo conectar, host inv\u00e1lido o c\u00f3digo PIN.", diff --git a/homeassistant/components/broadlink/translations/es-419.json b/homeassistant/components/broadlink/translations/es-419.json new file mode 100644 index 00000000000..9c3129a2c6c --- /dev/null +++ b/homeassistant/components/broadlink/translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "not_supported": "Dispositivo no compatible" + }, + "flow_title": "{name} ({model} en {host})", + "step": { + "auth": { + "title": "Autenticarse en el dispositivo" + }, + "finish": { + "title": "Elija un nombre para el dispositivo" + }, + "reset": { + "description": "{name} ({model} en {host}) est\u00e1 bloqueado. Debe desbloquear el dispositivo para autenticarse y completar la configuraci\u00f3n. Instrucciones:\n 1. Abra la aplicaci\u00f3n Broadlink.\n 2. Haga clic en el dispositivo.\n 3. Haga clic en `...` en la esquina superior derecha.\n 4. Despl\u00e1cese hasta el final de la p\u00e1gina.\n 5. Desactive el bloqueo.", + "title": "Desbloquear el dispositivo" + }, + "unlock": { + "data": { + "unlock": "S\u00ed, hazlo." + }, + "description": "{name} ({model} en {host}) est\u00e1 bloqueado. Esto puede provocar problemas de autenticaci\u00f3n en Home Assistant. \u00bfQuieres desbloquearlo?", + "title": "Desbloquear el dispositivo (opcional)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/canary/translations/es-419.json b/homeassistant/components/canary/translations/es-419.json new file mode 100644 index 00000000000..8ce6a8fb855 --- /dev/null +++ b/homeassistant/components/canary/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "title": "Conectarse a Canary" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "Argumentos pasados a ffmpeg para c\u00e1maras", + "timeout": "Solicitar tiempo de espera (segundos)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/translations/es-419.json b/homeassistant/components/cast/translations/es-419.json index fd893b9680f..ffcc09b23ca 100644 --- a/homeassistant/components/cast/translations/es-419.json +++ b/homeassistant/components/cast/translations/es-419.json @@ -3,10 +3,42 @@ "abort": { "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast." }, + "error": { + "invalid_known_hosts": "Los hosts conocidos deben ser una lista de hosts separados por comas." + }, "step": { + "config": { + "data": { + "known_hosts": "Hosts conocidos" + }, + "description": "Hosts conocidos: una lista separada por comas de nombres de host o direcciones IP de dispositivos de transmisi\u00f3n, que se utiliza si el descubrimiento de mDNS no funciona.", + "title": "Configuraci\u00f3n de Google Cast" + }, "confirm": { "description": "\u00bfDesea configurar Google Cast?" } } + }, + "options": { + "error": { + "invalid_known_hosts": "Los hosts conocidos deben ser una lista de hosts separados por comas." + }, + "step": { + "advanced_options": { + "data": { + "ignore_cec": "Ignorar CEC", + "uuid": "UUID permitidos" + }, + "description": "UUID permitidos: una lista separada por comas de UUID de dispositivos de transmisi\u00f3n para agregar a Home Assistant. \u00daselo solo si no desea agregar todos los dispositivos de transmisi\u00f3n disponibles.\nIgnorar CEC: una lista separada por comas de Chromecasts que debe ignorar los datos de CEC para determinar la entrada activa. Esto se pasar\u00e1 a pychromecast.IGNORE_CEC.", + "title": "Configuraci\u00f3n avanzada de Google Cast" + }, + "basic_options": { + "data": { + "known_hosts": "Hosts conocidos" + }, + "description": "Hosts conocidos: una lista separada por comas de nombres de host o direcciones IP de dispositivos de transmisi\u00f3n, que se utiliza si el descubrimiento de mDNS no funciona.", + "title": "Configuraci\u00f3n de Google Cast" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/es-419.json b/homeassistant/components/climacell/translations/es-419.json new file mode 100644 index 00000000000..deb60db2004 --- /dev/null +++ b/homeassistant/components/climacell/translations/es-419.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "rate_limited": "Actualmente la tarifa est\u00e1 limitada. Vuelve a intentarlo m\u00e1s tarde." + }, + "step": { + "user": { + "data": { + "api_version": "Versi\u00f3n de la API" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timestep": "Min. entre pron\u00f3sticos de NowCast" + }, + "description": "Si elige habilitar la entidad de pron\u00f3stico \"nowcast\", puede configurar el n\u00famero de minutos entre cada pron\u00f3stico. El n\u00famero de pron\u00f3sticos proporcionados depende del n\u00famero de minutos elegidos entre los pron\u00f3sticos." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/pl.json b/homeassistant/components/cloud/translations/pl.json index 1df32a14d8e..30aaeeb77d1 100644 --- a/homeassistant/components/cloud/translations/pl.json +++ b/homeassistant/components/cloud/translations/pl.json @@ -4,7 +4,7 @@ "alexa_enabled": "Alexa w\u0142\u0105czona", "can_reach_cert_server": "Dost\u0119p do serwera certyfikat\u00f3w", "can_reach_cloud": "Dost\u0119p do chmury Home Assistant", - "can_reach_cloud_auth": "Dost\u0119p do serwera certyfikat\u00f3w", + "can_reach_cloud_auth": "Dost\u0119p do serwera uwierzytelniania", "google_enabled": "Asystent Google w\u0142\u0105czony", "logged_in": "Zalogowany", "relayer_connected": "Relayer pod\u0142\u0105czony", diff --git a/homeassistant/components/cloudflare/translations/es-419.json b/homeassistant/components/cloudflare/translations/es-419.json new file mode 100644 index 00000000000..03b49267d12 --- /dev/null +++ b/homeassistant/components/cloudflare/translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "description": "Vuelva a autenticarse con su cuenta de Cloudflare." + } + }, + "records": { + "data": { + "records": "Registros" + }, + "title": "Elegir los registros que desea actualizar" + }, + "user": { + "description": "Esta integraci\u00f3n requiere un token de API creado con Zone: Zone: Read y Zone: DNS: Edit permisos para todas las zonas de su cuenta.", + "title": "Conectarse a Cloudflare" + }, + "zone": { + "data": { + "zone": "Zona" + }, + "title": "Elija la zona para actualizar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/es-419.json b/homeassistant/components/co2signal/translations/es-419.json new file mode 100644 index 00000000000..023c867ee9b --- /dev/null +++ b/homeassistant/components/co2signal/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "country": { + "data": { + "country_code": "C\u00f3digo de pa\u00eds" + } + }, + "user": { + "data": { + "location": "Obtener datos para" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/es-419.json b/homeassistant/components/coinbase/translations/es-419.json new file mode 100644 index 00000000000..12acea8a7df --- /dev/null +++ b/homeassistant/components/coinbase/translations/es-419.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "Secreto de la API", + "exchange_rates": "Tipos de cambio" + }, + "description": "Ingrese los detalles de su clave API proporcionada por Coinbase.", + "title": "Detalles clave de la API de Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/es-419.json b/homeassistant/components/coolmaster/translations/es-419.json index 9073238aa91..0c41a0dbfe5 100644 --- a/homeassistant/components/coolmaster/translations/es-419.json +++ b/homeassistant/components/coolmaster/translations/es-419.json @@ -6,6 +6,11 @@ "step": { "user": { "data": { + "cool": "Soporta el modo de enfriamiento", + "dry": "Soporta el modo seco", + "fan_only": "Soporta el modo solo ventilador", + "heat": "Soporta el modo de calor", + "heat_cool": "Soporta el modo autom\u00e1tico de calor/fr\u00edo", "host": "Host", "off": "Puede ser apagado" }, diff --git a/homeassistant/components/cover/translations/es-419.json b/homeassistant/components/cover/translations/es-419.json index c6f9f7db7dd..d2a1aebaa1d 100644 --- a/homeassistant/components/cover/translations/es-419.json +++ b/homeassistant/components/cover/translations/es-419.json @@ -6,7 +6,8 @@ "open": "Abrir {entity_name}", "open_tilt": "Abrir la inclinaci\u00f3n de {entity_name}", "set_position": "Establecer la posici\u00f3n de {entity_name}", - "set_tilt_position": "Establecer la posici\u00f3n de inclinaci\u00f3n {entity_name}" + "set_tilt_position": "Establecer la posici\u00f3n de inclinaci\u00f3n {entity_name}", + "stop": "Detener {entity_name}" }, "condition_type": { "is_closed": "{entity_name} est\u00e1 cerrado", diff --git a/homeassistant/components/deconz/translations/es-419.json b/homeassistant/components/deconz/translations/es-419.json index e439d1da949..ceb0ca39d2c 100644 --- a/homeassistant/components/deconz/translations/es-419.json +++ b/homeassistant/components/deconz/translations/es-419.json @@ -4,6 +4,7 @@ "already_configured": "El Bridge ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n para el puente ya est\u00e1 en progreso.", "no_bridges": "No se descubrieron puentes deCONZ", + "no_hardware_available": "No hay hardware de radio conectado a deCONZ", "not_deconz_bridge": "No es un puente deCONZ", "updated_instance": "Instancia deCONZ actualizada con nueva direcci\u00f3n de host" }, @@ -41,6 +42,10 @@ "button_2": "Segundo bot\u00f3n", "button_3": "Tercer bot\u00f3n", "button_4": "Cuarto bot\u00f3n", + "button_5": "Quinto bot\u00f3n", + "button_6": "Sexto bot\u00f3n", + "button_7": "S\u00e9ptimo bot\u00f3n", + "button_8": "Octavo bot\u00f3n", "close": "Cerrar", "dim_down": "Bajar la intensidad", "dim_up": "Aumentar intensidad", @@ -65,6 +70,7 @@ "remote_button_quadruple_press": "El bot\u00f3n \"{subtype}\" ha sido pulsado 4 veces", "remote_button_quintuple_press": "El bot\u00f3n \"{subtype}\" ha sido pulsado 5 veces", "remote_button_rotated": "Bot\u00f3n girado \"{subtype}\"", + "remote_button_rotated_fast": "Bot\u00f3n girado r\u00e1pidamente \"{subtype}\"", "remote_button_rotation_stopped": "Se detuvo la rotaci\u00f3n del bot\u00f3n \"{subtype}\"", "remote_button_short_press": "Se presion\u00f3 el bot\u00f3n \"{subtype}\"", "remote_button_short_release": "Se solt\u00f3 el bot\u00f3n \"{subtype}\"", @@ -92,7 +98,8 @@ "deconz_devices": { "data": { "allow_clip_sensor": "Permitir sensores deCONZ CLIP", - "allow_deconz_groups": "Permitir grupos de luz deCONZ" + "allow_deconz_groups": "Permitir grupos de luz deCONZ", + "allow_new_devices": "Permitir la adici\u00f3n autom\u00e1tica de nuevos dispositivos" }, "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ", "title": "Opciones de deCONZ" diff --git a/homeassistant/components/demo/translations/es-419.json b/homeassistant/components/demo/translations/es-419.json index 8057621520a..d7c6160bc30 100644 --- a/homeassistant/components/demo/translations/es-419.json +++ b/homeassistant/components/demo/translations/es-419.json @@ -4,6 +4,7 @@ "options_1": { "data": { "bool": "Booleano opcional", + "constant": "Constante", "int": "Entrada num\u00e9rica" } }, diff --git a/homeassistant/components/demo/translations/select.es-419.json b/homeassistant/components/demo/translations/select.es-419.json new file mode 100644 index 00000000000..bc66e11847a --- /dev/null +++ b/homeassistant/components/demo/translations/select.es-419.json @@ -0,0 +1,8 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Velocidad de la luz", + "ridiculous_speed": "Velocidad rid\u00edcula" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/es-419.json b/homeassistant/components/denonavr/translations/es-419.json new file mode 100644 index 00000000000..c506f9f6aac --- /dev/null +++ b/homeassistant/components/denonavr/translations/es-419.json @@ -0,0 +1,15 @@ +{ + "options": { + "step": { + "init": { + "data": { + "update_audyssey": "Actualizar la configuraci\u00f3n de Audyssey", + "zone2": "Configurar Zona 2", + "zone3": "Configurar Zona 3" + }, + "description": "Especificar configuraciones opcionales", + "title": "Receptores de red Denon AVR" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/es-419.json b/homeassistant/components/device_tracker/translations/es-419.json index 8a8b7197dcb..26b8877d4ce 100644 --- a/homeassistant/components/device_tracker/translations/es-419.json +++ b/homeassistant/components/device_tracker/translations/es-419.json @@ -3,6 +3,10 @@ "condition_type": { "is_home": "{entity_name} est\u00e1 en casa", "is_not_home": "{entity_name} no est\u00e1 en casa" + }, + "trigger_type": { + "enters": "{entity_name} ingresa a una zona", + "leaves": "{entity_name} abandona una zona" } }, "state": { diff --git a/homeassistant/components/devolo_home_control/translations/es-419.json b/homeassistant/components/devolo_home_control/translations/es-419.json new file mode 100644 index 00000000000..b9e484949df --- /dev/null +++ b/homeassistant/components/devolo_home_control/translations/es-419.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "reauth_failed": "Utilice el mismo usuario mydevolo que antes." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/translations/es-419.json b/homeassistant/components/dexcom/translations/es-419.json new file mode 100644 index 00000000000..a2d55e2b462 --- /dev/null +++ b/homeassistant/components/dexcom/translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "server": "Servidor" + }, + "description": "Ingrese las credenciales de Dexcom Share", + "title": "Configurar la integraci\u00f3n de Dexcom" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "unit_of_measurement": "Unidad de medida" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr/translations/es-419.json b/homeassistant/components/dsmr/translations/es-419.json new file mode 100644 index 00000000000..82e9427b171 --- /dev/null +++ b/homeassistant/components/dsmr/translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "setup_serial": { + "title": "Dispositivo" + }, + "setup_serial_manual_path": { + "title": "Ruta" + }, + "user": { + "data": { + "type": "Tipo de conecci\u00f3n" + }, + "title": "Seleccione el tipo de conexi\u00f3n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "time_between_update": "Tiempo m\u00ednimo entre actualizaciones de entidad [s]" + }, + "title": "Opciones DSMR" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dunehd/translations/es-419.json b/homeassistant/components/dunehd/translations/es-419.json new file mode 100644 index 00000000000..5ad7a6640b4 --- /dev/null +++ b/homeassistant/components/dunehd/translations/es-419.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "Configure la integraci\u00f3n de Dune HD. Si tiene problemas con la configuraci\u00f3n, vaya a: https://www.home-assistant.io/integrations/dunehd \n\n Aseg\u00farese de que su reproductor est\u00e9 encendido.", + "title": "Dune HD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eafm/translations/es-419.json b/homeassistant/components/eafm/translations/es-419.json new file mode 100644 index 00000000000..52757f0a1d5 --- /dev/null +++ b/homeassistant/components/eafm/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "no_stations": "No se encontraron estaciones de monitoreo de inundaciones." + }, + "step": { + "user": { + "data": { + "station": "Estaci\u00f3n" + }, + "description": "Seleccione la estaci\u00f3n que desea monitorear", + "title": "Seguimiento de una estaci\u00f3n de monitoreo de inundaciones" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/es-419.json b/homeassistant/components/econet/translations/es-419.json new file mode 100644 index 00000000000..f019a47ae4a --- /dev/null +++ b/homeassistant/components/econet/translations/es-419.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "Configurar cuenta Rheem EcoNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/es-419.json b/homeassistant/components/energy/translations/es-419.json new file mode 100644 index 00000000000..64c2f5bffa1 --- /dev/null +++ b/homeassistant/components/energy/translations/es-419.json @@ -0,0 +1,3 @@ +{ + "title": "Energ\u00eda" +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/es-419.json b/homeassistant/components/enocean/translations/es-419.json new file mode 100644 index 00000000000..a0eaca491b2 --- /dev/null +++ b/homeassistant/components/enocean/translations/es-419.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "invalid_dongle_path": "Ruta de dongle no v\u00e1lida" + }, + "error": { + "invalid_dongle_path": "No se encontr\u00f3 ning\u00fan dongle v\u00e1lido para esta ruta" + }, + "step": { + "detect": { + "data": { + "path": "Ruta de dongle USB" + }, + "title": "Seleccione la ruta a su ENOcean dongle" + }, + "manual": { + "data": { + "path": "Ruta de dongle USB" + }, + "title": "Ingrese la ruta a su ENOcean dongle" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/es-419.json b/homeassistant/components/enphase_envoy/translations/es-419.json new file mode 100644 index 00000000000..3dd80c3f60b --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/es-419.json @@ -0,0 +1,5 @@ +{ + "config": { + "flow_title": "{serial} ({host})" + } +} \ No newline at end of file diff --git a/homeassistant/components/epson/translations/es-419.json b/homeassistant/components/epson/translations/es-419.json new file mode 100644 index 00000000000..230dada00f7 --- /dev/null +++ b/homeassistant/components/epson/translations/es-419.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "powered_off": "\u00bfEst\u00e1 encendido el proyector? Debe encender el proyector para la configuraci\u00f3n inicial." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/es-419.json b/homeassistant/components/ezviz/translations/es-419.json new file mode 100644 index 00000000000..376cb65c383 --- /dev/null +++ b/homeassistant/components/ezviz/translations/es-419.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "ezviz_cloud_account_missing": "Falta la cuenta en la nube de Ezviz. Vuelva a configurar la cuenta en la nube de Ezviz" + }, + "flow_title": "{serial}" + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/es-419.json b/homeassistant/components/faa_delays/translations/es-419.json new file mode 100644 index 00000000000..838f7af274d --- /dev/null +++ b/homeassistant/components/faa_delays/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Este aeropuerto ya est\u00e1 configurado." + }, + "error": { + "invalid_airport": "El c\u00f3digo del aeropuerto no es v\u00e1lido" + }, + "step": { + "user": { + "data": { + "id": "Aeropuerto" + }, + "description": "Ingrese un c\u00f3digo de aeropuerto de EE. UU. en formato IATA", + "title": "Retrasos de la FAA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fireservicerota/translations/es-419.json b/homeassistant/components/fireservicerota/translations/es-419.json new file mode 100644 index 00000000000..cf14204ec0c --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/es-419.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "reauth": { + "description": "Los tokens de autenticaci\u00f3n dejaron de ser v\u00e1lidos, inicie sesi\u00f3n para volver a crearlos." + }, + "user": { + "data": { + "url": "Sitio web" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/es-419.json b/homeassistant/components/flick_electric/translations/es-419.json new file mode 100644 index 00000000000..59ecddf99c3 --- /dev/null +++ b/homeassistant/components/flick_electric/translations/es-419.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "client_id": "Id. de cliente (opcional)", + "client_secret": "Secreto de cliente (opcional)" + }, + "title": "Credenciales de acceso a Flick" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/es-419.json b/homeassistant/components/flume/translations/es-419.json index 026875846c6..4b63e326d7f 100644 --- a/homeassistant/components/flume/translations/es-419.json +++ b/homeassistant/components/flume/translations/es-419.json @@ -9,6 +9,10 @@ "unknown": "Error inesperado" }, "step": { + "reauth_confirm": { + "description": "La contrase\u00f1a de {username} ya no es v\u00e1lida.", + "title": "Vuelva a autenticar su cuenta de Flume" + }, "user": { "data": { "client_id": "Identificaci\u00f3n del cliente", diff --git a/homeassistant/components/forecast_solar/translations/es-419.json b/homeassistant/components/forecast_solar/translations/es-419.json new file mode 100644 index 00000000000..e2b71af40de --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", + "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", + "modules power": "Potencia pico total en vatios de sus m\u00f3dulos solares" + }, + "description": "Complete los datos de sus paneles solares. Consulte la documentaci\u00f3n si un campo no est\u00e1 claro." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Clave de API Forecast.Solar (opcional)", + "azimuth": "Azimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", + "damping": "Factor de amortiguaci\u00f3n: ajusta los resultados por la ma\u00f1ana y por la noche", + "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", + "modules power": "Potencia pico total en vatios de sus m\u00f3dulos solares" + }, + "description": "Estos valores permiten modificar el resultado de Solar.Forecast. Consulte la documentaci\u00f3n si un campo no est\u00e1 claro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/translations/es-419.json b/homeassistant/components/forked_daapd/translations/es-419.json new file mode 100644 index 00000000000..c62c1889284 --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/es-419.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "not_forked_daapd": "El dispositivo no es un servidor daapd bifurcado." + }, + "error": { + "forbidden": "No puede conectarse. Verifique sus permisos de red bifurcados-daapd.", + "websocket_not_enabled": "El websocket del servidor forked-daapd no est\u00e1 habilitado." + }, + "step": { + "user": { + "data": { + "port": "Puerto API" + }, + "title": "Configurar dispositivo bifurcado-daapd" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "librespot_java_port": "Puerto para control de tuber\u00edas librespot-java (si se usa)", + "max_playlists": "N\u00famero m\u00e1ximo de listas de reproducci\u00f3n utilizadas como fuentes", + "tts_pause_time": "Segundos para pausar antes y despu\u00e9s de TTS", + "tts_volume": "Volumen de TTS (flotante en el rango [0,1])" + }, + "description": "Configure varias opciones para la integraci\u00f3n bifurcada-daapd.", + "title": "Configurar las opciones bifurcadas-daapd" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/foscam/translations/es-419.json b/homeassistant/components/foscam/translations/es-419.json new file mode 100644 index 00000000000..39027bdf914 --- /dev/null +++ b/homeassistant/components/foscam/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "invalid_response": "Respuesta no v\u00e1lida del dispositivo" + }, + "step": { + "user": { + "data": { + "rtsp_port": "Puerto RTSP", + "stream": "Stream" + } + } + } + }, + "title": "Foscam" +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/es-419.json b/homeassistant/components/freedompro/translations/es-419.json new file mode 100644 index 00000000000..ed1317689fe --- /dev/null +++ b/homeassistant/components/freedompro/translations/es-419.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "Ingrese la clave API obtenida de https://home.freedompro.eu", + "title": "Clave de API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/es-419.json b/homeassistant/components/fritz/translations/es-419.json new file mode 100644 index 00000000000..94412f031e6 --- /dev/null +++ b/homeassistant/components/fritz/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "description": "Configure las herramientas de FRITZ! Box para controlar su FRITZ! Box.\nM\u00ednimo necesario: nombre de usuario, contrase\u00f1a.", + "title": "Configurar las herramientas de FRITZ! Box" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Segundos para considerar un dispositivo en 'casa'" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/es-419.json b/homeassistant/components/fritzbox/translations/es-419.json index f66a3dc0dd0..da929ac9983 100644 --- a/homeassistant/components/fritzbox/translations/es-419.json +++ b/homeassistant/components/fritzbox/translations/es-419.json @@ -14,6 +14,9 @@ }, "description": "\u00bfDesea configurar {name}?" }, + "reauth_confirm": { + "description": "Actualice su informaci\u00f3n de inicio de sesi\u00f3n para {name}." + }, "user": { "data": { "host": "Host o direcci\u00f3n IP", diff --git a/homeassistant/components/fritzbox_callmonitor/translations/es-419.json b/homeassistant/components/fritzbox_callmonitor/translations/es-419.json new file mode 100644 index 00000000000..8b10d7d7c2a --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "insufficient_permissions": "El usuario no tiene permisos suficientes para acceder a la configuraci\u00f3n de AVM FRITZ! Box y sus agendas telef\u00f3nicas." + }, + "flow_title": "{name}", + "step": { + "phonebook": { + "data": { + "phonebook": "Directorio telef\u00f3nico" + } + } + } + }, + "options": { + "error": { + "malformed_prefixes": "Los prefijos est\u00e1n mal formados, verifique su formato." + }, + "step": { + "init": { + "data": { + "prefixes": "Prefijos (lista separada por comas)" + }, + "title": "Configurar prefijos" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/es-419.json b/homeassistant/components/garages_amsterdam/translations/es-419.json new file mode 100644 index 00000000000..ef74816d2fc --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "garage_name": "Nombre del garaje" + }, + "title": "Elija un garaje para monitorear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/goalzero/translations/es-419.json b/homeassistant/components/goalzero/translations/es-419.json new file mode 100644 index 00000000000..9d464996349 --- /dev/null +++ b/homeassistant/components/goalzero/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "confirm_discovery": { + "description": "Se recomienda reservar DHCP en su enrutador. Si no se configura, es posible que el dispositivo no est\u00e9 disponible hasta que Home Assistant detecte la nueva direcci\u00f3n IP. Consulte el manual de usuario de su enrutador." + }, + "user": { + "description": "Primero, debe descargar la aplicaci\u00f3n Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\nSiga las instrucciones para conectar su Yeti a su red Wi-Fi. Se recomienda reservar DHCP en su enrutador. Si no se configura, es posible que el dispositivo no est\u00e9 disponible hasta que Home Assistant detecte la nueva direcci\u00f3n IP. Consulte el manual de usuario de su enrutador." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/ca.json b/homeassistant/components/homeassistant/translations/ca.json index 4e227cedadd..e9c91d9df20 100644 --- a/homeassistant/components/homeassistant/translations/ca.json +++ b/homeassistant/components/homeassistant/translations/ca.json @@ -10,6 +10,7 @@ "os_version": "Versi\u00f3 del sistema operatiu", "python_version": "Versi\u00f3 de Python", "timezone": "Zona hor\u00e0ria", + "user": "Usuari", "version": "Versi\u00f3", "virtualenv": "Entorn virtual" } diff --git a/homeassistant/components/homeassistant/translations/cs.json b/homeassistant/components/homeassistant/translations/cs.json index 0b60fb374bb..fbd96241e36 100644 --- a/homeassistant/components/homeassistant/translations/cs.json +++ b/homeassistant/components/homeassistant/translations/cs.json @@ -10,6 +10,7 @@ "os_version": "Verze opera\u010dn\u00edho syst\u00e9mu", "python_version": "Verze Pythonu", "timezone": "\u010casov\u00e9 p\u00e1smo", + "user": "U\u017eivatel", "version": "Verze", "virtualenv": "Virtu\u00e1ln\u00ed prost\u0159ed\u00ed" } diff --git a/homeassistant/components/homeassistant/translations/de.json b/homeassistant/components/homeassistant/translations/de.json index 426fab01031..54909cb3c24 100644 --- a/homeassistant/components/homeassistant/translations/de.json +++ b/homeassistant/components/homeassistant/translations/de.json @@ -10,6 +10,7 @@ "os_version": "Betriebssystem-Version", "python_version": "Python-Version", "timezone": "Zeitzone", + "user": "Benutzer", "version": "Version", "virtualenv": "Virtuelle Umgebung" } diff --git a/homeassistant/components/homeassistant/translations/en.json b/homeassistant/components/homeassistant/translations/en.json index 897b577c33c..977bc203fea 100644 --- a/homeassistant/components/homeassistant/translations/en.json +++ b/homeassistant/components/homeassistant/translations/en.json @@ -10,6 +10,7 @@ "os_version": "Operating System Version", "python_version": "Python Version", "timezone": "Timezone", + "user": "User", "version": "Version", "virtualenv": "Virtual Environment" } diff --git a/homeassistant/components/homeassistant/translations/et.json b/homeassistant/components/homeassistant/translations/et.json index fd53bf02877..529b84120d7 100644 --- a/homeassistant/components/homeassistant/translations/et.json +++ b/homeassistant/components/homeassistant/translations/et.json @@ -10,6 +10,7 @@ "os_version": "Operatsioonis\u00fcsteemi versioon", "python_version": "Pythoni versioon", "timezone": "Ajav\u00f6\u00f6nd", + "user": "Kasutaja", "version": "Versioon", "virtualenv": "Virtuaalne keskkond" } diff --git a/homeassistant/components/homeassistant/translations/fr.json b/homeassistant/components/homeassistant/translations/fr.json index 6b7d4f93559..ae9dfb0a7da 100644 --- a/homeassistant/components/homeassistant/translations/fr.json +++ b/homeassistant/components/homeassistant/translations/fr.json @@ -10,6 +10,7 @@ "os_version": "Version du syst\u00e8me d'exploitation", "python_version": "Version de Python", "timezone": "Fuseau horaire", + "user": "Utilisateur", "version": "Version", "virtualenv": "Environnement virtuel" } diff --git a/homeassistant/components/homeassistant/translations/it.json b/homeassistant/components/homeassistant/translations/it.json index 2d8d73597d3..3052a536338 100644 --- a/homeassistant/components/homeassistant/translations/it.json +++ b/homeassistant/components/homeassistant/translations/it.json @@ -10,6 +10,7 @@ "os_version": "Versione del Sistema Operativo", "python_version": "Versione Python", "timezone": "Fuso orario", + "user": "Utente", "version": "Versione", "virtualenv": "Ambiente virtuale" } diff --git a/homeassistant/components/homeassistant/translations/lt.json b/homeassistant/components/homeassistant/translations/lt.json new file mode 100644 index 00000000000..b1fd35bf9db --- /dev/null +++ b/homeassistant/components/homeassistant/translations/lt.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "user": "Vartotojas" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/nl.json b/homeassistant/components/homeassistant/translations/nl.json index 8c76ffa39be..1037d161c2b 100644 --- a/homeassistant/components/homeassistant/translations/nl.json +++ b/homeassistant/components/homeassistant/translations/nl.json @@ -10,6 +10,7 @@ "os_version": "Versie van het besturingssysteem", "python_version": "Python-versie", "timezone": "Tijdzone", + "user": "Gebruiker", "version": "Versie", "virtualenv": "Virtuele omgeving" } diff --git a/homeassistant/components/homeassistant/translations/pl.json b/homeassistant/components/homeassistant/translations/pl.json index ea91096d0c2..9f85cc4ff15 100644 --- a/homeassistant/components/homeassistant/translations/pl.json +++ b/homeassistant/components/homeassistant/translations/pl.json @@ -10,6 +10,7 @@ "os_version": "Wersja systemu operacyjnego", "python_version": "Wersja Pythona", "timezone": "Strefa czasowa", + "user": "U\u017cytkownik", "version": "Wersja", "virtualenv": "\u015arodowisko wirtualne" } diff --git a/homeassistant/components/homeassistant/translations/ru.json b/homeassistant/components/homeassistant/translations/ru.json index c479fa41f43..f8932f1ea7d 100644 --- a/homeassistant/components/homeassistant/translations/ru.json +++ b/homeassistant/components/homeassistant/translations/ru.json @@ -10,6 +10,7 @@ "os_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u044b", "python_version": "\u0412\u0435\u0440\u0441\u0438\u044f Python", "timezone": "\u0427\u0430\u0441\u043e\u0432\u043e\u0439 \u043f\u043e\u044f\u0441", + "user": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", "version": "\u0412\u0435\u0440\u0441\u0438\u044f", "virtualenv": "\u0412\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u043e\u0435 \u043e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u0435" } diff --git a/homeassistant/components/homeassistant/translations/zh-Hant.json b/homeassistant/components/homeassistant/translations/zh-Hant.json index 36f4fb70e24..21897b04560 100644 --- a/homeassistant/components/homeassistant/translations/zh-Hant.json +++ b/homeassistant/components/homeassistant/translations/zh-Hant.json @@ -10,6 +10,7 @@ "os_version": "\u4f5c\u696d\u7cfb\u7d71\u7248\u672c", "python_version": "Python \u7248\u672c", "timezone": "\u6642\u5340", + "user": "\u4f7f\u7528\u8005", "version": "\u7248\u672c", "virtualenv": "\u865b\u64ec\u74b0\u5883" } diff --git a/homeassistant/components/homematicip_cloud/translations/lt.json b/homeassistant/components/homematicip_cloud/translations/lt.json new file mode 100644 index 00000000000..a270a8acbc2 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "init": { + "data": { + "pin": "PIN kodas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hyperion/translations/nl.json b/homeassistant/components/hyperion/translations/nl.json index 056971b435f..992d705a533 100644 --- a/homeassistant/components/hyperion/translations/nl.json +++ b/homeassistant/components/hyperion/translations/nl.json @@ -46,7 +46,7 @@ "init": { "data": { "effect_show_list": "Hyperion-effecten om te laten zien", - "priority": "Hyperion prioriteit te gebruiken voor kleuren en effecten" + "priority": "Hyperion prioriteit gebruiken voor kleuren en effecten" } } } diff --git a/homeassistant/components/luftdaten/translations/lt.json b/homeassistant/components/luftdaten/translations/lt.json new file mode 100644 index 00000000000..3ab861ad9ee --- /dev/null +++ b/homeassistant/components/luftdaten/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "show_on_map": "Rodyti \u017eem\u0117lapyje" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/translations/lt.json b/homeassistant/components/nest/translations/lt.json new file mode 100644 index 00000000000..3cac49e3871 --- /dev/null +++ b/homeassistant/components/nest/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "link": { + "data": { + "code": "PIN kodas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/translations/lt.json b/homeassistant/components/point/translations/lt.json new file mode 100644 index 00000000000..baf3fb1292d --- /dev/null +++ b/homeassistant/components/point/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "flow_impl": "Teik\u0117jas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/lt.json b/homeassistant/components/prosegur/translations/lt.json new file mode 100644 index 00000000000..9c06bf84e41 --- /dev/null +++ b/homeassistant/components/prosegur/translations/lt.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "country": "Valstyb\u0117", + "password": "Slapta\u017eodis", + "username": "Vartotojo vardas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/lt.json b/homeassistant/components/renault/translations/lt.json new file mode 100644 index 00000000000..883b5c03e2c --- /dev/null +++ b/homeassistant/components/renault/translations/lt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Slapta\u017eodis", + "username": "El. pa\u0161tas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/translations/lt.json b/homeassistant/components/tradfri/translations/lt.json new file mode 100644 index 00000000000..2dff6a15f18 --- /dev/null +++ b/homeassistant/components/tradfri/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "auth": { + "data": { + "security_code": "Saugumo kodas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/nl.json b/homeassistant/components/tuya/translations/nl.json index ed0488f524d..56b2ae8236f 100644 --- a/homeassistant/components/tuya/translations/nl.json +++ b/homeassistant/components/tuya/translations/nl.json @@ -52,7 +52,7 @@ }, "init": { "data": { - "discovery_interval": "Polling-interval van ontdekt apparaat in seconden", + "discovery_interval": "Polling-interval van nieuwe apparaten in seconden", "list_devices": "Selecteer de te configureren apparaten of laat leeg om de configuratie op te slaan", "query_device": "Selecteer apparaat dat query-methode zal gebruiken voor snellere statusupdate", "query_interval": "Peilinginterval van het apparaat in seconden" diff --git a/homeassistant/components/uptimerobot/translations/cs.json b/homeassistant/components/uptimerobot/translations/cs.json new file mode 100644 index 00000000000..7261d6146fb --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/en.json b/homeassistant/components/uptimerobot/translations/en.json index cec1753b367..72433ab86c3 100644 --- a/homeassistant/components/uptimerobot/translations/en.json +++ b/homeassistant/components/uptimerobot/translations/en.json @@ -5,7 +5,6 @@ }, "error": { "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" }, "step": { diff --git a/homeassistant/components/uptimerobot/translations/nl.json b/homeassistant/components/uptimerobot/translations/nl.json new file mode 100644 index 00000000000..3a77fedf228 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.de.json b/homeassistant/components/xiaomi_miio/translations/select.de.json new file mode 100644 index 00000000000..804eb7a7629 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.de.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Helligkeit", + "dim": "Dimmer", + "off": "Aus" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.en.json b/homeassistant/components/xiaomi_miio/translations/select.en.json new file mode 100644 index 00000000000..60a1d738b81 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.en.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Bright", + "dim": "Dim", + "off": "Off" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.et.json b/homeassistant/components/xiaomi_miio/translations/select.et.json new file mode 100644 index 00000000000..7195f5703b4 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.et.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Hele", + "dim": "Tuhm", + "off": "Kustu" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.fr.json b/homeassistant/components/xiaomi_miio/translations/select.fr.json new file mode 100644 index 00000000000..29c9afe1e95 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.fr.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Brillant", + "dim": "Faible", + "off": "\u00c9teint" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.he.json b/homeassistant/components/xiaomi_miio/translations/select.he.json new file mode 100644 index 00000000000..0059da60e86 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.he.json @@ -0,0 +1,7 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "off": "\u05db\u05d1\u05d5\u05d9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.nl.json b/homeassistant/components/xiaomi_miio/translations/select.nl.json new file mode 100644 index 00000000000..eaa69b3170c --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.nl.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Helder", + "dim": "Dim", + "off": "Uit" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.pl.json b/homeassistant/components/xiaomi_miio/translations/select.pl.json new file mode 100644 index 00000000000..ba5a0ab727f --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.pl.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "jasne", + "dim": "ciemne", + "off": "wy\u0142\u0105czone" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.ru.json b/homeassistant/components/xiaomi_miio/translations/select.ru.json new file mode 100644 index 00000000000..4dac3002d1b --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.ru.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "\u042f\u0440\u043a\u043e", + "dim": "\u0422\u0443\u0441\u043a\u043b\u043e", + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json b/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json new file mode 100644 index 00000000000..ed977dc9cd5 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "\u4eae\u5149", + "dim": "\u8abf\u5149", + "off": "\u95dc\u9589" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/cs.json b/homeassistant/components/yale_smart_alarm/translations/cs.json index f19158bca25..70947657e4d 100644 --- a/homeassistant/components/yale_smart_alarm/translations/cs.json +++ b/homeassistant/components/yale_smart_alarm/translations/cs.json @@ -9,16 +9,16 @@ "step": { "reauth_confirm": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "ID oblasti", + "name": "Jm\u00e9no", "password": "Heslo", "username": "U\u017eivatelsk\u00e9 jm\u00e9no" } }, "user": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "ID oblasti", + "name": "Jm\u00e9no", "password": "Heslo", "username": "U\u017eivatelsk\u00e9 jm\u00e9no" } diff --git a/homeassistant/components/zone/translations/lt.json b/homeassistant/components/zone/translations/lt.json new file mode 100644 index 00000000000..d7127048a63 --- /dev/null +++ b/homeassistant/components/zone/translations/lt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "init": { + "data": { + "name": "Pavadinimas" + } + } + }, + "title": "Zona" + } +} \ No newline at end of file From c3a509bdd88a0f0bc3c0d58d11d293a71af26874 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 4 Aug 2021 18:50:09 -0600 Subject: [PATCH 157/903] Add support for jammed status to SimpliSafe locks (#54006) --- homeassistant/components/simplisafe/lock.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index e912eedb955..3068e6209ae 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -16,7 +16,6 @@ from . import SimpliSafe, SimpliSafeEntity from .const import DATA_CLIENT, DOMAIN, LOGGER ATTR_LOCK_LOW_BATTERY = "lock_low_battery" -ATTR_JAMMED = "jammed" ATTR_PIN_PAD_LOW_BATTERY = "pin_pad_low_battery" @@ -75,9 +74,9 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): self._attr_extra_state_attributes.update( { ATTR_LOCK_LOW_BATTERY: self._lock.lock_low_battery, - ATTR_JAMMED: self._lock.state == LockStates.jammed, ATTR_PIN_PAD_LOW_BATTERY: self._lock.pin_pad_low_battery, } ) + self._attr_is_jammed = self._lock.state == LockStates.jammed self._attr_is_locked = self._lock.state == LockStates.locked From 4d6c95a12684bc28f82c10d18e02bffcb24c0f21 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 5 Aug 2021 08:16:33 +0200 Subject: [PATCH 158/903] Don't double-validate KNX select options (#54020) --- homeassistant/components/knx/select.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 07f74c04e4f..90e0203a8be 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -97,7 +97,5 @@ class KNXSelect(KnxEntity, SelectEntity, RestoreEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - payload = self._option_payloads.get(option) - if payload is None: - raise ValueError(f"Invalid option for {self.entity_id}: {option}") + payload = self._option_payloads[option] await self._device.set(payload) From f2f084abe2f06b211c1b884d3376022bd94e0004 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 4 Aug 2021 23:17:15 -0700 Subject: [PATCH 159/903] Use SwitchEntityDescription instead of EntityDescription in the motionEye integration (#54019) --- homeassistant/components/motioneye/switch.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index f9197d00c08..abe4314447c 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -15,10 +15,9 @@ from motioneye_client.const import ( KEY_VIDEO_STREAMING, ) -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -26,26 +25,26 @@ from . import MotionEyeEntity, get_camera_from_cameras, listen_for_new_cameras from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE MOTIONEYE_SWITCHES = [ - EntityDescription( + SwitchEntityDescription( key=KEY_MOTION_DETECTION, name="Motion Detection", entity_registry_enabled_default=True, ), - EntityDescription( + SwitchEntityDescription( key=KEY_TEXT_OVERLAY, name="Text Overlay", entity_registry_enabled_default=False ), - EntityDescription( + SwitchEntityDescription( key=KEY_VIDEO_STREAMING, name="Video Streaming", entity_registry_enabled_default=False, ), - EntityDescription( + SwitchEntityDescription( key=KEY_STILL_IMAGES, name="Still Images", entity_registry_enabled_default=True ), - EntityDescription( + SwitchEntityDescription( key=KEY_MOVIES, name="Movies", entity_registry_enabled_default=True ), - EntityDescription( + SwitchEntityDescription( key=KEY_UPLOAD_ENABLED, name="Upload Enabled", entity_registry_enabled_default=False, @@ -89,7 +88,7 @@ class MotionEyeSwitch(MotionEyeEntity, SwitchEntity): client: MotionEyeClient, coordinator: DataUpdateCoordinator, options: MappingProxyType[str, str], - entity_description: EntityDescription, + entity_description: SwitchEntityDescription, ) -> None: """Initialize the switch.""" super().__init__( From 36c0478c4a22744a33bd289ec78a1f5a64687144 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Thu, 5 Aug 2021 09:16:47 +0200 Subject: [PATCH 160/903] Activate mypy for Reddit (#53949) --- homeassistant/components/reddit/sensor.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py index 1e755b950bf..7472ad42301 100644 --- a/homeassistant/components/reddit/sensor.py +++ b/homeassistant/components/reddit/sensor.py @@ -91,7 +91,7 @@ class RedditSensor(SensorEntity): self._limit = limit self._sort_by = sort_by - self._subreddit_data = [] + self._subreddit_data: list = [] @property def name(self): diff --git a/mypy.ini b/mypy.ini index 07e56f29409..b1cd49b82bb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1608,9 +1608,6 @@ ignore_errors = true [mypy-homeassistant.components.rachio.*] ignore_errors = true -[mypy-homeassistant.components.reddit.*] -ignore_errors = true - [mypy-homeassistant.components.ring.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index b795e6c03c8..9ec3a3b2fff 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -132,7 +132,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.profiler.*", "homeassistant.components.proxmoxve.*", "homeassistant.components.rachio.*", - "homeassistant.components.reddit.*", "homeassistant.components.ring.*", "homeassistant.components.rpi_power.*", "homeassistant.components.ruckus_unleashed.*", From be880fdaa93934d3a2c880c8246ba85f035e5515 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Thu, 5 Aug 2021 09:18:03 +0200 Subject: [PATCH 161/903] Activate mypy for Updater (#53950) --- homeassistant/components/updater/binary_sensor.py | 5 +++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/updater/binary_sensor.py b/homeassistant/components/updater/binary_sensor.py index 93d19029992..1c6bacede62 100644 --- a/homeassistant/components/updater/binary_sensor.py +++ b/homeassistant/components/updater/binary_sensor.py @@ -1,4 +1,5 @@ """Support for Home Assistant Updater binary sensors.""" +from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -28,14 +29,14 @@ class UpdaterBinary(CoordinatorEntity, BinarySensorEntity): return "updater" @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" if not self.coordinator.data: return None return self.coordinator.data.update_available @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict | None: """Return the optional state attributes.""" if not self.coordinator.data: return None diff --git a/mypy.ini b/mypy.ini index b1cd49b82bb..671a6cbc9a7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1722,9 +1722,6 @@ ignore_errors = true [mypy-homeassistant.components.unifi.*] ignore_errors = true -[mypy-homeassistant.components.updater.*] -ignore_errors = true - [mypy-homeassistant.components.upnp.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 9ec3a3b2fff..5f456829725 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -170,7 +170,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.tradfri.*", "homeassistant.components.tuya.*", "homeassistant.components.unifi.*", - "homeassistant.components.updater.*", "homeassistant.components.upnp.*", "homeassistant.components.velbus.*", "homeassistant.components.vera.*", From dd479d410aac0609c34bfd23baa9e77874709981 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 5 Aug 2021 02:29:23 -0600 Subject: [PATCH 162/903] Bump pylitterbot to 2021.8.0 (#54000) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index a22499fb062..facf79a7bd7 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -3,7 +3,7 @@ "name": "Litter-Robot", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litterrobot", - "requirements": ["pylitterbot==2021.7.2"], + "requirements": ["pylitterbot==2021.8.0"], "codeowners": ["@natekspencer"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 4b66d19b803..f88d8f5e7ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1562,7 +1562,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.7.2 +pylitterbot==2021.8.0 # homeassistant.components.loopenergy pyloopenergy==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 85e8790a1d4..a05331a0231 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -884,7 +884,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.7.2 +pylitterbot==2021.8.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.11.0 From 29e604bd222080dae50b4b5380ca90473a3e73b7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 5 Aug 2021 11:46:21 +0200 Subject: [PATCH 163/903] Add vscode task code coverage (#53783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- .vscode/tasks.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 24d643b96bc..5488c3472de 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -60,6 +60,21 @@ }, "problemMatcher": [] }, + { + "label": "Code Coverage", + "detail": "Generate code coverage report for a given integration.", + "type": "shell", + "command": "pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, { "label": "Generate Requirements", "type": "shell", @@ -102,5 +117,12 @@ }, "problemMatcher": [] } + ], + "inputs": [ + { + "id": "integrationName", + "type": "promptString", + "description": "For which integration should the task run?" + } ] } From 1bc3c743db72eba8bb9d60ee4b0cdde17a949211 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 5 Aug 2021 12:12:06 +0200 Subject: [PATCH 164/903] Add missing device class to SAJ energy sensors (#54048) --- homeassistant/components/saj/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index fb3b31764f8..1b46632051e 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_TYPE, CONF_USERNAME, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, @@ -204,6 +205,8 @@ class SAJsensor(SensorEntity): """Return the device class the sensor belongs to.""" if self.unit_of_measurement == POWER_WATT: return DEVICE_CLASS_POWER + if self.unit_of_measurement == ENERGY_KILO_WATT_HOUR: + return DEVICE_CLASS_ENERGY if ( self.unit_of_measurement == TEMP_CELSIUS or self._sensor.unit == TEMP_FAHRENHEIT From 4a37ff2ddacd85c5b60bb826da5f5f41fff1de0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 5 Aug 2021 12:13:47 +0200 Subject: [PATCH 165/903] Bump pyuptimerobot to 21.8.1 (#53995) * Bump pyuptimerobot to 21.08.0 * pylint * bump to 21.8.1 * Uppdate strings * Update homeassistant/components/uptimerobot/strings.json Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- .../components/uptimerobot/__init__.py | 41 ++++++++----------- .../components/uptimerobot/binary_sensor.py | 2 +- .../components/uptimerobot/config_flow.py | 35 ++++++++-------- homeassistant/components/uptimerobot/const.py | 36 ---------------- .../components/uptimerobot/entity.py | 11 +++-- .../components/uptimerobot/manifest.json | 2 +- .../components/uptimerobot/strings.json | 4 +- .../uptimerobot/translations/en.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../uptimerobot/test_config_flow.py | 38 ++++++++++------- 11 files changed, 73 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index b4d606ca637..17bc8f9a629 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -1,44 +1,35 @@ """The Uptime Robot integration.""" from __future__ import annotations -import async_timeout -from pyuptimerobot import UptimeRobot +from pyuptimerobot import UptimeRobot, UptimeRobotException, UptimeRobotMonitor from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY 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 ( - API_ATTR_MONITORS, - API_ATTR_OK, - API_ATTR_STAT, - CONNECTION_ERROR, - COORDINATOR_UPDATE_INTERVAL, - DOMAIN, - LOGGER, - PLATFORMS, - MonitorData, -) +from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Uptime Robot from a config entry.""" hass.data.setdefault(DOMAIN, {}) - uptime_robot_api = UptimeRobot() + uptime_robot_api = UptimeRobot( + entry.data[CONF_API_KEY], async_get_clientsession(hass) + ) - async def async_update_data() -> list[MonitorData]: + async def async_update_data() -> list[UptimeRobotMonitor]: """Fetch data from API UptimeRobot API.""" - async with async_timeout.timeout(10): - monitors = await hass.async_add_executor_job( - uptime_robot_api.getMonitors, entry.data[CONF_API_KEY] - ) - if not monitors or monitors.get(API_ATTR_STAT) != API_ATTR_OK: - raise UpdateFailed(CONNECTION_ERROR) - return [ - MonitorData.from_dict(monitor) - for monitor in monitors.get(API_ATTR_MONITORS, []) - ] + try: + response = await uptime_robot_api.async_get_monitors() + except UptimeRobotException as exception: + raise UpdateFailed(exception) from exception + else: + if response.status == API_ATTR_OK: + monitors: list[UptimeRobotMonitor] = response.data + return monitors + raise UpdateFailed(response.error.message) hass.data[DOMAIN][entry.entry_id] = coordinator = DataUpdateCoordinator( hass, diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 69daeaea7c8..f99689f2507 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -50,7 +50,7 @@ async def async_setup_entry( coordinator, BinarySensorEntityDescription( key=str(monitor.id), - name=monitor.name, + name=monitor.friendly_name, device_class=DEVICE_CLASS_CONNECTIVITY, ), target=monitor.url, diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index ad0d382061a..6bdd8a6e39c 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -1,7 +1,7 @@ """Config flow for Uptime Robot integration.""" from __future__ import annotations -from pyuptimerobot import UptimeRobot +from pyuptimerobot import UptimeRobot, UptimeRobotAccount, UptimeRobotException import voluptuous as vol from homeassistant import config_entries @@ -9,24 +9,26 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import API_ATTR_OK, API_ATTR_STAT, DOMAIN, LOGGER +from .const import API_ATTR_OK, DOMAIN, LOGGER STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) -async def validate_input(hass: HomeAssistant, data: ConfigType) -> None: +async def validate_input(hass: HomeAssistant, data: ConfigType) -> UptimeRobotAccount: """Validate the user input allows us to connect.""" + uptime_robot_api = UptimeRobot(data[CONF_API_KEY], async_get_clientsession(hass)) - uptime_robot_api = UptimeRobot() - - monitors = await hass.async_add_executor_job( - uptime_robot_api.getMonitors, data[CONF_API_KEY] - ) - - if not monitors or monitors.get(API_ATTR_STAT) != API_ATTR_OK: - raise CannotConnect("Error communicating with Uptime Robot API") + try: + response = await uptime_robot_api.async_get_account_details() + except UptimeRobotException as exception: + raise CannotConnect(exception) from exception + else: + if response.status == API_ATTR_OK: + return response.data + raise CannotConnect(response.error.message) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -43,14 +45,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - await validate_input(self.hass, user_input) + account = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(title=account.email, data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors @@ -65,9 +67,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="already_configured") - return self.async_create_entry( - title="", data={CONF_API_KEY: import_config[CONF_API_KEY]} - ) + imported_config = {CONF_API_KEY: import_config[CONF_API_KEY]} + + account = await validate_input(self.hass, imported_config) + return self.async_create_entry(title=account.email, data=imported_config) class CannotConnect(HomeAssistantError): diff --git a/homeassistant/components/uptimerobot/const.py b/homeassistant/components/uptimerobot/const.py index f0bc0699290..ee9832a040a 100644 --- a/homeassistant/components/uptimerobot/const.py +++ b/homeassistant/components/uptimerobot/const.py @@ -1,9 +1,7 @@ """Constants for the Uptime Robot integration.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta -from enum import Enum from logging import Logger, getLogger from typing import Final @@ -14,42 +12,8 @@ COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=60) DOMAIN: Final = "uptimerobot" PLATFORMS: Final = ["binary_sensor"] -CONNECTION_ERROR: Final = "Error connecting to the Uptime Robot API" - ATTRIBUTION: Final = "Data provided by Uptime Robot" ATTR_TARGET: Final = "target" -API_ATTR_STAT: Final = "stat" API_ATTR_OK: Final = "ok" -API_ATTR_MONITORS: Final = "monitors" - - -class MonitorType(Enum): - """Monitors type.""" - - HTTP = 1 - keyword = 2 - ping = 3 - - -@dataclass -class MonitorData: - """Dataclass for monitors.""" - - id: int - status: int - url: str - name: str - type: MonitorType - - @staticmethod - def from_dict(monitor: dict) -> MonitorData: - """Create a new monitor from a dict.""" - return MonitorData( - id=monitor["id"], - status=monitor["status"], - url=monitor["url"], - name=monitor["friendly_name"], - type=MonitorType(monitor["type"]), - ) diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index ed9d6b2a2f9..4b4847dfc7c 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -1,6 +1,8 @@ """Base UptimeRobot entity.""" from __future__ import annotations +from pyuptimerobot import UptimeRobotMonitor + from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import ( @@ -8,7 +10,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import ATTR_TARGET, ATTRIBUTION, DOMAIN, MonitorData +from .const import ATTR_TARGET, ATTRIBUTION, DOMAIN class UptimeRobotEntity(CoordinatorEntity): @@ -48,12 +50,12 @@ class UptimeRobotEntity(CoordinatorEntity): return {} @property - def monitors(self) -> list[MonitorData]: + def monitors(self) -> list[UptimeRobotMonitor]: """Return all monitors.""" return self.coordinator.data or [] @property - def monitor(self) -> MonitorData | None: + def monitor(self) -> UptimeRobotMonitor | None: """Return the monitor for this entity.""" return next( ( @@ -67,7 +69,8 @@ class UptimeRobotEntity(CoordinatorEntity): @property def monitor_available(self) -> bool: """Returtn if the monitor is available.""" - return self.monitor.status == 2 if self.monitor else False + status: bool = self.monitor.status == 2 if self.monitor else False + return status @property def available(self) -> bool: diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index c0f880facb1..9c91b32bdfd 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -3,7 +3,7 @@ "name": "Uptime Robot", "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "requirements": [ - "pyuptimerobot==0.0.5" + "pyuptimerobot==21.8.1" ], "codeowners": [ "@ludeeus" diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index 817d79e57cc..bae1e54c2b6 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -12,7 +12,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } } -} \ No newline at end of file +} diff --git a/homeassistant/components/uptimerobot/translations/en.json b/homeassistant/components/uptimerobot/translations/en.json index 72433ab86c3..7e35d8e9531 100644 --- a/homeassistant/components/uptimerobot/translations/en.json +++ b/homeassistant/components/uptimerobot/translations/en.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Account already configured" }, "error": { "cannot_connect": "Failed to connect", diff --git a/requirements_all.txt b/requirements_all.txt index f88d8f5e7ae..1bf4ed9b063 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1948,7 +1948,7 @@ pytradfri[async]==7.0.6 pytrafikverket==0.1.6.2 # homeassistant.components.uptimerobot -pyuptimerobot==0.0.5 +pyuptimerobot==21.8.1 # homeassistant.components.keyboard # pyuserinput==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a05331a0231..7d72f300d4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1077,7 +1077,7 @@ pytraccar==0.9.0 pytradfri[async]==7.0.6 # homeassistant.components.uptimerobot -pyuptimerobot==0.0.5 +pyuptimerobot==21.8.1 # homeassistant.components.vera pyvera==0.3.13 diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 2c918204838..0da20086cc4 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -1,13 +1,10 @@ """Test the Uptime Robot config flow.""" from unittest.mock import patch +from pyuptimerobot import UptimeRobotApiResponse + from homeassistant import config_entries, setup -from homeassistant.components.uptimerobot.const import ( - API_ATTR_MONITORS, - API_ATTR_OK, - API_ATTR_STAT, - DOMAIN, -) +from homeassistant.components.uptimerobot.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -26,8 +23,13 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "pyuptimerobot.UptimeRobot.getMonitors", - return_value={API_ATTR_STAT: API_ATTR_OK, API_ATTR_MONITORS: []}, + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test"}, + } + ), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, @@ -39,7 +41,7 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "" + assert result2["title"] == "test@test.test" assert result2["data"] == {"api_key": "1234"} assert len(mock_setup_entry.mock_calls) == 1 @@ -50,7 +52,10 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("pyuptimerobot.UptimeRobot.getMonitors", return_value=None): + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict({"stat": "fail", "error": {}}), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_key": "1234"}, @@ -63,8 +68,13 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: async def test_flow_import(hass): """Test an import flow.""" with patch( - "pyuptimerobot.UptimeRobot.getMonitors", - return_value={API_ATTR_STAT: API_ATTR_OK, API_ATTR_MONITORS: []}, + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test"}, + } + ), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, @@ -81,8 +91,8 @@ async def test_flow_import(hass): assert result["data"] == {"api_key": "1234"} with patch( - "pyuptimerobot.UptimeRobot.getMonitors", - return_value={API_ATTR_STAT: API_ATTR_OK, API_ATTR_MONITORS: []}, + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict({"stat": "ok", "monitors": []}), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, From 91ab86c17c7901b803f5509f5a5aad99147cc2f7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 5 Aug 2021 12:29:00 +0200 Subject: [PATCH 166/903] Add state class support to Netatmo (#54051) --- homeassistant/components/netatmo/sensor.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 14128aefa6a..0c55b459847 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -7,7 +7,11 @@ from typing import NamedTuple, cast import pyatmo -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, @@ -81,6 +85,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=True, unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="temp_trend", @@ -96,6 +101,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, entity_registry_enabled_default=True, device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="pressure", @@ -104,6 +110,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=True, unit_of_measurement=PRESSURE_MBAR, device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="pressure_trend", @@ -119,6 +126,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=True, unit_of_measurement=SOUND_PRESSURE_DB, icon="mdi:volume-high", + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="humidity", @@ -127,6 +135,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=True, unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="rain", @@ -159,6 +168,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=True, unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="windangle", @@ -174,6 +184,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, unit_of_measurement=DEGREE, icon="mdi:compass-outline", + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="windstrength", @@ -182,6 +193,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=True, unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="gustangle", @@ -197,6 +209,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, unit_of_measurement=DEGREE, icon="mdi:compass-outline", + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="guststrength", @@ -205,6 +218,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="reachable", @@ -227,6 +241,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="wifi_status", @@ -242,6 +257,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="health_idx", From 25eb27cb9f4121204a9f8ea44df79322d019d1cf Mon Sep 17 00:00:00 2001 From: Gleb Sinyavskiy Date: Thu, 5 Aug 2021 12:47:42 +0200 Subject: [PATCH 167/903] Add tractive integration (#51002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Scaffold * Implement config flow * Add dymmy device tracker and TractiveClient * Add simple DeviceTracker * Add device info * Listen to tractive event and update tracker entities accordingly * Refactoring * Fix logging level * Handle connection errors * Remove sleep * Fix logging * Remove unused strings * Replace username config with email * Update aiotractive * Use debug instead of info * Cover config_flow * Update .coveragerc * Add quality scale to manifest * pylint * Update aiotractive * Do not emit SERVER_AVAILABLE, properly handle availability * Use async_get_clientsession Co-authored-by: Daniel Hjelseth Høyer * Add @Danielhiversen as a codeowner * Remove the title from strings and translations * Update homeassistant/components/tractive/__init__.py Co-authored-by: Franck Nijhof * Force CI * Use _attr style properties instead of methods * Remove entry_type * Remove quality scale * Make pyupgrade happy Co-authored-by: Daniel Hjelseth Høyer Co-authored-by: Franck Nijhof --- .coveragerc | 2 + CODEOWNERS | 1 + homeassistant/components/tractive/__init__.py | 153 ++++++++++++++++++ .../components/tractive/config_flow.py | 74 +++++++++ homeassistant/components/tractive/const.py | 12 ++ .../components/tractive/device_tracker.py | 145 +++++++++++++++++ .../components/tractive/manifest.json | 14 ++ .../components/tractive/strings.json | 19 +++ .../components/tractive/translations/en.json | 19 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tractive/__init__.py | 1 + tests/components/tractive/test_config_flow.py | 78 +++++++++ 14 files changed, 525 insertions(+) create mode 100644 homeassistant/components/tractive/__init__.py create mode 100644 homeassistant/components/tractive/config_flow.py create mode 100644 homeassistant/components/tractive/const.py create mode 100644 homeassistant/components/tractive/device_tracker.py create mode 100644 homeassistant/components/tractive/manifest.json create mode 100644 homeassistant/components/tractive/strings.json create mode 100644 homeassistant/components/tractive/translations/en.json create mode 100644 tests/components/tractive/__init__.py create mode 100644 tests/components/tractive/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 39055879a8d..263cfd76172 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1079,6 +1079,8 @@ omit = homeassistant/components/traccar/device_tracker.py homeassistant/components/traccar/const.py homeassistant/components/trackr/device_tracker.py + homeassistant/components/tractive/__init__.py + homeassistant/components/tractive/device_tracker.py homeassistant/components/tradfri/* homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 371a03a0d91..86622690fb9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -526,6 +526,7 @@ homeassistant/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti @thegardenmonkey homeassistant/components/traccar/* @ludeeus homeassistant/components/trace/* @home-assistant/core +homeassistant/components/tractive/* @Danielhiversen @zhulik homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py new file mode 100644 index 00000000000..cb8eff1c8bb --- /dev/null +++ b/homeassistant/components/tractive/__init__.py @@ -0,0 +1,153 @@ +"""The tractive integration.""" +from __future__ import annotations + +import asyncio +import logging + +import aiotractive + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + DOMAIN, + RECONNECT_INTERVAL, + SERVER_UNAVAILABLE, + TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_POSITION_UPDATED, +) + +PLATFORMS = ["device_tracker"] + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up tractive from a config entry.""" + data = entry.data + + hass.data.setdefault(DOMAIN, {}) + + client = aiotractive.Tractive( + data[CONF_EMAIL], data[CONF_PASSWORD], session=async_get_clientsession(hass) + ) + try: + creds = await client.authenticate() + except aiotractive.exceptions.TractiveError as error: + await client.close() + raise ConfigEntryNotReady from error + + tractive = TractiveClient(hass, client, creds["user_id"]) + tractive.subscribe() + + hass.data[DOMAIN][entry.entry_id] = tractive + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + async def cancel_listen_task(_): + await tractive.unsubscribe() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cancel_listen_task) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + tractive = hass.data[DOMAIN].pop(entry.entry_id) + await tractive.unsubscribe() + return unload_ok + + +class TractiveClient: + """A Tractive client.""" + + def __init__(self, hass, client, user_id): + """Initialize the client.""" + self._hass = hass + self._client = client + self._user_id = user_id + self._listen_task = None + + @property + def user_id(self): + """Return user id.""" + return self._user_id + + async def trackable_objects(self): + """Get list of trackable objects.""" + return await self._client.trackable_objects() + + def tracker(self, tracker_id): + """Get tracker by id.""" + return self._client.tracker(tracker_id) + + def subscribe(self): + """Start event listener coroutine.""" + self._listen_task = asyncio.create_task(self._listen()) + + async def unsubscribe(self): + """Stop event listener coroutine.""" + if self._listen_task: + self._listen_task.cancel() + await self._client.close() + + async def _listen(self): + server_was_unavailable = False + while True: + try: + async for event in self._client.events(): + if server_was_unavailable: + _LOGGER.debug("Tractive is back online") + server_was_unavailable = False + if event["message"] != "tracker_status": + continue + + if "hardware" in event: + self._send_hardware_update(event) + + if "position" in event: + self._send_position_update(event) + except aiotractive.exceptions.TractiveError: + _LOGGER.debug( + "Tractive is not available. Internet connection is down? Sleeping %i seconds and retrying", + RECONNECT_INTERVAL.total_seconds(), + ) + async_dispatcher_send( + self._hass, f"{SERVER_UNAVAILABLE}-{self._user_id}" + ) + await asyncio.sleep(RECONNECT_INTERVAL.total_seconds()) + server_was_unavailable = True + continue + + def _send_hardware_update(self, event): + payload = {"battery_level": event["hardware"]["battery_level"]} + self._dispatch_tracker_event( + TRACKER_HARDWARE_STATUS_UPDATED, event["tracker_id"], payload + ) + + def _send_position_update(self, event): + payload = { + "latitude": event["position"]["latlong"][0], + "longitude": event["position"]["latlong"][1], + "accuracy": event["position"]["accuracy"], + } + self._dispatch_tracker_event( + TRACKER_POSITION_UPDATED, event["tracker_id"], payload + ) + + def _dispatch_tracker_event(self, event_name, tracker_id, payload): + async_dispatcher_send( + self._hass, + f"{event_name}-{tracker_id}", + payload, + ) diff --git a/homeassistant/components/tractive/config_flow.py b/homeassistant/components/tractive/config_flow.py new file mode 100644 index 00000000000..70ed9071c7b --- /dev/null +++ b/homeassistant/components/tractive/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for tractive integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiotractive +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({CONF_EMAIL: str, CONF_PASSWORD: str}) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + client = aiotractive.api.API(data[CONF_EMAIL], data[CONF_PASSWORD]) + try: + user_id = await client.user_id() + except aiotractive.exceptions.UnauthorizedError as error: + raise InvalidAuth from error + finally: + await client.close() + + return {"title": data[CONF_EMAIL], "user_id": user_id} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for tractive.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["user_id"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py new file mode 100644 index 00000000000..5d265c489ff --- /dev/null +++ b/homeassistant/components/tractive/const.py @@ -0,0 +1,12 @@ +"""Constants for the tractive integration.""" + +from datetime import timedelta + +DOMAIN = "tractive" + +RECONNECT_INTERVAL = timedelta(seconds=10) + +TRACKER_HARDWARE_STATUS_UPDATED = "tracker_hardware_status_updated" +TRACKER_POSITION_UPDATED = "tracker_position_updated" + +SERVER_UNAVAILABLE = "tractive_server_unavailable" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py new file mode 100644 index 00000000000..1365faa6419 --- /dev/null +++ b/homeassistant/components/tractive/device_tracker.py @@ -0,0 +1,145 @@ +"""Support for Tractive device trackers.""" + +import asyncio +import logging + +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + DOMAIN, + SERVER_UNAVAILABLE, + TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_POSITION_UPDATED, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Tractive device trackers.""" + client = hass.data[DOMAIN][entry.entry_id] + + trackables = await client.trackable_objects() + + entities = await asyncio.gather( + *(create_trackable_entity(client, trackable) for trackable in trackables) + ) + + async_add_entities(entities) + + +async def create_trackable_entity(client, trackable): + """Create an entity instance.""" + trackable = await trackable.details() + tracker = client.tracker(trackable["device_id"]) + + tracker_details, hw_info, pos_report = await asyncio.gather( + tracker.details(), tracker.hw_info(), tracker.pos_report() + ) + + return TractiveDeviceTracker( + client.user_id, trackable, tracker_details, hw_info, pos_report + ) + + +class TractiveDeviceTracker(TrackerEntity): + """Tractive device tracker.""" + + def __init__(self, user_id, trackable, tracker_details, hw_info, pos_report): + """Initialize tracker entity.""" + self._user_id = user_id + + self._battery_level = hw_info["battery_level"] + self._latitude = pos_report["latlong"][0] + self._longitude = pos_report["latlong"][1] + self._accuracy = pos_report["pos_uncertainty"] + self._tracker_id = tracker_details["_id"] + + self._attr_name = f"{self._tracker_id} {trackable['details']['name']}" + self._attr_unique_id = trackable["_id"] + self._attr_icon = "mdi:paw" + self._attr_device_info = { + "identifiers": {(DOMAIN, self._tracker_id)}, + "name": f"Tractive ({self._tracker_id})", + "manufacturer": "Tractive GmbH", + "sw_version": tracker_details["fw_version"], + "model": tracker_details["model_number"], + } + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._latitude + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._longitude + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._accuracy + + @property + def battery_level(self): + """Return the battery level of the device.""" + return self._battery_level + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + + @callback + def handle_hardware_status_update(event): + self._battery_level = event["battery_level"] + self._attr_available = True + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", + handle_hardware_status_update, + ) + ) + + @callback + def handle_position_update(event): + self._latitude = event["latitude"] + self._longitude = event["longitude"] + self._accuracy = event["accuracy"] + self._attr_available = True + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_POSITION_UPDATED}-{self._tracker_id}", + handle_position_update, + ) + ) + + @callback + def handle_server_unavailable(): + self._latitude = None + self._longitude = None + self._accuracy = None + self._battery_level = None + self._attr_available = False + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + handle_server_unavailable, + ) + ) diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json new file mode 100644 index 00000000000..2328c07f905 --- /dev/null +++ b/homeassistant/components/tractive/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "tractive", + "name": "Tractive", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tractive", + "requirements": [ + "aiotractive==0.5.1" + ], + "codeowners": [ + "@Danielhiversen", + "@zhulik" + ], + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json new file mode 100644 index 00000000000..510b5697e56 --- /dev/null +++ b/homeassistant/components/tractive/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "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_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/en.json b/homeassistant/components/tractive/translations/en.json new file mode 100644 index 00000000000..4abfd682903 --- /dev/null +++ b/homeassistant/components/tractive/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3d6730fe65a..d125f507d3a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -272,6 +272,7 @@ FLOWS = [ "totalconnect", "tplink", "traccar", + "tractive", "tradfri", "transmission", "tuya", diff --git a/requirements_all.txt b/requirements_all.txt index 1bf4ed9b063..ec370ad1dcd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -245,6 +245,9 @@ aioswitcher==2.0.4 # homeassistant.components.syncthing aiosyncthing==0.5.1 +# homeassistant.components.tractive +aiotractive==0.5.1 + # homeassistant.components.unifi aiounifi==26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d72f300d4e..0eca53e6cdc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -166,6 +166,9 @@ aioswitcher==2.0.4 # homeassistant.components.syncthing aiosyncthing==0.5.1 +# homeassistant.components.tractive +aiotractive==0.5.1 + # homeassistant.components.unifi aiounifi==26 diff --git a/tests/components/tractive/__init__.py b/tests/components/tractive/__init__.py new file mode 100644 index 00000000000..dcde4b87436 --- /dev/null +++ b/tests/components/tractive/__init__.py @@ -0,0 +1 @@ +"""Tests for the tractive integration.""" diff --git a/tests/components/tractive/test_config_flow.py b/tests/components/tractive/test_config_flow.py new file mode 100644 index 00000000000..080aadb2bc7 --- /dev/null +++ b/tests/components/tractive/test_config_flow.py @@ -0,0 +1,78 @@ +"""Test the tractive config flow.""" +from unittest.mock import patch + +import aiotractive + +from homeassistant import config_entries, setup +from homeassistant.components.tractive.const import DOMAIN +from homeassistant.core import HomeAssistant + +USER_INPUT = { + "email": "test-email@example.com", + "password": "test-password", +} + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "aiotractive.api.API.user_id", return_value={"user_id": "user_id"} + ), patch( + "homeassistant.components.tractive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-email@example.com" + assert result2["data"] == USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +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": config_entries.SOURCE_USER} + ) + + with patch( + "aiotractive.api.API.user_id", + side_effect=aiotractive.exceptions.UnauthorizedError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiotractive.api.API.user_id", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} From debcc6689f22bf564ab08ee261aed6231ec88d3b Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Thu, 5 Aug 2021 13:01:12 +0200 Subject: [PATCH 168/903] Activate mypy for Cloudflare (#54041) --- homeassistant/components/cloudflare/config_flow.py | 2 -- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - tests/components/cloudflare/test_config_flow.py | 2 +- 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index 2a369fe65e0..27a22dbc5bd 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -173,7 +173,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_records(self, user_input: dict | None = None): """Handle the picking the zone records.""" - errors = {} if user_input is not None: self.cloudflare_config.update(user_input) @@ -183,7 +182,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="records", data_schema=_records_schema(self.records), - errors=errors, ) async def _async_validate_or_error(self, config): diff --git a/mypy.ini b/mypy.ini index 671a6cbc9a7..7f0a932f0af 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1299,9 +1299,6 @@ ignore_errors = true [mypy-homeassistant.components.cloud.*] ignore_errors = true -[mypy-homeassistant.components.cloudflare.*] -ignore_errors = true - [mypy-homeassistant.components.config.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 5f456829725..23967721053 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -29,7 +29,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.cert_expiry.*", "homeassistant.components.climacell.*", "homeassistant.components.cloud.*", - "homeassistant.components.cloudflare.*", "homeassistant.components.config.*", "homeassistant.components.conversation.*", "homeassistant.components.deconz.*", diff --git a/tests/components/cloudflare/test_config_flow.py b/tests/components/cloudflare/test_config_flow.py index 230f4c3647f..16177850ad5 100644 --- a/tests/components/cloudflare/test_config_flow.py +++ b/tests/components/cloudflare/test_config_flow.py @@ -55,7 +55,7 @@ async def test_user_form(hass, cfupdate_flow): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "records" - assert result["errors"] == {} + assert result["errors"] is None with _patch_async_setup_entry() as mock_setup_entry: result = await hass.config_entries.flow.async_configure( From 786a83f84443e7d8747f74e4d1e813c34d9236e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 5 Aug 2021 14:58:29 +0200 Subject: [PATCH 169/903] Add unique_id to Uptime Robot config_flow (#54055) --- .coveragerc | 1 + .../components/uptimerobot/config_flow.py | 84 +++++++---- .../components/uptimerobot/manifest.json | 2 +- .../components/uptimerobot/strings.json | 4 +- .../uptimerobot/translations/en.json | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../uptimerobot/test_config_flow.py | 139 +++++++++++++++++- 8 files changed, 196 insertions(+), 42 deletions(-) diff --git a/.coveragerc b/.coveragerc index 263cfd76172..1a9221b3abb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1114,6 +1114,7 @@ omit = homeassistant/components/upcloud/switch.py homeassistant/components/upnp/* homeassistant/components/upc_connect/* + homeassistant/components/uptimerobot/__init__.py homeassistant/components/uptimerobot/binary_sensor.py homeassistant/components/uptimerobot/const.py homeassistant/components/uptimerobot/entity.py diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index 6bdd8a6e39c..7bab74fa03e 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -1,14 +1,19 @@ """Config flow for Uptime Robot integration.""" from __future__ import annotations -from pyuptimerobot import UptimeRobot, UptimeRobotAccount, UptimeRobotException +from pyuptimerobot import ( + UptimeRobot, + UptimeRobotAccount, + UptimeRobotApiError, + UptimeRobotApiResponse, + UptimeRobotAuthenticationException, + UptimeRobotException, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY -from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -17,41 +22,58 @@ from .const import API_ATTR_OK, DOMAIN, LOGGER STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) -async def validate_input(hass: HomeAssistant, data: ConfigType) -> UptimeRobotAccount: - """Validate the user input allows us to connect.""" - uptime_robot_api = UptimeRobot(data[CONF_API_KEY], async_get_clientsession(hass)) - - try: - response = await uptime_robot_api.async_get_account_details() - except UptimeRobotException as exception: - raise CannotConnect(exception) from exception - else: - if response.status == API_ATTR_OK: - return response.data - raise CannotConnect(response.error.message) - - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Uptime Robot.""" VERSION = 1 + async def _validate_input( + self, data: ConfigType + ) -> tuple[dict[str, str], UptimeRobotAccount | None]: + """Validate the user input allows us to connect.""" + errors: dict[str, str] = {} + response: UptimeRobotApiResponse | UptimeRobotApiError | None = None + uptime_robot_api = UptimeRobot( + data[CONF_API_KEY], async_get_clientsession(self.hass) + ) + + try: + response = await uptime_robot_api.async_get_account_details() + except UptimeRobotAuthenticationException as exception: + LOGGER.error(exception) + errors["base"] = "invalid_api_key" + except UptimeRobotException as exception: + LOGGER.error(exception) + errors["base"] = "cannot_connect" + except Exception as exception: # pylint: disable=broad-except + LOGGER.exception(exception) + errors["base"] = "unknown" + else: + if response.status != API_ATTR_OK: + errors["base"] = "unknown" + LOGGER.error(response.error.message) + + account: UptimeRobotAccount | None = ( + response.data + if response and response.data and response.data.email + else None + ) + if account: + await self.async_set_unique_id(str(account.user_id)) + self._abort_if_unique_id_configured() + + return errors, account + async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - try: - account = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + errors, account = await self._validate_input(user_input) + if account: return self.async_create_entry(title=account.email, data=user_input) return self.async_show_form( @@ -69,9 +91,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): imported_config = {CONF_API_KEY: import_config[CONF_API_KEY]} - account = await validate_input(self.hass, imported_config) - return self.async_create_entry(title=account.email, data=imported_config) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" + _, account = await self._validate_input(imported_config) + if account: + return self.async_create_entry(title=account.email, data=imported_config) + return self.async_abort(reason="unknown") diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 9c91b32bdfd..22d9a6d9477 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -3,7 +3,7 @@ "name": "Uptime Robot", "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "requirements": [ - "pyuptimerobot==21.8.1" + "pyuptimerobot==21.8.2" ], "codeowners": [ "@ludeeus" diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index bae1e54c2b6..f51061eec33 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -9,10 +9,12 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/homeassistant/components/uptimerobot/translations/en.json b/homeassistant/components/uptimerobot/translations/en.json index 7e35d8e9531..99ab9426006 100644 --- a/homeassistant/components/uptimerobot/translations/en.json +++ b/homeassistant/components/uptimerobot/translations/en.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "Account already configured" + "already_configured": "Account already configured", + "unknown": "Unexpected error" }, "error": { "cannot_connect": "Failed to connect", + "invalid_api_key": "Invalid API key", "unknown": "Unexpected error" }, "step": { diff --git a/requirements_all.txt b/requirements_all.txt index ec370ad1dcd..6cd0e2cce17 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1951,7 +1951,7 @@ pytradfri[async]==7.0.6 pytrafikverket==0.1.6.2 # homeassistant.components.uptimerobot -pyuptimerobot==21.8.1 +pyuptimerobot==21.8.2 # homeassistant.components.keyboard # pyuserinput==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0eca53e6cdc..487d3d483d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1080,7 +1080,7 @@ pytraccar==0.9.0 pytradfri[async]==7.0.6 # homeassistant.components.uptimerobot -pyuptimerobot==21.8.1 +pyuptimerobot==21.8.2 # homeassistant.components.vera pyvera==0.3.13 diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 0da20086cc4..41f0b6b639e 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -1,7 +1,12 @@ """Test the Uptime Robot config flow.""" from unittest.mock import patch +from pytest import LogCaptureFixture from pyuptimerobot import UptimeRobotApiResponse +from pyuptimerobot.exceptions import ( + UptimeRobotAuthenticationException, + UptimeRobotException, +) from homeassistant import config_entries, setup from homeassistant.components.uptimerobot.const import DOMAIN @@ -12,6 +17,8 @@ from homeassistant.data_entry_flow import ( RESULT_TYPE_FORM, ) +from tests.common import MockConfigEntry + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -20,14 +27,14 @@ async def test_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {} + assert result["errors"] is None with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", return_value=UptimeRobotApiResponse.from_dict( { "stat": "ok", - "account": {"email": "test@test.test"}, + "account": {"email": "test@test.test", "user_id": 1234567890}, } ), ), patch( @@ -40,6 +47,7 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() + assert result2["result"].unique_id == "1234567890" assert result2["type"] == RESULT_TYPE_CREATE_ENTRY assert result2["title"] == "test@test.test" assert result2["data"] == {"api_key": "1234"} @@ -54,7 +62,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict({"stat": "fail", "error": {}}), + side_effect=UptimeRobotException, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -65,6 +73,66 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} +async def test_form_unexpected_error(hass: HomeAssistant) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_api_key_error(hass: HomeAssistant) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + side_effect=UptimeRobotAuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + + assert result2["errors"] == {"base": "invalid_api_key"} + + +async def test_form_api_error(hass: HomeAssistant, caplog: LogCaptureFixture) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "fail", + "error": {"message": "test error from API."}, + } + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + + assert result2["errors"] == {"base": "unknown"} + assert "test error from API." in caplog.text + + async def test_flow_import(hass): """Test an import flow.""" with patch( @@ -72,7 +140,7 @@ async def test_flow_import(hass): return_value=UptimeRobotApiResponse.from_dict( { "stat": "ok", - "account": {"email": "test@test.test"}, + "account": {"email": "test@test.test", "user_id": 1234567890}, } ), ), patch( @@ -92,7 +160,12 @@ async def test_flow_import(hass): with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict({"stat": "ok", "monitors": []}), + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test", "user_id": 1234567890}, + } + ), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, @@ -104,5 +177,61 @@ async def test_flow_import(hass): ) await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 0 assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict({"stat": "ok"}), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"platform": DOMAIN, "api_key": "12345"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" + + +async def test_user_unique_id_already_exists(hass): + """Test creating an entry where the unique_id already exists.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test", "user_id": 1234567890}, + } + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "12345"}, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" From b930e17d649a7cf4bec254f6cdfbd50bd48f59ef Mon Sep 17 00:00:00 2001 From: Ian Date: Thu, 5 Aug 2021 09:47:23 -0700 Subject: [PATCH 170/903] Bump py-nextbusnext to 0.1.5 (#53924) * NextBus: Rebrand and bump new version of py-nextbusnext * Revert rebrand --- homeassistant/components/nextbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 71001bfc52c..3343e24b277 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -3,6 +3,6 @@ "name": "NextBus", "documentation": "https://www.home-assistant.io/integrations/nextbus", "codeowners": ["@vividboarder"], - "requirements": ["py_nextbusnext==0.1.4"], + "requirements": ["py_nextbusnext==0.1.5"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 6cd0e2cce17..edda15b959d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1297,7 +1297,7 @@ pyW215==0.7.0 pyW800rf32==0.1 # homeassistant.components.nextbus -py_nextbusnext==0.1.4 +py_nextbusnext==0.1.5 # homeassistant.components.ads pyads==3.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 487d3d483d4..4e0594f0df8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -730,7 +730,7 @@ pyRFXtrx==0.27.0 pyTibber==0.19.0 # homeassistant.components.nextbus -py_nextbusnext==0.1.4 +py_nextbusnext==0.1.5 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 From 3655859be2c61798d47422875d967cbe11f5fc68 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Aug 2021 10:09:56 -0700 Subject: [PATCH 171/903] Add some metadata to pvoutput energy sensor (#54074) --- homeassistant/components/pvoutput/sensor.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index eb461061dcc..999ac14e949 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -6,7 +6,12 @@ import logging import voluptuous as vol from homeassistant.components.rest.data import RestData -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + DEVICE_CLASS_ENERGY, + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.const import ( ATTR_DATE, ATTR_TEMPERATURE, @@ -14,6 +19,7 @@ from homeassistant.const import ( ATTR_VOLTAGE, CONF_API_KEY, CONF_NAME, + ENERGY_WATT_HOUR, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -66,10 +72,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class PvoutputSensor(SensorEntity): """Representation of a PVOutput sensor.""" + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_unit_of_measurement = ENERGY_WATT_HOUR + def __init__(self, rest, name): """Initialize a PVOutput sensor.""" self.rest = rest - self._name = name + self._attr_name = name self.pvcoutput = None self.status = namedtuple( "status", @@ -86,11 +96,6 @@ class PvoutputSensor(SensorEntity): ], ) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def state(self): """Return the state of the device.""" @@ -125,6 +130,7 @@ class PvoutputSensor(SensorEntity): def _async_update_from_rest_data(self): """Update state from the rest data.""" try: + # https://pvoutput.org/help/api_specification.html#get-status-service self.pvcoutput = self.status._make(self.rest.data.split(",")) except TypeError: self.pvcoutput = None From 98877924d390848d4a8a5901670f7aa6b9e1d55f Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 5 Aug 2021 19:11:01 +0200 Subject: [PATCH 172/903] Add `state_class` for KNX sensors (#53996) --- homeassistant/components/knx/schema.py | 3 +++ homeassistant/components/knx/sensor.py | 1 + 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 11b2504d129..196c171c9b5 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -17,6 +17,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASSES as BINARY_SENSOR_DEVICE_CLASSES, ) from homeassistant.components.cover import DEVICE_CLASSES as COVER_DEVICE_CLASSES +from homeassistant.components.sensor import STATE_CLASSES_SCHEMA from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_ID, @@ -724,6 +725,7 @@ class SensorSchema(KNXPlatformSchema): CONF_ALWAYS_CALLBACK = "always_callback" CONF_STATE_ADDRESS = CONF_STATE_ADDRESS + CONF_STATE_CLASS = "state_class" CONF_SYNC_STATE = CONF_SYNC_STATE DEFAULT_NAME = "KNX Sensor" @@ -732,6 +734,7 @@ class SensorSchema(KNXPlatformSchema): vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, vol.Required(CONF_TYPE): sensor_type_validator, vol.Required(CONF_STATE_ADDRESS): ga_list_validator, } diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index e095b2aee47..5fee8446e91 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -63,6 +63,7 @@ class KNXSensor(KnxEntity, SensorEntity): self._attr_force_update = self._device.always_callback self._attr_unique_id = str(self._device.sensor_value.group_address_state) self._attr_unit_of_measurement = self._device.unit_of_measurement() + self._attr_state_class = config.get(SensorSchema.CONF_STATE_CLASS) @property def state(self) -> StateType: From 5dc87d959c01f7266f61ec121f4f73ea56c20ea9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 5 Aug 2021 13:46:58 -0400 Subject: [PATCH 173/903] Bump up ZHA dependencies (#54079) --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index fa117a3f1ff..5200c0a8b31 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -9,11 +9,11 @@ "pyserial-asyncio==0.5", "zha-quirks==0.0.59", "zigpy-cc==0.5.2", - "zigpy-deconz==0.12.0", + "zigpy-deconz==0.12.1", "zigpy==0.36.1", "zigpy-xbee==0.13.0", "zigpy-zigate==0.7.3", - "zigpy-znp==0.5.2" + "zigpy-znp==0.5.3" ], "codeowners": ["@dmulcahey", "@adminiuga"], "zeroconf": [ diff --git a/requirements_all.txt b/requirements_all.txt index edda15b959d..187351dace9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2457,7 +2457,7 @@ ziggo-mediabox-xl==1.1.0 zigpy-cc==0.5.2 # homeassistant.components.zha -zigpy-deconz==0.12.0 +zigpy-deconz==0.12.1 # homeassistant.components.zha zigpy-xbee==0.13.0 @@ -2466,7 +2466,7 @@ zigpy-xbee==0.13.0 zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.2 +zigpy-znp==0.5.3 # homeassistant.components.zha zigpy==0.36.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e0594f0df8..e673479b616 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1356,7 +1356,7 @@ zha-quirks==0.0.59 zigpy-cc==0.5.2 # homeassistant.components.zha -zigpy-deconz==0.12.0 +zigpy-deconz==0.12.1 # homeassistant.components.zha zigpy-xbee==0.13.0 @@ -1365,7 +1365,7 @@ zigpy-xbee==0.13.0 zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.2 +zigpy-znp==0.5.3 # homeassistant.components.zha zigpy==0.36.1 From 26cb588ee2d54088d5750d9492057d8a7fe30592 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Aug 2021 12:47:14 -0500 Subject: [PATCH 174/903] Handle empty software version when setting up HomeKit (#54068) Fixes #54059 Fixes #54024 --- .../components/homekit/accessories.py | 8 +++--- tests/components/homekit/test_accessories.py | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index e86135fb9a1..c1f5078e2d6 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -228,17 +228,17 @@ class HomeAccessory(Accessory): self.config = config or {} domain = split_entity_id(entity_id)[0].replace("_", " ") - if ATTR_MANUFACTURER in self.config: + if self.config.get(ATTR_MANUFACTURER) is not None: manufacturer = self.config[ATTR_MANUFACTURER] - elif ATTR_INTEGRATION in self.config: + elif self.config.get(ATTR_INTEGRATION) is not None: manufacturer = self.config[ATTR_INTEGRATION].replace("_", " ").title() else: manufacturer = f"{MANUFACTURER} {domain}".title() - if ATTR_MODEL in self.config: + if self.config.get(ATTR_MODEL) is not None: model = self.config[ATTR_MODEL] else: model = domain.title() - if ATTR_SW_VERSION in self.config: + if self.config.get(ATTR_SW_VERSION) is not None: sw_version = format_sw_version(self.config[ATTR_SW_VERSION]) else: sw_version = __version__ diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 9b70bf4830e..5904d1c11c6 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -41,6 +41,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, __version__, + __version__ as hass_version, ) from homeassistant.helpers.event import TRACK_STATE_CHANGE_CALLBACKS @@ -130,6 +131,7 @@ async def test_home_accessory(hass, hk_driver): serv.get_characteristic(CHAR_SERIAL_NUMBER).value == "light.accessory_that_exceeds_the_maximum_maximum_maximum_maximum" ) + assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == "0.4.3" hass.states.async_set(entity_id, "on") await hass.async_block_till_done() @@ -157,6 +159,31 @@ async def test_home_accessory(hass, hk_driver): assert serv.get_characteristic(CHAR_MODEL).value == "Test Model" +async def test_accessory_with_missing_basic_service_info(hass, hk_driver): + """Test HomeAccessory class.""" + entity_id = "sensor.accessory" + hass.states.async_set(entity_id, "on") + acc = HomeAccessory( + hass, + hk_driver, + "Home Accessory", + entity_id, + 3, + { + ATTR_MODEL: None, + ATTR_MANUFACTURER: None, + ATTR_SW_VERSION: None, + ATTR_INTEGRATION: None, + }, + ) + serv = acc.get_service(SERV_ACCESSORY_INFO) + assert serv.get_characteristic(CHAR_NAME).value == "Home Accessory" + assert serv.get_characteristic(CHAR_MANUFACTURER).value == "Home Assistant Sensor" + assert serv.get_characteristic(CHAR_MODEL).value == "Sensor" + assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == entity_id + assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == hass_version + + async def test_battery_service(hass, hk_driver, caplog): """Test battery service.""" entity_id = "homekit.accessory" From fcc3d24904fa8ea7a8ee5c105a3b7f0158026103 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Aug 2021 13:10:42 -0700 Subject: [PATCH 175/903] We shouldn't add measurement without last_reset to metered entities (#54087) --- homeassistant/components/pvoutput/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index 999ac14e949..5744dbfff9a 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -9,7 +9,6 @@ from homeassistant.components.rest.data import RestData from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, PLATFORM_SCHEMA, - STATE_CLASS_MEASUREMENT, SensorEntity, ) from homeassistant.const import ( @@ -72,7 +71,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class PvoutputSensor(SensorEntity): """Representation of a PVOutput sensor.""" - _attr_state_class = STATE_CLASS_MEASUREMENT _attr_device_class = DEVICE_CLASS_ENERGY _attr_unit_of_measurement = ENERGY_WATT_HOUR From 8377b557da8712789e0e5abb2c5aeda1f38895b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Aug 2021 13:11:01 -0700 Subject: [PATCH 176/903] Packages to support config platforms (#54085) --- homeassistant/components/automation/__init__.py | 3 --- homeassistant/components/automation/config.py | 2 ++ homeassistant/components/script/config.py | 2 ++ homeassistant/components/template/config.py | 2 ++ homeassistant/config.py | 17 ++++++++++++++++- tests/test_config.py | 13 ++++++++++++- 6 files changed, 34 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index a0ff4930b51..5e1b53c535e 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -71,9 +71,6 @@ from homeassistant.loader import bind_hass from homeassistant.util.dt import parse_datetime from .config import AutomationConfig, async_validate_config_item - -# Not used except by packages to check config structure -from .config import PLATFORM_SCHEMA # noqa: F401 from .const import ( CONF_ACTION, CONF_INITIAL_STATE, diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index 83076778b91..e852b6cc4c0 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -37,6 +37,8 @@ from .helpers import async_get_blueprints # mypy: allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs, no-warn-return-any +PACKAGE_MERGE_HINT = "list" + _CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA]) PLATFORM_SCHEMA = vol.All( diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index 6993b7181e1..44b739e84c7 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -39,6 +39,8 @@ from .const import ( ) from .helpers import async_get_blueprints +PACKAGE_MERGE_HINT = "dict" + SCRIPT_ENTITY_SCHEMA = make_script_schema( { vol.Optional(CONF_ALIAS): cv.string, diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 007f40a6d0a..165420bf404 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -13,6 +13,8 @@ from homeassistant.helpers.trigger import async_validate_trigger_config from . import binary_sensor as binary_sensor_platform, sensor as sensor_platform from .const import CONF_TRIGGER, DOMAIN +PACKAGE_MERGE_HINT = "list" + CONFIG_SECTION_SCHEMA = vol.Schema( { vol.Optional(CONF_UNIQUE_ID): cv.string, diff --git a/homeassistant/config.py b/homeassistant/config.py index e7b6e04e8cf..12a39ab291b 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -723,7 +723,22 @@ async def merge_packages_config( _log_pkg_error(pack_name, comp_name, config, str(ex)) continue - merge_list = hasattr(component, "PLATFORM_SCHEMA") + try: + config_platform: ModuleType | None = integration.get_platform("config") + # Test if config platform has a config validator + if not hasattr(config_platform, "async_validate_config"): + config_platform = None + except ImportError: + config_platform = None + + merge_list = False + + # If integration has a custom config validator, it needs to provide a hint. + if config_platform is not None: + merge_list = config_platform.PACKAGE_MERGE_HINT == "list" # type: ignore[attr-defined] + + if not merge_list: + merge_list = hasattr(component, "PLATFORM_SCHEMA") if not merge_list and hasattr(component, "CONFIG_SCHEMA"): merge_list = _identify_config_schema(component) == "list" diff --git a/tests/test_config.py b/tests/test_config.py index c1eb1ab7540..96196c943aa 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -650,19 +650,30 @@ async def test_merge(merge_log_err, hass): "pack_list": {"light": {"platform": "test"}}, "pack_list2": {"light": [{"platform": "test"}]}, "pack_none": {"wake_on_lan": None}, + "pack_special": { + "automation": [{"some": "yay"}], + "script": {"a_script": "yay"}, + "template": [{"some": "yay"}], + }, } config = { config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, "input_boolean": {"ib2": None}, "light": {"platform": "test"}, + "automation": [], + "script": {}, + "template": [], } await config_util.merge_packages_config(hass, config, packages) assert merge_log_err.call_count == 0 - assert len(config) == 5 + assert len(config) == 8 assert len(config["input_boolean"]) == 2 assert len(config["input_select"]) == 1 assert len(config["light"]) == 3 + assert len(config["automation"]) == 1 + assert len(config["script"]) == 1 + assert len(config["template"]) == 1 assert isinstance(config["wake_on_lan"], OrderedDict) From 64c9f9e1cb60690a890ab5f5a88e53746439a6a9 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 6 Aug 2021 00:15:04 +0000 Subject: [PATCH 177/903] [ci skip] Translation update --- .../accuweather/translations/hu.json | 5 ++ .../components/acmeda/translations/hu.json | 8 ++ .../components/adax/translations/hu.json | 20 +++++ .../components/adguard/translations/hu.json | 6 +- .../components/agent_dvr/translations/hu.json | 3 +- .../components/airvisual/translations/hu.json | 18 ++++- .../airvisual/translations/sensor.hu.json | 20 +++++ .../alarm_control_panel/translations/hu.json | 7 +- .../alarmdecoder/translations/hu.json | 2 + .../ambiclimate/translations/hu.json | 11 +++ .../components/arcam_fmj/translations/hu.json | 10 ++- .../components/atag/translations/hu.json | 6 +- .../components/august/translations/hu.json | 1 + .../components/awair/translations/hu.json | 3 +- .../components/axis/translations/hu.json | 10 ++- .../azure_devops/translations/hu.json | 15 +++- .../components/blebox/translations/hu.json | 5 +- .../components/blink/translations/hu.json | 14 +++- .../components/braviatv/translations/hu.json | 2 + .../components/bsblan/translations/hu.json | 5 +- .../cert_expiry/translations/hu.json | 11 ++- .../components/co2signal/translations/hu.json | 34 ++++++++ .../components/control4/translations/hu.json | 10 +++ .../coolmaster/translations/hu.json | 11 ++- .../components/deconz/translations/hu.json | 11 ++- .../components/demo/translations/hu.json | 7 ++ .../components/denonavr/translations/hu.json | 28 ++++++- .../components/dexcom/translations/hu.json | 4 +- .../components/directv/translations/hu.json | 8 ++ .../components/doorbird/translations/hu.json | 17 +++- .../components/dunehd/translations/hu.json | 1 + .../components/eafm/translations/hu.json | 7 +- .../components/elgato/translations/hu.json | 7 +- .../components/elkm1/translations/hu.json | 11 ++- .../components/energy/translations/hu.json | 3 + .../components/enocean/translations/hu.json | 16 ++++ .../components/firmata/translations/hu.json | 4 + .../flick_electric/translations/hu.json | 2 + .../components/flipr/translations/hu.json | 30 +++++++ .../components/flume/translations/hu.json | 6 +- .../flunearyou/translations/hu.json | 3 +- .../forked_daapd/translations/hu.json | 13 ++- .../components/freebox/translations/hu.json | 4 + .../components/fritzbox/translations/hu.json | 4 +- .../geonetnz_quakes/translations/hu.json | 1 + .../components/gogogate2/translations/hu.json | 1 + .../growatt_server/translations/hu.json | 1 + .../components/guardian/translations/hu.json | 6 +- .../components/hangouts/translations/hu.json | 1 + .../components/harmony/translations/hu.json | 22 ++++- .../components/heos/translations/hu.json | 4 +- .../homeassistant/translations/he.json | 1 + .../homeassistant/translations/hu.json | 1 + .../components/homekit/translations/hu.json | 12 +++ .../homekit_controller/translations/hu.json | 2 + .../components/honeywell/translations/hu.json | 17 ++++ .../huawei_lte/translations/hu.json | 3 + .../components/hue/translations/hu.json | 22 ++++- .../translations/hu.json | 7 +- .../hvv_departures/translations/hu.json | 23 +++++- .../components/iaqualink/translations/hu.json | 4 +- .../components/icloud/translations/hu.json | 5 +- .../components/insteon/translations/fa.json | 7 ++ .../components/insteon/translations/hu.json | 27 ++++++- .../components/ipp/translations/hu.json | 14 +++- .../components/iqvia/translations/hu.json | 12 +++ .../islamic_prayer_times/translations/hu.json | 18 ++++- .../components/isy994/translations/hu.json | 13 ++- .../components/juicenet/translations/hu.json | 1 + .../components/konnected/translations/hu.json | 81 +++++++++++++++++-- .../components/kraken/translations/nl.json | 4 - .../components/life360/translations/hu.json | 4 +- .../components/litejet/translations/hu.json | 10 +++ .../logi_circle/translations/hu.json | 7 ++ .../lutron_caseta/translations/hu.json | 4 + .../components/melcloud/translations/hu.json | 7 +- .../minecraft_server/translations/hu.json | 5 +- .../components/monoprice/translations/hu.json | 26 +++++- .../components/mqtt/translations/hu.json | 20 ++++- .../components/myq/translations/hu.json | 3 +- .../components/netatmo/translations/hu.json | 16 +++- .../nfandroidtv/translations/hu.json | 21 +++++ .../components/nuheat/translations/hu.json | 4 +- .../components/nut/translations/hu.json | 25 +++++- .../components/nws/translations/hu.json | 7 +- .../components/onvif/translations/hu.json | 14 +++- .../opentherm_gw/translations/hu.json | 3 +- .../ovo_energy/translations/hu.json | 1 + .../components/ozw/translations/hu.json | 1 + .../panasonic_viera/translations/hu.json | 3 +- .../components/plex/translations/hu.json | 13 ++- .../components/powerwall/translations/hu.json | 6 +- .../components/prosegur/translations/hu.json | 29 +++++++ .../pvpc_hourly_pricing/translations/hu.json | 7 +- .../components/rachio/translations/hu.json | 11 +++ .../components/renault/translations/he.json | 3 +- .../components/renault/translations/hu.json | 27 +++++++ .../components/roku/translations/hu.json | 7 +- .../components/roomba/translations/hu.json | 2 + .../components/roon/translations/hu.json | 4 +- .../components/sense/translations/hu.json | 3 +- .../components/sentry/translations/hu.json | 3 +- .../components/shelly/translations/hu.json | 3 + .../simplisafe/translations/hu.json | 15 ++++ .../components/smappee/translations/hu.json | 12 ++- .../components/smarthab/translations/hu.json | 4 +- .../smartthings/translations/hu.json | 14 +++- .../components/solaredge/translations/hu.json | 3 +- .../components/solarlog/translations/hu.json | 6 +- .../components/soma/translations/hu.json | 1 + .../somfy_mylink/translations/hu.json | 3 + .../speedtestdotnet/translations/hu.json | 2 + .../squeezebox/translations/hu.json | 3 +- .../srp_energy/translations/hu.json | 1 + .../switcher_kis/translations/hu.json | 13 +++ .../components/syncthru/translations/hu.json | 3 +- .../synology_dsm/translations/hu.json | 18 ++++- .../components/tado/translations/hu.json | 15 +++- .../components/tesla/translations/hu.json | 2 + .../components/toon/translations/hu.json | 10 +++ .../totalconnect/translations/hu.json | 3 +- .../components/traccar/translations/hu.json | 6 ++ .../components/tractive/translations/ca.json | 19 +++++ .../components/tractive/translations/en.json | 2 +- .../components/tractive/translations/et.json | 19 +++++ .../components/tractive/translations/hu.json | 19 +++++ .../components/tractive/translations/it.json | 19 +++++ .../components/tractive/translations/pl.json | 19 +++++ .../components/tractive/translations/ru.json | 19 +++++ .../tractive/translations/zh-Hant.json | 19 +++++ .../transmission/translations/hu.json | 3 +- .../twentemilieu/translations/hu.json | 8 +- .../components/unifi/translations/hu.json | 37 ++++++++- .../components/upb/translations/hu.json | 7 +- .../components/upnp/translations/hu.json | 9 +++ .../uptimerobot/translations/ca.json | 20 +++++ .../uptimerobot/translations/de.json | 18 +++++ .../uptimerobot/translations/en.json | 2 +- .../uptimerobot/translations/et.json | 20 +++++ .../uptimerobot/translations/he.json | 18 +++++ .../uptimerobot/translations/hu.json | 20 +++++ .../uptimerobot/translations/it.json | 18 +++++ .../uptimerobot/translations/pl.json | 20 +++++ .../uptimerobot/translations/ru.json | 20 +++++ .../uptimerobot/translations/zh-Hant.json | 20 +++++ .../components/velbus/translations/hu.json | 9 +++ .../components/vera/translations/hu.json | 30 +++++++ .../components/vilfo/translations/hu.json | 1 + .../components/vizio/translations/hu.json | 19 ++++- .../components/wemo/translations/hu.json | 5 ++ .../components/wiffi/translations/hu.json | 4 +- .../components/withings/translations/hu.json | 1 + .../components/wolflink/translations/hu.json | 6 +- .../wolflink/translations/sensor.hu.json | 76 ++++++++++++++++- .../xiaomi_miio/translations/select.ca.json | 9 +++ .../xiaomi_miio/translations/select.he.json | 2 + .../xiaomi_miio/translations/select.hu.json | 9 +++ .../xiaomi_miio/translations/select.it.json | 9 +++ .../yale_smart_alarm/translations/hu.json | 28 +++++++ .../components/youless/translations/hu.json | 15 ++++ .../components/zha/translations/hu.json | 68 +++++++++++++++- 161 files changed, 1703 insertions(+), 129 deletions(-) create mode 100644 homeassistant/components/adax/translations/hu.json create mode 100644 homeassistant/components/airvisual/translations/sensor.hu.json create mode 100644 homeassistant/components/co2signal/translations/hu.json create mode 100644 homeassistant/components/energy/translations/hu.json create mode 100644 homeassistant/components/flipr/translations/hu.json create mode 100644 homeassistant/components/honeywell/translations/hu.json create mode 100644 homeassistant/components/insteon/translations/fa.json create mode 100644 homeassistant/components/nfandroidtv/translations/hu.json create mode 100644 homeassistant/components/prosegur/translations/hu.json create mode 100644 homeassistant/components/renault/translations/hu.json create mode 100644 homeassistant/components/switcher_kis/translations/hu.json create mode 100644 homeassistant/components/tractive/translations/ca.json create mode 100644 homeassistant/components/tractive/translations/et.json create mode 100644 homeassistant/components/tractive/translations/hu.json create mode 100644 homeassistant/components/tractive/translations/it.json create mode 100644 homeassistant/components/tractive/translations/pl.json create mode 100644 homeassistant/components/tractive/translations/ru.json create mode 100644 homeassistant/components/tractive/translations/zh-Hant.json create mode 100644 homeassistant/components/uptimerobot/translations/ca.json create mode 100644 homeassistant/components/uptimerobot/translations/de.json create mode 100644 homeassistant/components/uptimerobot/translations/et.json create mode 100644 homeassistant/components/uptimerobot/translations/he.json create mode 100644 homeassistant/components/uptimerobot/translations/hu.json create mode 100644 homeassistant/components/uptimerobot/translations/it.json create mode 100644 homeassistant/components/uptimerobot/translations/pl.json create mode 100644 homeassistant/components/uptimerobot/translations/ru.json create mode 100644 homeassistant/components/uptimerobot/translations/zh-Hant.json create mode 100644 homeassistant/components/vera/translations/hu.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.ca.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.hu.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.it.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/hu.json create mode 100644 homeassistant/components/youless/translations/hu.json diff --git a/homeassistant/components/accuweather/translations/hu.json b/homeassistant/components/accuweather/translations/hu.json index ce4721693f3..3cb78005d46 100644 --- a/homeassistant/components/accuweather/translations/hu.json +++ b/homeassistant/components/accuweather/translations/hu.json @@ -15,6 +15,7 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" }, + "description": "Ha seg\u00edts\u00e9gre van sz\u00fcks\u00e9ge a konfigur\u00e1l\u00e1shoz, n\u00e9zze meg itt: https://www.home-assistant.io/integrations/accuweather/ \n\nEgyes \u00e9rz\u00e9kel\u0151k alap\u00e9rtelmez\u00e9s szerint nincsenek enged\u00e9lyezve. Az integr\u00e1ci\u00f3s konfigur\u00e1ci\u00f3 ut\u00e1n enged\u00e9lyezheti \u0151ket az entit\u00e1s-nyilv\u00e1ntart\u00e1sban.\nAz id\u0151j\u00e1r\u00e1s-el\u0151rejelz\u00e9s alap\u00e9rtelmez\u00e9s szerint nincs enged\u00e9lyezve. Ezt az integr\u00e1ci\u00f3s be\u00e1ll\u00edt\u00e1sokban enged\u00e9lyezheti.", "title": "AccuWeather" } } @@ -22,6 +23,10 @@ "options": { "step": { "user": { + "data": { + "forecast": "Id\u0151j\u00e1r\u00e1s el\u0151rejelz\u00e9s" + }, + "description": "Az AccuWeather API kulcs ingyenes verzi\u00f3j\u00e1nak korl\u00e1tai miatt, amikor enged\u00e9lyezi az id\u0151j\u00e1r\u00e1s -el\u0151rejelz\u00e9st, az adatfriss\u00edt\u00e9seket 40 percenk\u00e9nt 80 percenk\u00e9nt hajtj\u00e1k v\u00e9gre.", "title": "AccuWeather be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/acmeda/translations/hu.json b/homeassistant/components/acmeda/translations/hu.json index 6105977de80..f302995e7e9 100644 --- a/homeassistant/components/acmeda/translations/hu.json +++ b/homeassistant/components/acmeda/translations/hu.json @@ -2,6 +2,14 @@ "config": { "abort": { "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" + }, + "step": { + "user": { + "data": { + "id": "Gazdag\u00e9p azonos\u00edt\u00f3" + }, + "title": "V\u00e1lassza ki a hozz\u00e1adni k\u00edv\u00e1nt hubot" + } } } } \ No newline at end of file diff --git a/homeassistant/components/adax/translations/hu.json b/homeassistant/components/adax/translations/hu.json new file mode 100644 index 00000000000..726381a4dd7 --- /dev/null +++ b/homeassistant/components/adax/translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "user": { + "data": { + "account_id": "Fi\u00f3k ID", + "host": "Gazdag\u00e9p", + "password": "Jelsz\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/hu.json b/homeassistant/components/adguard/translations/hu.json index 22fb5539bfa..8a860caf79d 100644 --- a/homeassistant/components/adguard/translations/hu.json +++ b/homeassistant/components/adguard/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", + "existing_instance_updated": "Friss\u00edtette a megl\u00e9v\u0151 konfigur\u00e1ci\u00f3t." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" @@ -19,7 +20,8 @@ "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "username": "Felhaszn\u00e1l\u00f3n\u00e9v", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" - } + }, + "description": "\u00c1ll\u00edtsa be az AdGuard Home p\u00e9ld\u00e1nyt, hogy lehet\u0151v\u00e9 tegye a fel\u00fcgyeletet \u00e9s az ir\u00e1ny\u00edt\u00e1st." } } } diff --git a/homeassistant/components/agent_dvr/translations/hu.json b/homeassistant/components/agent_dvr/translations/hu.json index 49968ceea75..fff86517073 100644 --- a/homeassistant/components/agent_dvr/translations/hu.json +++ b/homeassistant/components/agent_dvr/translations/hu.json @@ -12,7 +12,8 @@ "data": { "host": "Hoszt", "port": "Port" - } + }, + "title": "\u00c1ll\u00edtsa be az Agent DVR-t" } } } diff --git a/homeassistant/components/airvisual/translations/hu.json b/homeassistant/components/airvisual/translations/hu.json index e7c47e93793..043a2402283 100644 --- a/homeassistant/components/airvisual/translations/hu.json +++ b/homeassistant/components/airvisual/translations/hu.json @@ -34,13 +34,29 @@ "data": { "ip_address": "Hoszt", "password": "Jelsz\u00f3" - } + }, + "description": "Szem\u00e9lyes AirVisual egys\u00e9g figyel\u00e9se. A jelsz\u00f3 lek\u00e9rhet\u0151 a k\u00e9sz\u00fcl\u00e9k felhaszn\u00e1l\u00f3i fel\u00fclet\u00e9r\u0151l.", + "title": "AirVisual Node/Pro konfigur\u00e1l\u00e1sa" }, "reauth_confirm": { "data": { "api_key": "API kulcs" }, "title": "Az AirVisual \u00fajb\u00f3li hiteles\u00edt\u00e9se" + }, + "user": { + "description": "V\u00e1lassza ki, hogy milyen t\u00edpus\u00fa AirVisual adatokat szeretne figyelni.", + "title": "Az AirVisual konfigur\u00e1l\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "A megfigyelt f\u00f6ldrajz megjelen\u00edt\u00e9se a t\u00e9rk\u00e9pen" + }, + "title": "Az AirVisual konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/airvisual/translations/sensor.hu.json b/homeassistant/components/airvisual/translations/sensor.hu.json new file mode 100644 index 00000000000..93fbb2ce510 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.hu.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Sz\u00e9n-monoxid", + "n2": "Nitrog\u00e9n-dioxid", + "o3": "\u00d3zon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "K\u00e9n-dioxid" + }, + "airvisual__pollutant_level": { + "good": "J\u00f3", + "hazardous": "Vesz\u00e9lyes", + "moderate": "M\u00e9rs\u00e9kelt", + "unhealthy": "Eg\u00e9szs\u00e9gtelen", + "unhealthy_sensitive": "Eg\u00e9szs\u00e9gtelen az \u00e9rz\u00e9keny csoportok sz\u00e1m\u00e1ra", + "very_unhealthy": "Nagyon eg\u00e9szs\u00e9gtelen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/hu.json b/homeassistant/components/alarm_control_panel/translations/hu.json index 961006938d9..5eba25a9ec2 100644 --- a/homeassistant/components/alarm_control_panel/translations/hu.json +++ b/homeassistant/components/alarm_control_panel/translations/hu.json @@ -9,7 +9,12 @@ "trigger": "{entity_name} riaszt\u00e1si esem\u00e9ny ind\u00edt\u00e1sa" }, "condition_type": { - "is_armed_vacation": "{entity_name} nyaral\u00e1s \u00e9les\u00edtve" + "is_armed_away": "{entity_name} \u00e9les\u00edtve van", + "is_armed_home": "{entity_name} \u00e9les\u00edtett otthoni m\u00f3dban", + "is_armed_night": "{entity_name} \u00e9les\u00edtett \u00e9jszaka m\u00f3dban", + "is_armed_vacation": "{entity_name} nyaral\u00e1s \u00e9les\u00edtve", + "is_disarmed": "{entity_name} hat\u00e1stalan\u00edtva", + "is_triggered": "{entity_name} aktiv\u00e1lva van" }, "trigger_type": { "armed_away": "{entity_name} t\u00e1voz\u00f3 m\u00f3dban lett \u00e9les\u00edtve", diff --git a/homeassistant/components/alarmdecoder/translations/hu.json b/homeassistant/components/alarmdecoder/translations/hu.json index 47db325f06c..ace9c7059ca 100644 --- a/homeassistant/components/alarmdecoder/translations/hu.json +++ b/homeassistant/components/alarmdecoder/translations/hu.json @@ -31,6 +31,7 @@ "error": { "int": "Az al\u00e1bbi mez\u0151nek eg\u00e9sz sz\u00e1mnak kell lennie.", "loop_range": "Az RF hurok eg\u00e9sz sz\u00e1m\u00e1nak 1 \u00e9s 4 k\u00f6z\u00f6tt kell lennie.", + "loop_rfid": "Az RF hurok nem haszn\u00e1lhat\u00f3 RF sorozat n\u00e9lk\u00fcl.", "relay_inclusive": "A rel\u00e9c\u00edm \u00e9s a rel\u00e9csatorna egym\u00e1st\u00f3l f\u00fcgg, \u00e9s egy\u00fctt kell felt\u00fcntetni." }, "step": { @@ -55,6 +56,7 @@ "zone_name": "Z\u00f3na neve", "zone_relayaddr": "Rel\u00e9 c\u00edm", "zone_relaychan": "Rel\u00e9 csatorna", + "zone_rfid": "RF soros", "zone_type": "Z\u00f3na t\u00edpusa" }, "description": "Adja meg a {zone_number} z\u00f3na adatait. {zone_number} z\u00f3na t\u00f6rl\u00e9s\u00e9hez hagyja \u00fcresen a Z\u00f3na neve elemet.", diff --git a/homeassistant/components/ambiclimate/translations/hu.json b/homeassistant/components/ambiclimate/translations/hu.json index 04035f04cca..3898535c427 100644 --- a/homeassistant/components/ambiclimate/translations/hu.json +++ b/homeassistant/components/ambiclimate/translations/hu.json @@ -1,11 +1,22 @@ { "config": { "abort": { + "access_token": "Ismeretlen hiba a hozz\u00e1f\u00e9r\u00e9si token gener\u00e1l\u00e1s\u00e1ban.", "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t." }, "create_entry": { "default": "Sikeres hiteles\u00edt\u00e9s" + }, + "error": { + "follow_link": "K\u00e9rlek, k\u00f6vesd a hivatkoz\u00e1st \u00e9s hiteles\u00edtsd magad miel\u0151tt megnyomod a K\u00fcld\u00e9s gombot", + "no_token": "Nem hiteles\u00edtett Ambiclimate" + }, + "step": { + "auth": { + "description": "K\u00e9rj\u00fck, k\u00f6vesse ezt a [link] ({authorization_url} Author_url}) \u00e9s ** Enged\u00e9lyezze ** a hozz\u00e1f\u00e9r\u00e9st Ambiclimate -fi\u00f3kj\u00e1hoz, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot.\n (Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a megadott visszah\u00edv\u00e1si URL {cb_url})", + "title": "Ambiclimate hiteles\u00edt\u00e9se" + } } } } \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/hu.json b/homeassistant/components/arcam_fmj/translations/hu.json index e1784c4ad66..dfccbbe7143 100644 --- a/homeassistant/components/arcam_fmj/translations/hu.json +++ b/homeassistant/components/arcam_fmj/translations/hu.json @@ -5,13 +5,21 @@ "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, + "error": { + "one": "\u00dcres", + "other": "\u00dcres" + }, "flow_title": "{host}", "step": { + "confirm": { + "description": "Hozz\u00e1 szeretn\u00e9 adni az Arcam FMJ \"{host}\" eszk\u00f6zt a HomeAssistanthoz?" + }, "user": { "data": { "host": "Hoszt", "port": "Port" - } + }, + "description": "K\u00e9rj\u00fck, adja meg az eszk\u00f6z gazdag\u00e9pnev\u00e9t vagy IP-c\u00edm\u00e9t." } } } diff --git a/homeassistant/components/atag/translations/hu.json b/homeassistant/components/atag/translations/hu.json index 134f3bedfe8..8c3b4a055b0 100644 --- a/homeassistant/components/atag/translations/hu.json +++ b/homeassistant/components/atag/translations/hu.json @@ -4,14 +4,16 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unauthorized": "A p\u00e1ros\u00edt\u00e1s megtagadva, ellen\u0151rizze az eszk\u00f6z hiteles\u00edt\u00e9si k\u00e9r\u00e9s\u00e9t" }, "step": { "user": { "data": { "host": "Hoszt", "port": "Port" - } + }, + "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" } } } diff --git a/homeassistant/components/august/translations/hu.json b/homeassistant/components/august/translations/hu.json index fec6ad93b26..aeaef514e71 100644 --- a/homeassistant/components/august/translations/hu.json +++ b/homeassistant/components/august/translations/hu.json @@ -30,6 +30,7 @@ "data": { "code": "Ellen\u0151rz\u0151 k\u00f3d" }, + "description": "K\u00e9rj\u00fck, ellen\u0151rizze a {login_method} ({username}), \u00e9s \u00edrja be al\u00e1bb az ellen\u0151rz\u0151 k\u00f3dot", "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s" } } diff --git a/homeassistant/components/awair/translations/hu.json b/homeassistant/components/awair/translations/hu.json index 53827adf344..f465186a95b 100644 --- a/homeassistant/components/awair/translations/hu.json +++ b/homeassistant/components/awair/translations/hu.json @@ -21,7 +21,8 @@ "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", "email": "E-mail" - } + }, + "description": "Regisztr\u00e1lnia kell az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokenj\u00e9hez a k\u00f6vetkez\u0151 c\u00edmen: https://developer.getawair.com/onboard/login" } } } diff --git a/homeassistant/components/axis/translations/hu.json b/homeassistant/components/axis/translations/hu.json index 972690ede97..709de5851ad 100644 --- a/homeassistant/components/axis/translations/hu.json +++ b/homeassistant/components/axis/translations/hu.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "link_local_address": "A linkek helyi c\u00edmei nem t\u00e1mogatottak", + "not_axis_device": "A felfedezett eszk\u00f6z nem Axis eszk\u00f6z" }, "error": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", @@ -17,7 +19,8 @@ "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "\u00c1ll\u00edtsa be az Axis eszk\u00f6zt" } } }, @@ -26,7 +29,8 @@ "configure_stream": { "data": { "stream_profile": "V\u00e1lassza ki a haszn\u00e1lni k\u00edv\u00e1nt adatfolyam-profilt" - } + }, + "title": "Axis eszk\u00f6z vide\u00f3 stream opci\u00f3k" } } } diff --git a/homeassistant/components/azure_devops/translations/hu.json b/homeassistant/components/azure_devops/translations/hu.json index f85c6795fd5..e42ebc8d8e2 100644 --- a/homeassistant/components/azure_devops/translations/hu.json +++ b/homeassistant/components/azure_devops/translations/hu.json @@ -6,14 +6,25 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "project_error": "Nem siker\u00fclt lek\u00e9rni a projekt adatait." }, "flow_title": "{project_url}", "step": { "reauth": { - "description": "A(z) {project_url} hiteles\u00edt\u00e9se nem siker\u00fclt. K\u00e9rj\u00fck, adja meg jelenlegi hiteles\u00edt\u0151 adatait." + "data": { + "personal_access_token": "Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si token (PAT)" + }, + "description": "A(z) {project_url} hiteles\u00edt\u00e9se nem siker\u00fclt. K\u00e9rj\u00fck, adja meg jelenlegi hiteles\u00edt\u0151 adatait.", + "title": "\u00dajrahiteles\u00edt\u00e9s" }, "user": { + "data": { + "organization": "Szervezet", + "personal_access_token": "Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si token (PAT)", + "project": "Projekt" + }, + "description": "\u00c1ll\u00edtson be egy Azure DevOps-p\u00e9ld\u00e1nyt a projekt el\u00e9r\u00e9s\u00e9hez. Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si token csak mag\u00e1nprojekthez sz\u00fcks\u00e9ges.", "title": "Azure DevOps Project hozz\u00e1ad\u00e1sa" } } diff --git a/homeassistant/components/blebox/translations/hu.json b/homeassistant/components/blebox/translations/hu.json index 97a6c1bdc18..ce51a8a0967 100644 --- a/homeassistant/components/blebox/translations/hu.json +++ b/homeassistant/components/blebox/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "address_already_configured": "Egy BleBox-eszk\u00f6z m\u00e1r konfigur\u00e1lva van a(z) {address} c\u00edmen.", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { @@ -14,7 +15,9 @@ "data": { "host": "IP c\u00edm", "port": "Port" - } + }, + "description": "\u00c1ll\u00edtsa be a BleBox k\u00e9sz\u00fcl\u00e9ket a Homeassistantba val\u00f3 integr\u00e1ci\u00f3hoz.", + "title": "\u00c1ll\u00edtsa be a BleBox eszk\u00f6zt" } } } diff --git a/homeassistant/components/blink/translations/hu.json b/homeassistant/components/blink/translations/hu.json index e56b142a5b0..135a2f7ef2e 100644 --- a/homeassistant/components/blink/translations/hu.json +++ b/homeassistant/components/blink/translations/hu.json @@ -21,7 +21,19 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Jelentkezzen be Blink-fi\u00f3kkal" + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)" + }, + "description": "Blink integr\u00e1ci\u00f3 konfigur\u00e1l\u00e1sa", + "title": "Villog\u00e1si lehet\u0151s\u00e9gek" } } } diff --git a/homeassistant/components/braviatv/translations/hu.json b/homeassistant/components/braviatv/translations/hu.json index fbb23fdee04..5f96af8bad7 100644 --- a/homeassistant/components/braviatv/translations/hu.json +++ b/homeassistant/components/braviatv/translations/hu.json @@ -14,12 +14,14 @@ "data": { "pin": "PIN-k\u00f3d" }, + "description": "\u00cdrja be a Sony Bravia TV -n l\u00e1that\u00f3 PIN -k\u00f3dot. \n\n Ha a PIN -k\u00f3d nem jelenik meg, t\u00f6r\u00f6lje a Home Assistant regisztr\u00e1ci\u00f3j\u00e1t a t\u00e9v\u00e9n, l\u00e9pjen a k\u00f6vetkez\u0151re: Be\u00e1ll\u00edt\u00e1sok - > H\u00e1l\u00f3zat - > T\u00e1voli eszk\u00f6z be\u00e1ll\u00edt\u00e1sai - > T\u00e1vol\u00edtsa el a t\u00e1voli eszk\u00f6z regisztr\u00e1ci\u00f3j\u00e1t.", "title": "Sony Bravia TV enged\u00e9lyez\u00e9se" }, "user": { "data": { "host": "Hoszt" }, + "description": "\u00c1ll\u00edtsa be a Sony Bravia TV integr\u00e1ci\u00f3t. Ha probl\u00e9m\u00e1i vannak a konfigur\u00e1ci\u00f3val, l\u00e1togasson el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/braviatv \n\n Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a TV be van kapcsolva.", "title": "Sony Bravia TV" } } diff --git a/homeassistant/components/bsblan/translations/hu.json b/homeassistant/components/bsblan/translations/hu.json index 499a7d92331..51feb8b75d7 100644 --- a/homeassistant/components/bsblan/translations/hu.json +++ b/homeassistant/components/bsblan/translations/hu.json @@ -11,10 +11,13 @@ "user": { "data": { "host": "Hoszt", + "passkey": "Jelsz\u00f3 karakterl\u00e1nc", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "\u00c1ll\u00edtsa be a BSB-Lan eszk\u00f6zt az HomeAssistantba val\u00f3 integr\u00e1ci\u00f3hoz.", + "title": "Csatlakoz\u00e1s a BSB-Lan eszk\u00f6zh\u00f6z" } } } diff --git a/homeassistant/components/cert_expiry/translations/hu.json b/homeassistant/components/cert_expiry/translations/hu.json index 2ae516565e3..de459c324df 100644 --- a/homeassistant/components/cert_expiry/translations/hu.json +++ b/homeassistant/components/cert_expiry/translations/hu.json @@ -4,14 +4,21 @@ "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", "import_failed": "Nem siker\u00fclt import\u00e1lni a konfigur\u00e1ci\u00f3t" }, + "error": { + "connection_refused": "A kapcsolat megtagadva a gazdag\u00e9phez val\u00f3 csatlakoz\u00e1skor", + "connection_timeout": "T\u00fall\u00e9p\u00e9s, amikor ehhez a gazdag\u00e9phez kapcsol\u00f3dik", + "resolve_failed": "Ez a gazdag\u00e9p nem oldhat\u00f3 fel" + }, "step": { "user": { "data": { "host": "Hoszt", "name": "A tan\u00fas\u00edtv\u00e1ny neve", "port": "Port" - } + }, + "title": "Hat\u00e1rozza meg a vizsg\u00e1land\u00f3 tan\u00fas\u00edtv\u00e1nyt" } } - } + }, + "title": "Tan\u00fas\u00edtv\u00e1ny lej\u00e1rata" } \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/hu.json b/homeassistant/components/co2signal/translations/hu.json new file mode 100644 index 00000000000..00bc19e7b49 --- /dev/null +++ b/homeassistant/components/co2signal/translations/hu.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "api_ratelimit": "API D\u00edjkorl\u00e1t t\u00fall\u00e9pve", + "unknown": "V\u00e1ratlan hiba" + }, + "error": { + "api_ratelimit": "API D\u00edjkorl\u00e1t t\u00fall\u00e9pve", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + } + }, + "country": { + "data": { + "country_code": "Orsz\u00e1g k\u00f3d" + } + }, + "user": { + "data": { + "api_key": "Hozz\u00e1f\u00e9r\u00e9si token", + "location": "Adatok lek\u00e9rdez\u00e9se a" + }, + "description": "Token k\u00e9r\u00e9s\u00e9hez l\u00e1togasson el a https://co2signal.com/ webhelyre." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/hu.json b/homeassistant/components/control4/translations/hu.json index 68cb4fe23a9..5d41eb09a84 100644 --- a/homeassistant/components/control4/translations/hu.json +++ b/homeassistant/components/control4/translations/hu.json @@ -14,6 +14,16 @@ "host": "IP c\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "K\u00e9rj\u00fck, adja meg Control4-fi\u00f3kj\u00e1nak adatait \u00e9s a helyi vez\u00e9rl\u0151 IP-c\u00edm\u00e9t." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Friss\u00edt\u00e9sek k\u00f6z\u00f6tti m\u00e1sodpercek" } } } diff --git a/homeassistant/components/coolmaster/translations/hu.json b/homeassistant/components/coolmaster/translations/hu.json index bf67763ca6b..d52dba6b4b4 100644 --- a/homeassistant/components/coolmaster/translations/hu.json +++ b/homeassistant/components/coolmaster/translations/hu.json @@ -1,14 +1,21 @@ { "config": { "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "no_units": "Nem tal\u00e1lhat\u00f3 HVAC egys\u00e9g a CoolMasterNet gazdag\u00e9pben." }, "step": { "user": { "data": { + "cool": "T\u00e1mogatott a h\u0171t\u00e9si m\u00f3d(ok)", + "dry": "T\u00e1mogassa a p\u00e1r\u00e1tlan\u00edt\u00f3 m\u00f3d(ok)", + "fan_only": "T\u00e1mogaott csak ventil\u00e1tor m\u00f3d(ok)", + "heat": "T\u00e1mogatott f\u0171t\u00e9si m\u00f3d(ok)", + "heat_cool": "T\u00e1mogatott f\u0171t\u00e9si/h\u0171t\u00e9si m\u00f3d(ok)", "host": "Hoszt", "off": "Ki lehet kapcsolni" - } + }, + "title": "\u00c1ll\u00edtsa be a CoolMasterNet kapcsolat r\u00e9szleteit." } } } diff --git a/homeassistant/components/deconz/translations/hu.json b/homeassistant/components/deconz/translations/hu.json index 84493ccb9f6..bc003a279e8 100644 --- a/homeassistant/components/deconz/translations/hu.json +++ b/homeassistant/components/deconz/translations/hu.json @@ -26,12 +26,18 @@ "host": "Hoszt", "port": "Port" } + }, + "user": { + "data": { + "host": "V\u00e1lassza ki a felfedezett deCONZ \u00e1tj\u00e1r\u00f3t" + } } } }, "device_automation": { "trigger_subtype": { "both_buttons": "Mindk\u00e9t gomb", + "bottom_buttons": "Als\u00f3 gombok", "button_1": "Els\u0151 gomb", "button_2": "M\u00e1sodik gomb", "button_3": "Harmadik gomb", @@ -52,6 +58,7 @@ "side_4": "4. oldal", "side_5": "5. oldal", "side_6": "6. oldal", + "top_buttons": "Fels\u0151 gombok", "turn_off": "Kikapcsolva", "turn_on": "Bekapcsolva" }, @@ -63,6 +70,7 @@ "remote_button_quadruple_press": "\"{subtype}\" gombra n\u00e9gyszer kattintottak", "remote_button_quintuple_press": "\"{subtype}\" gombra \u00f6tsz\u00f6r kattintottak", "remote_button_rotated": "A gomb elforgatva: \"{subtype}\"", + "remote_button_rotated_fast": "A gomb gyorsan elfordult: \"{subtype}\"", "remote_button_rotation_stopped": "A (z) \"{subtype}\" gomb forg\u00e1sa le\u00e1llt", "remote_button_short_press": "\"{subtype}\" gomb lenyomva", "remote_button_short_release": "\"{subtype}\" gomb elengedve", @@ -93,7 +101,8 @@ "allow_deconz_groups": "DeCONZ f\u00e9nycsoportok enged\u00e9lyez\u00e9se", "allow_new_devices": "Enged\u00e9lyezze az \u00faj eszk\u00f6z\u00f6k automatikus hozz\u00e1ad\u00e1s\u00e1t" }, - "description": "A deCONZ eszk\u00f6zt\u00edpusok l\u00e1that\u00f3s\u00e1g\u00e1nak konfigur\u00e1l\u00e1sa" + "description": "A deCONZ eszk\u00f6zt\u00edpusok l\u00e1that\u00f3s\u00e1g\u00e1nak konfigur\u00e1l\u00e1sa", + "title": "deCONZ opci\u00f3k" } } } diff --git a/homeassistant/components/demo/translations/hu.json b/homeassistant/components/demo/translations/hu.json index 0f8f1673d43..3bfe095189a 100644 --- a/homeassistant/components/demo/translations/hu.json +++ b/homeassistant/components/demo/translations/hu.json @@ -1,9 +1,16 @@ { "options": { "step": { + "init": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + } + }, "options_1": { "data": { "bool": "Opcion\u00e1lis logikai \u00e9rt\u00e9k", + "constant": "\u00c1lland\u00f3", "int": "Numerikus bemenet" } }, diff --git a/homeassistant/components/denonavr/translations/hu.json b/homeassistant/components/denonavr/translations/hu.json index e6727d3c29f..43ee362d65a 100644 --- a/homeassistant/components/denonavr/translations/hu.json +++ b/homeassistant/components/denonavr/translations/hu.json @@ -3,17 +3,32 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", - "cannot_connect": "Nem siker\u00fclt csatlakozni, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja \u00fajra. A h\u00e1l\u00f3zati \u00e9s Ethernet k\u00e1belek kih\u00faz\u00e1sa \u00e9s \u00fajracsatlakoztat\u00e1sa seg\u00edthet" + "cannot_connect": "Nem siker\u00fclt csatlakozni, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja \u00fajra. A h\u00e1l\u00f3zati \u00e9s Ethernet k\u00e1belek kih\u00faz\u00e1sa \u00e9s \u00fajracsatlakoztat\u00e1sa seg\u00edthet", + "not_denonavr_manufacturer": "Nem egy Denon AVR h\u00e1l\u00f3zati vev\u0151, felfedezett gy\u00e1rt\u00f3 nem egyezik", + "not_denonavr_missing": "Nem Denon AVR h\u00e1l\u00f3zati vev\u0151, a felfedez\u00e9si inform\u00e1ci\u00f3k nem teljesek" }, "error": { "discovery_error": "Nem siker\u00fclt megtal\u00e1lni a Denon AVR h\u00e1l\u00f3zati er\u0151s\u00edt\u0151t" }, "flow_title": "{name}", "step": { + "confirm": { + "description": "K\u00e9rj\u00fck, er\u0151s\u00edtse meg a vev\u0151 hozz\u00e1ad\u00e1s\u00e1t", + "title": "Denon AVR h\u00e1l\u00f3zati vev\u0151k\u00e9sz\u00fcl\u00e9kek" + }, + "select": { + "data": { + "select_host": "Vev\u0151 IP-c\u00edme" + }, + "description": "Futtassa \u00fajra a be\u00e1ll\u00edt\u00e1st, ha tov\u00e1bbi vev\u0151k\u00e9sz\u00fcl\u00e9keket szeretne csatlakoztatni", + "title": "V\u00e1lassza ki a csatlakoztatni k\u00edv\u00e1nt vev\u0151t" + }, "user": { "data": { "host": "IP c\u00edm" - } + }, + "description": "Csatlakozzon a vev\u0151h\u00f6z, ha az IP-c\u00edm nincs be\u00e1ll\u00edtva, az automatikus felder\u00edt\u00e9st haszn\u00e1lja", + "title": "Denon AVR h\u00e1l\u00f3zati vev\u0151k\u00e9sz\u00fcl\u00e9kek" } } }, @@ -21,8 +36,13 @@ "step": { "init": { "data": { - "update_audyssey": "Friss\u00edtse az Audyssey be\u00e1ll\u00edt\u00e1sait" - } + "show_all_sources": "Az \u00f6sszes forr\u00e1s megjelen\u00edt\u00e9se", + "update_audyssey": "Friss\u00edtse az Audyssey be\u00e1ll\u00edt\u00e1sait", + "zone2": "\u00c1ll\u00edtsa be a 2. z\u00f3n\u00e1t", + "zone3": "\u00c1ll\u00edtsa be a 3. z\u00f3n\u00e1t" + }, + "description": "Adja meg az opcion\u00e1lis be\u00e1ll\u00edt\u00e1sokat", + "title": "Denon AVR h\u00e1l\u00f3zati vev\u0151k\u00e9sz\u00fcl\u00e9kek" } } } diff --git a/homeassistant/components/dexcom/translations/hu.json b/homeassistant/components/dexcom/translations/hu.json index 45f38b22a84..039eb56f8f0 100644 --- a/homeassistant/components/dexcom/translations/hu.json +++ b/homeassistant/components/dexcom/translations/hu.json @@ -14,7 +14,9 @@ "password": "Jelsz\u00f3", "server": "Szerver", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "Adja meg a Dexcom Share hiteles\u00edt\u0151 adatait", + "title": "Dexcom integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sa" } } }, diff --git a/homeassistant/components/directv/translations/hu.json b/homeassistant/components/directv/translations/hu.json index 0309eb35881..3e0a7d5cb57 100644 --- a/homeassistant/components/directv/translations/hu.json +++ b/homeassistant/components/directv/translations/hu.json @@ -7,7 +7,15 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, + "flow_title": "{name}", "step": { + "ssdp_confirm": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + }, + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?" + }, "user": { "data": { "host": "Hoszt" diff --git a/homeassistant/components/doorbird/translations/hu.json b/homeassistant/components/doorbird/translations/hu.json index 3f74783b7ac..cb4c46e699a 100644 --- a/homeassistant/components/doorbird/translations/hu.json +++ b/homeassistant/components/doorbird/translations/hu.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "link_local_address": "A linkek helyi c\u00edmei nem t\u00e1mogatottak", + "not_doorbird_device": "Ez az eszk\u00f6z nem DoorBird" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -16,7 +18,18 @@ "name": "Eszk\u00f6z neve", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon a DoorBird-hez" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Vessz\u0151vel elv\u00e1lasztott esem\u00e9nyek list\u00e1ja." + }, + "description": "Adjon hozz\u00e1 vessz\u0151vel elv\u00e1lasztott esem\u00e9nynevet minden k\u00f6vetni k\u00edv\u00e1nt esem\u00e9nyhez. Miut\u00e1n itt megadta \u0151ket, haszn\u00e1lja a DoorBird alkalmaz\u00e1st, hogy hozz\u00e1rendelje \u0151ket egy adott esem\u00e9nyhez. Tekintse meg a dokument\u00e1ci\u00f3t a https://www.home-assistant.io/integrations/doorbird/#events c\u00edmen. P\u00e9lda: valaki_pr\u00e9selt_gomb, mozg\u00e1s" } } } diff --git a/homeassistant/components/dunehd/translations/hu.json b/homeassistant/components/dunehd/translations/hu.json index cf0b593d546..148a6fde0d0 100644 --- a/homeassistant/components/dunehd/translations/hu.json +++ b/homeassistant/components/dunehd/translations/hu.json @@ -13,6 +13,7 @@ "data": { "host": "Hoszt" }, + "description": "\u00c1ll\u00edtsa be a Dune HD integr\u00e1ci\u00f3t. Ha probl\u00e9m\u00e1i vannak a konfigur\u00e1ci\u00f3val, l\u00e1togasson el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/dunehd \n\n Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a lej\u00e1tsz\u00f3 be van kapcsolva.", "title": "Dune HD" } } diff --git a/homeassistant/components/eafm/translations/hu.json b/homeassistant/components/eafm/translations/hu.json index 38863029f12..820958e4e6e 100644 --- a/homeassistant/components/eafm/translations/hu.json +++ b/homeassistant/components/eafm/translations/hu.json @@ -1,13 +1,16 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "no_stations": "Nem tal\u00e1lhat\u00f3 \u00e1rv\u00edzfigyel\u0151 \u00e1llom\u00e1s." }, "step": { "user": { "data": { "station": "\u00c1llom\u00e1s" - } + }, + "description": "V\u00e1lassza ki a figyelni k\u00edv\u00e1nt \u00e1llom\u00e1st", + "title": "\u00c1rv\u00edzfigyel\u0151 \u00e1llom\u00e1s nyomon k\u00f6vet\u00e9se" } } } diff --git a/homeassistant/components/elgato/translations/hu.json b/homeassistant/components/elgato/translations/hu.json index ef6404bd92d..0cd9f2589b8 100644 --- a/homeassistant/components/elgato/translations/hu.json +++ b/homeassistant/components/elgato/translations/hu.json @@ -13,7 +13,12 @@ "data": { "host": "Hoszt", "port": "Port" - } + }, + "description": "\u00c1ll\u00edtsa be az Elgato Light-ot, hogy integr\u00e1lhat\u00f3 legyen az HomeAssistantba." + }, + "zeroconf_confirm": { + "description": "Hozz\u00e1 szeretn\u00e9 adni a \"{serial_number}\" sorozatsz\u00e1m\u00fa Elgato Light-ot az HomeAssistanthoz?", + "title": "Felfedezett Elgato Light eszk\u00f6z(\u00f6k)" } } } diff --git a/homeassistant/components/elkm1/translations/hu.json b/homeassistant/components/elkm1/translations/hu.json index 83862dfb75f..ff6445f0b72 100644 --- a/homeassistant/components/elkm1/translations/hu.json +++ b/homeassistant/components/elkm1/translations/hu.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "address_already_configured": "Az ElkM1 ezzel a c\u00edmmel m\u00e1r konfigur\u00e1lva van", + "already_configured": "Az ezzel az el\u0151taggal rendelkez\u0151 ElkM1 m\u00e1r konfigur\u00e1lva van" + }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", @@ -8,10 +12,15 @@ "step": { "user": { "data": { + "address": "Az IP-c\u00edm vagy tartom\u00e1ny vagy soros port, ha soros kapcsolaton kereszt\u00fcl csatlakozik.", "password": "Jelsz\u00f3", + "prefix": "Egyedi el\u0151tag (hagyja \u00fcresen, ha csak egy ElkM1 van).", "protocol": "Protokoll", + "temperature_unit": "Az ElkM1 h\u0151m\u00e9rs\u00e9kleti egys\u00e9g haszn\u00e1lja.", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A c\u00edmsornak a \u201ebiztons\u00e1gos\u201d \u00e9s a \u201enem biztons\u00e1gos\u201d \u201ec\u00edm [: port]\u201d form\u00e1tum\u00fanak kell lennie. P\u00e9lda: '192.168.1.1'. A port opcion\u00e1lis, \u00e9s alap\u00e9rtelmez\u00e9s szerint 2101 \u201enem biztons\u00e1gos\u201d \u00e9s 2601 \u201ebiztons\u00e1gos\u201d. A soros protokollhoz a c\u00edmnek 'tty [: baud]' form\u00e1tum\u00fanak kell lennie. P\u00e9lda: '/dev/ttyS1'. A baud opcion\u00e1lis, \u00e9s alap\u00e9rtelmez\u00e9s szerint 115200.", + "title": "Csatlakoz\u00e1s az Elk-M1 vez\u00e9rl\u0151h\u00f6z" } } } diff --git a/homeassistant/components/energy/translations/hu.json b/homeassistant/components/energy/translations/hu.json new file mode 100644 index 00000000000..c8d85790fdd --- /dev/null +++ b/homeassistant/components/energy/translations/hu.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/hu.json b/homeassistant/components/enocean/translations/hu.json index 065747fb39d..9cc6843682c 100644 --- a/homeassistant/components/enocean/translations/hu.json +++ b/homeassistant/components/enocean/translations/hu.json @@ -1,7 +1,23 @@ { "config": { "abort": { + "invalid_dongle_path": "\u00c9rv\u00e9nytelen dongle \u00fatvonal", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "error": { + "invalid_dongle_path": "Nem tal\u00e1lhat\u00f3 \u00e9rv\u00e9nyes dongle ehhez az \u00fatvonalhoz" + }, + "step": { + "detect": { + "data": { + "path": "USB dongle el\u00e9r\u00e9si \u00fatja" + } + }, + "manual": { + "data": { + "path": "USB dongle el\u00e9r\u00e9si \u00fatja" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/hu.json b/homeassistant/components/firmata/translations/hu.json index 563ede56155..8224d177a9f 100644 --- a/homeassistant/components/firmata/translations/hu.json +++ b/homeassistant/components/firmata/translations/hu.json @@ -2,6 +2,10 @@ "config": { "abort": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "one": "\u00dcres", + "other": "\u00dcres" } } } \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/hu.json b/homeassistant/components/flick_electric/translations/hu.json index f7ed726e433..90ea92089e1 100644 --- a/homeassistant/components/flick_electric/translations/hu.json +++ b/homeassistant/components/flick_electric/translations/hu.json @@ -11,6 +11,8 @@ "step": { "user": { "data": { + "client_id": "Kliens ID (opcion\u00e1lis)", + "client_secret": "Kliens jelsz\u00f3 (nem k\u00f6telez\u0151)", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, diff --git a/homeassistant/components/flipr/translations/hu.json b/homeassistant/components/flipr/translations/hu.json new file mode 100644 index 00000000000..4daf0446abc --- /dev/null +++ b/homeassistant/components/flipr/translations/hu.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "no_flipr_id_found": "A fi\u00f3kj\u00e1hoz jelenleg nem tartozik Flipr-azonos\u00edt\u00f3. El\u0151sz\u00f6r ellen\u0151riznie kell, hogy m\u0171k\u00f6dik-e a Flipr mobilalkalmaz\u00e1s\u00e1val.", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr azonos\u00edt\u00f3" + }, + "description": "V\u00e1lassza ki a Flipr azonos\u00edt\u00f3j\u00e1t a list\u00e1b\u00f3l", + "title": "V\u00e1lassza ki a Flipr-t" + }, + "user": { + "data": { + "email": "Email", + "password": "Jelsz\u00f3" + }, + "description": "Csatlakozzon a Flipr-fi\u00f3kj\u00e1val.", + "title": "Csatlakoz\u00e1s a Flipr-hez" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/hu.json b/homeassistant/components/flume/translations/hu.json index e607ac4255e..e1780be5654 100644 --- a/homeassistant/components/flume/translations/hu.json +++ b/homeassistant/components/flume/translations/hu.json @@ -19,9 +19,13 @@ }, "user": { "data": { + "client_id": "\u00dcgyf\u00e9lazonos\u00edt\u00f3", + "client_secret": "\u00dcgyf\u00e9l jelszva", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A Flume Personal API el\u00e9r\u00e9s\u00e9hez \u201e\u00dcgyf\u00e9l-azonos\u00edt\u00f3t\u201d \u00e9s \u201e\u00dcgyf\u00e9ltitkot\u201d kell k\u00e9rnie a https://portal.flumetech.com/settings#token c\u00edmen.", + "title": "Csatlakozzon a Flume-fi\u00f3kj\u00e1hoz" } } } diff --git a/homeassistant/components/flunearyou/translations/hu.json b/homeassistant/components/flunearyou/translations/hu.json index 4f8cca2a939..b9ef1712ced 100644 --- a/homeassistant/components/flunearyou/translations/hu.json +++ b/homeassistant/components/flunearyou/translations/hu.json @@ -11,7 +11,8 @@ "data": { "latitude": "Sz\u00e9less\u00e9g", "longitude": "Hossz\u00fas\u00e1g" - } + }, + "description": "Figyelje a felhaszn\u00e1l\u00f3alap\u00fa \u00e9s a CDC jelent\u00e9seket egy p\u00e1r koordin\u00e1t\u00e1ra." } } } diff --git a/homeassistant/components/forked_daapd/translations/hu.json b/homeassistant/components/forked_daapd/translations/hu.json index 3400984dcd6..aac95b2956a 100644 --- a/homeassistant/components/forked_daapd/translations/hu.json +++ b/homeassistant/components/forked_daapd/translations/hu.json @@ -1,17 +1,24 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "not_forked_daapd": "Az eszk\u00f6z nem forked-daapd kiszolg\u00e1l\u00f3." }, "error": { "forbidden": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a forked-daapd h\u00e1l\u00f3zati enged\u00e9lyeket.", - "unknown_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + "unknown_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", + "websocket_not_enabled": "forked-daapd szerver websocket nincs enged\u00e9lyezve.", + "wrong_host_or_port": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a gazdag\u00e9pet \u00e9s a portot.", + "wrong_password": "Helytelen jelsz\u00f3." }, "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt" + "host": "Hoszt", + "name": "Megjelen\u00edt\u00e9si n\u00e9v", + "password": "API jelsz\u00f3 (hagyja \u00fcresen, ha nincs jelsz\u00f3)", + "port": "API port" } } } diff --git a/homeassistant/components/freebox/translations/hu.json b/homeassistant/components/freebox/translations/hu.json index 1f0b848d3b6..c929d56f38e 100644 --- a/homeassistant/components/freebox/translations/hu.json +++ b/homeassistant/components/freebox/translations/hu.json @@ -9,6 +9,10 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "link": { + "description": "Kattintson a \u201eK\u00fcld\u00e9s\u201d gombra, majd \u00e9rintse meg a jobbra mutat\u00f3 nyilat az \u00fatv\u00e1laszt\u00f3n a Freebox regisztr\u00e1l\u00e1s\u00e1hoz a HomeAssistant seg\u00edts\u00e9g\u00e9vel. \n\n ! [A gomb helye az \u00fatv\u00e1laszt\u00f3n] (/static/images/config_freebox.png)", + "title": "Freebox \u00fatv\u00e1laszt\u00f3 linkel\u00e9se" + }, "user": { "data": { "host": "Hoszt", diff --git a/homeassistant/components/fritzbox/translations/hu.json b/homeassistant/components/fritzbox/translations/hu.json index 81639b1d830..50a81601310 100644 --- a/homeassistant/components/fritzbox/translations/hu.json +++ b/homeassistant/components/fritzbox/translations/hu.json @@ -4,6 +4,7 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "not_supported": "Csatlakoztatva az AVM FRITZ! Boxhoz, de nem tudja vez\u00e9relni az intelligens otthoni eszk\u00f6z\u00f6ket.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { @@ -30,7 +31,8 @@ "host": "Hoszt", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "Adja meg az AVM FRITZ! Box adatait." } } } diff --git a/homeassistant/components/geonetnz_quakes/translations/hu.json b/homeassistant/components/geonetnz_quakes/translations/hu.json index 21a38c18e28..d6070db4fe7 100644 --- a/homeassistant/components/geonetnz_quakes/translations/hu.json +++ b/homeassistant/components/geonetnz_quakes/translations/hu.json @@ -6,6 +6,7 @@ "step": { "user": { "data": { + "mmi": "MMI", "radius": "Sug\u00e1r" }, "title": "T\u00f6ltsd ki a sz\u0171r\u0151 adatait." diff --git a/homeassistant/components/gogogate2/translations/hu.json b/homeassistant/components/gogogate2/translations/hu.json index 641046d7745..30d6ef5c016 100644 --- a/homeassistant/components/gogogate2/translations/hu.json +++ b/homeassistant/components/gogogate2/translations/hu.json @@ -15,6 +15,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "Adja meg a sz\u00fcks\u00e9ges inform\u00e1ci\u00f3kat al\u00e1bb.", "title": "A GogoGate2 vagy az iSmartGate be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/growatt_server/translations/hu.json b/homeassistant/components/growatt_server/translations/hu.json index d856d13a96b..5b2efc737fe 100644 --- a/homeassistant/components/growatt_server/translations/hu.json +++ b/homeassistant/components/growatt_server/translations/hu.json @@ -17,6 +17,7 @@ "data": { "name": "N\u00e9v", "password": "Jelsz\u00f3", + "url": "URL", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "title": "Adja meg Growatt adatait" diff --git a/homeassistant/components/guardian/translations/hu.json b/homeassistant/components/guardian/translations/hu.json index ca9a746f9d9..15469bead1e 100644 --- a/homeassistant/components/guardian/translations/hu.json +++ b/homeassistant/components/guardian/translations/hu.json @@ -13,7 +13,11 @@ "data": { "ip_address": "IP c\u00edm", "port": "Port" - } + }, + "description": "Konfigur\u00e1lja a helyi Elexa Guardian eszk\u00f6zt." + }, + "zeroconf_confirm": { + "description": "Be akarja \u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" } } } diff --git a/homeassistant/components/hangouts/translations/hu.json b/homeassistant/components/hangouts/translations/hu.json index 3c065b01169..2f02ba9f623 100644 --- a/homeassistant/components/hangouts/translations/hu.json +++ b/homeassistant/components/hangouts/translations/hu.json @@ -19,6 +19,7 @@ }, "user": { "data": { + "authorization_code": "Enged\u00e9lyez\u00e9si k\u00f3d (k\u00e9zi hiteles\u00edt\u00e9shez sz\u00fcks\u00e9ges)", "email": "E-mail", "password": "Jelsz\u00f3" }, diff --git a/homeassistant/components/harmony/translations/hu.json b/homeassistant/components/harmony/translations/hu.json index a9cb6ccecee..4922bbd1ac6 100644 --- a/homeassistant/components/harmony/translations/hu.json +++ b/homeassistant/components/harmony/translations/hu.json @@ -7,11 +7,29 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, + "flow_title": "{name}", "step": { + "link": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "title": "A Logitech Harmony Hub be\u00e1ll\u00edt\u00e1sa" + }, "user": { "data": { - "host": "Hoszt" - } + "host": "Hoszt", + "name": "Hub neve" + }, + "title": "A Logitech Harmony Hub be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Az alap\u00e9rtelmezett tev\u00e9kenys\u00e9g, amelyet akkor kell v\u00e9grehajtani, ha nincs megadva.", + "delay_secs": "A parancsok k\u00fcld\u00e9se k\u00f6z\u00f6tti k\u00e9s\u00e9s." + }, + "description": "Harmony Hub be\u00e1ll\u00edt\u00e1sok" } } } diff --git a/homeassistant/components/heos/translations/hu.json b/homeassistant/components/heos/translations/hu.json index 2fbce1993cd..c487b49ee47 100644 --- a/homeassistant/components/heos/translations/hu.json +++ b/homeassistant/components/heos/translations/hu.json @@ -10,7 +10,9 @@ "user": { "data": { "host": "Hoszt" - } + }, + "description": "K\u00e9rj\u00fck, adja meg egy Heos-eszk\u00f6z gazdag\u00e9pnev\u00e9t vagy IP-c\u00edm\u00e9t (lehet\u0151leg egy vezet\u00e9kkel a h\u00e1l\u00f3zathoz csatlakoztatott eszk\u00f6zt).", + "title": "Csatlakoz\u00e1s a Heos-hoz" } } } diff --git a/homeassistant/components/homeassistant/translations/he.json b/homeassistant/components/homeassistant/translations/he.json index f86d7b0dca0..20de5a2d1b7 100644 --- a/homeassistant/components/homeassistant/translations/he.json +++ b/homeassistant/components/homeassistant/translations/he.json @@ -8,6 +8,7 @@ "os_version": "\u05d2\u05d9\u05e8\u05e1\u05ea \u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e4\u05e2\u05dc\u05d4", "python_version": "\u05d2\u05e8\u05e1\u05ea \u05e4\u05d9\u05d9\u05ea\u05d5\u05df", "timezone": "\u05d0\u05d6\u05d5\u05e8 \u05d6\u05de\u05df", + "user": "\u05de\u05e9\u05ea\u05de\u05e9", "version": "\u05d2\u05d9\u05e8\u05e1\u05d4" } } diff --git a/homeassistant/components/homeassistant/translations/hu.json b/homeassistant/components/homeassistant/translations/hu.json index 9eddeeba112..b4da84596bf 100644 --- a/homeassistant/components/homeassistant/translations/hu.json +++ b/homeassistant/components/homeassistant/translations/hu.json @@ -10,6 +10,7 @@ "os_version": "Oper\u00e1ci\u00f3s rendszer verzi\u00f3ja", "python_version": "Python verzi\u00f3", "timezone": "Id\u0151z\u00f3na", + "user": "Felhaszn\u00e1l\u00f3", "version": "Verzi\u00f3", "virtualenv": "Virtu\u00e1lis k\u00f6rnyezet" } diff --git a/homeassistant/components/homekit/translations/hu.json b/homeassistant/components/homekit/translations/hu.json index 1afc0183a0d..c6fdf0afd74 100644 --- a/homeassistant/components/homekit/translations/hu.json +++ b/homeassistant/components/homekit/translations/hu.json @@ -1,13 +1,18 @@ { "config": { + "abort": { + "port_name_in_use": "Az azonos nev\u0171 vagy port\u00fa tartoz\u00e9k vagy h\u00edd m\u00e1r konfigur\u00e1lva van." + }, "step": { "pairing": { + "description": "A p\u00e1ros\u00edt\u00e1s befejez\u00e9s\u00e9hez k\u00f6vesse a \u201eHomeKit p\u00e1ros\u00edt\u00e1s\u201d szakasz \u201e\u00c9rtes\u00edt\u00e9sek\u201d szakasz\u00e1ban tal\u00e1lhat\u00f3 utas\u00edt\u00e1sokat.", "title": "HomeKit p\u00e1ros\u00edt\u00e1s" }, "user": { "data": { "include_domains": "Felvenni k\u00edv\u00e1nt domainek" }, + "description": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt domaineket. A domain minden t\u00e1mogatott entit\u00e1sa szerepelni fog. Minden tartoz\u00e9k m\u00f3dban k\u00fcl\u00f6n HomeKit p\u00e9ld\u00e1ny j\u00f6n l\u00e9tre minden TV m\u00e9dialej\u00e1tsz\u00f3hoz, tev\u00e9kenys\u00e9g alap\u00fa t\u00e1vir\u00e1ny\u00edt\u00f3hoz, z\u00e1rhoz \u00e9s f\u00e9nyk\u00e9pez\u0151g\u00e9phez.", "title": "Felvenni k\u00edv\u00e1nt domainek kiv\u00e1laszt\u00e1sa" } } @@ -15,12 +20,17 @@ "options": { "step": { "advanced": { + "data": { + "auto_start": "Automatikus ind\u00edt\u00e1s (tiltsa le, ha manu\u00e1lisan h\u00edvja a homekit.start szolg\u00e1ltat\u00e1st)" + }, + "description": "Ezeket a be\u00e1ll\u00edt\u00e1sokat csak akkor kell m\u00f3dos\u00edtani, ha a HomeKit nem m\u0171k\u00f6dik.", "title": "Halad\u00f3 be\u00e1ll\u00edt\u00e1sok" }, "cameras": { "data": { "camera_copy": "A nat\u00edv H.264 streameket t\u00e1mogat\u00f3 kamer\u00e1k" }, + "description": "Ellen\u0151rizze az \u00f6sszes kamer\u00e1t, amely t\u00e1mogatja a nat\u00edv H.264 adatfolyamokat. Ha a f\u00e9nyk\u00e9pez\u0151g\u00e9p nem ad ki H.264 adatfolyamot, a rendszer \u00e1tk\u00f3dolja a vide\u00f3t H.264 form\u00e1tumba a HomeKit sz\u00e1m\u00e1ra. Az \u00e1tk\u00f3dol\u00e1shoz nagy teljes\u00edtm\u00e9ny\u0171 CPU sz\u00fcks\u00e9ges, \u00e9s val\u00f3sz\u00edn\u0171leg nem fog m\u0171k\u00f6dni egylapos sz\u00e1m\u00edt\u00f3g\u00e9peken.", "title": "V\u00e1laszd ki a kamera vide\u00f3 kodekj\u00e9t." }, "include_exclude": { @@ -28,6 +38,7 @@ "entities": "Entit\u00e1sok", "mode": "M\u00f3d" }, + "description": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat. Kieg\u00e9sz\u00edt\u0151 m\u00f3dban csak egyetlen entit\u00e1s szerepel. H\u00eddbefogad\u00e1si m\u00f3dban a tartom\u00e1ny \u00f6sszes entit\u00e1sa szerepelni fog, hacsak nincsenek kijel\u00f6lve konkr\u00e9t entit\u00e1sok. H\u00eddkiz\u00e1r\u00e1si m\u00f3dban a domain \u00f6sszes entit\u00e1sa szerepelni fog, kiv\u00e9ve a kiz\u00e1rt entit\u00e1sokat. A legjobb teljes\u00edtm\u00e9ny \u00e9rdek\u00e9ben minden TV m\u00e9dialej\u00e1tsz\u00f3hoz, tev\u00e9kenys\u00e9galap\u00fa t\u00e1vir\u00e1ny\u00edt\u00f3hoz, z\u00e1rhoz \u00e9s f\u00e9nyk\u00e9pez\u0151g\u00e9phez k\u00fcl\u00f6n HomeKit tartoz\u00e9kot hoznak l\u00e9tre.", "title": "V\u00e1laszd ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat" }, "init": { @@ -35,6 +46,7 @@ "include_domains": "Felvenni k\u00edv\u00e1nt domainek", "mode": "M\u00f3d" }, + "description": "A HomeKit konfigur\u00e1lhat\u00f3 \u00fagy, hogy egy h\u00edd vagy egyetlen tartoz\u00e9k l\u00e1that\u00f3 legyen. Kieg\u00e9sz\u00edt\u0151 m\u00f3dban csak egyetlen entit\u00e1s haszn\u00e1lhat\u00f3. A tartoz\u00e9k m\u00f3dra van sz\u00fcks\u00e9g ahhoz, hogy a TV -eszk\u00f6zoszt\u00e1ly\u00fa m\u00e9dialej\u00e1tsz\u00f3k megfelel\u0151en m\u0171k\u00f6djenek. A \u201eTartalmazand\u00f3 tartom\u00e1nyok\u201d entit\u00e1sai szerepelni fognak a HomeKitben. A k\u00f6vetkez\u0151 k\u00e9perny\u0151n kiv\u00e1laszthatja, hogy mely entit\u00e1sokat k\u00edv\u00e1nja felvenni vagy kiz\u00e1rni a list\u00e1b\u00f3l.", "title": "V\u00e1laszd ki a felvenni k\u00edv\u00e1nt domaineket." }, "yaml": { diff --git a/homeassistant/components/homekit_controller/translations/hu.json b/homeassistant/components/homekit_controller/translations/hu.json index cd06d12e809..1ad63bfb508 100644 --- a/homeassistant/components/homekit_controller/translations/hu.json +++ b/homeassistant/components/homekit_controller/translations/hu.json @@ -21,6 +21,7 @@ "flow_title": "HomeKit tartoz\u00e9k: {name}", "step": { "busy_error": { + "description": "Sz\u00fcntesse meg a p\u00e1ros\u00edt\u00e1st az \u00f6sszes vez\u00e9rl\u0151n, vagy pr\u00f3b\u00e1lja \u00fajraind\u00edtani az eszk\u00f6zt, majd folytassa a p\u00e1ros\u00edt\u00e1st.", "title": "Az eszk\u00f6z m\u00e1r p\u00e1rosul egy m\u00e1sik vez\u00e9rl\u0151vel" }, "max_tries_error": { @@ -36,6 +37,7 @@ "title": "HomeKit tartoz\u00e9k p\u00e1ros\u00edt\u00e1sa" }, "protocol_error": { + "description": "El\u0151fordulhat, hogy a k\u00e9sz\u00fcl\u00e9k nincs p\u00e1ros\u00edt\u00e1si m\u00f3dban, \u00e9s sz\u00fcks\u00e9g lehet fizikai vagy virtu\u00e1lis gombnyom\u00e1sra. Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy az eszk\u00f6z p\u00e1ros\u00edt\u00e1si m\u00f3dban van, vagy pr\u00f3b\u00e1lja \u00fajraind\u00edtani az eszk\u00f6zt, majd folytassa a p\u00e1ros\u00edt\u00e1st.", "title": "Hiba t\u00f6rt\u00e9nt a tartoz\u00e9kkal val\u00f3 kommunik\u00e1ci\u00f3 sor\u00e1n" }, "user": { diff --git a/homeassistant/components/honeywell/translations/hu.json b/homeassistant/components/honeywell/translations/hu.json new file mode 100644 index 00000000000..5583dc22f2e --- /dev/null +++ b/homeassistant/components/honeywell/translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "K\u00e9rj\u00fck, adja meg a mytotalconnectcomfort.com webhelyre val\u00f3 bejelentkez\u00e9shez haszn\u00e1lt hiteles\u00edt\u0151 adatokat.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/hu.json b/homeassistant/components/huawei_lte/translations/hu.json index eff9c8a813b..22bd37c37ba 100644 --- a/homeassistant/components/huawei_lte/translations/hu.json +++ b/homeassistant/components/huawei_lte/translations/hu.json @@ -11,6 +11,8 @@ "incorrect_username": "Helytelen felhaszn\u00e1l\u00f3n\u00e9v", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "invalid_url": "\u00c9rv\u00e9nytelen URL", + "login_attempts_exceeded": "T\u00fall\u00e9pte a maxim\u00e1lis bejelentkez\u00e9si k\u00eds\u00e9rleteket. K\u00e9rj\u00fck, pr\u00f3b\u00e1lja \u00fajra k\u00e9s\u0151bb", + "response_error": "Ismeretlen hiba az eszk\u00f6zr\u0151l", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "flow_title": "{name}", @@ -21,6 +23,7 @@ "url": "URL", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "Adja meg az eszk\u00f6z hozz\u00e1f\u00e9r\u00e9si adatait.", "title": "Huawei LTE konfigur\u00e1l\u00e1sa" } } diff --git a/homeassistant/components/hue/translations/hu.json b/homeassistant/components/hue/translations/hu.json index d0aa043b10b..91321f9c6fd 100644 --- a/homeassistant/components/hue/translations/hu.json +++ b/homeassistant/components/hue/translations/hu.json @@ -35,12 +35,32 @@ }, "device_automation": { "trigger_subtype": { + "button_1": "Els\u0151 gomb", + "button_2": "M\u00e1sodik gomb", + "button_3": "Harmadik gomb", + "button_4": "Negyedik gomb", + "dim_down": "S\u00f6t\u00e9t\u00edt", + "dim_up": "Vil\u00e1gos\u00edt", + "double_buttons_1_3": "Els\u0151 \u00e9s harmadik gomb", + "double_buttons_2_4": "M\u00e1sodik \u00e9s negyedik gomb", "turn_off": "Kikapcsol\u00e1s", "turn_on": "Bekapcsol\u00e1s" }, "trigger_type": { + "remote_button_long_release": "A \"{subtype}\" gomb hossz\u00fa megnyom\u00e1s ut\u00e1n elengedve", "remote_button_short_press": "\"{subtype}\" gomb lenyomva", - "remote_button_short_release": "\"{subtype}\" gomb elengedve" + "remote_button_short_release": "\"{subtype}\" gomb elengedve", + "remote_double_button_long_press": "Mindk\u00e9t \"{subtype}\" hossz\u00fa megnyom\u00e1st k\u00f6vet\u0151en megjelent", + "remote_double_button_short_press": "Mindk\u00e9t \"{subtype}\" megjelent" + } + }, + "options": { + "step": { + "init": { + "data": { + "allow_hue_groups": "Hue csoportok enged\u00e9lyez\u00e9se" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/hunterdouglas_powerview/translations/hu.json b/homeassistant/components/hunterdouglas_powerview/translations/hu.json index 3de1b9d0117..1fedd8bc126 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/hu.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/hu.json @@ -9,10 +9,15 @@ }, "flow_title": "{name} ({host})", "step": { + "link": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "title": "Csatlakozzon a PowerView Hubhoz" + }, "user": { "data": { "host": "IP c\u00edm" - } + }, + "title": "Csatlakozzon a PowerView Hubhoz" } } } diff --git a/homeassistant/components/hvv_departures/translations/hu.json b/homeassistant/components/hvv_departures/translations/hu.json index deab9bcb929..dfbdd92f27a 100644 --- a/homeassistant/components/hvv_departures/translations/hu.json +++ b/homeassistant/components/hvv_departures/translations/hu.json @@ -5,15 +5,29 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "no_results": "Nincs eredm\u00e9ny. Pr\u00f3b\u00e1lja ki m\u00e1sik \u00e1llom\u00e1ssal/c\u00edmmel" }, "step": { + "station": { + "data": { + "station": "\u00c1llom\u00e1s/c\u00edm" + }, + "title": "Adja meg az \u00e1llom\u00e1st/c\u00edmet" + }, + "station_select": { + "data": { + "station": "\u00c1llom\u00e1s/c\u00edm" + }, + "title": "\u00c1llom\u00e1s/c\u00edm kiv\u00e1laszt\u00e1sa" + }, "user": { "data": { "host": "Hoszt", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon a HVV API-hoz" } } }, @@ -21,8 +35,11 @@ "step": { "init": { "data": { - "offset": "Eltol\u00e1s (perc)" + "filter": "V\u00e1lassza ki a sorokat", + "offset": "Eltol\u00e1s (perc)", + "real_time": "Val\u00f3s idej\u0171 adatok haszn\u00e1lata" }, + "description": "M\u00f3dos\u00edtsa az indul\u00e1si \u00e9rz\u00e9kel\u0151 be\u00e1ll\u00edt\u00e1sait", "title": "Be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/iaqualink/translations/hu.json b/homeassistant/components/iaqualink/translations/hu.json index dcb7b906ee3..1ca85c41190 100644 --- a/homeassistant/components/iaqualink/translations/hu.json +++ b/homeassistant/components/iaqualink/translations/hu.json @@ -11,7 +11,9 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "K\u00e9rj\u00fck, adja meg iAqualink-fi\u00f3kja felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t.", + "title": "Csatlakoz\u00e1s az iAqualinkhez" } } } diff --git a/homeassistant/components/icloud/translations/hu.json b/homeassistant/components/icloud/translations/hu.json index bb47cdd879b..722b3711e67 100644 --- a/homeassistant/components/icloud/translations/hu.json +++ b/homeassistant/components/icloud/translations/hu.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "no_device": "Egyik k\u00e9sz\u00fcl\u00e9ke sem aktiv\u00e1lta az \"iPhone keres\u00e9se\" funkci\u00f3t", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { @@ -14,6 +15,7 @@ "data": { "password": "Jelsz\u00f3" }, + "description": "A(z) {username} kor\u00e1bban megadott jelszava m\u00e1r nem m\u0171k\u00f6dik. Az integr\u00e1ci\u00f3 haszn\u00e1lat\u00e1hoz friss\u00edtse jelszav\u00e1t.", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" }, "trusted_device": { @@ -26,7 +28,8 @@ "user": { "data": { "password": "Jelsz\u00f3", - "username": "E-mail" + "username": "E-mail", + "with_family": "Csal\u00e1ddal" }, "description": "Adja meg hiteles\u00edt\u0151 adatait", "title": "iCloud hiteles\u00edt\u0151 adatok" diff --git a/homeassistant/components/insteon/translations/fa.json b/homeassistant/components/insteon/translations/fa.json new file mode 100644 index 00000000000..2456fbcba00 --- /dev/null +++ b/homeassistant/components/insteon/translations/fa.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0628\u0647 \u062f\u0631\u0633\u062a\u06cc \u062a\u0646\u0638\u06cc\u0645 \u0634\u062f\u0647 \u0627\u0633\u062a. \u062a\u0646\u0647\u0627 \u06cc\u06a9 \u062a\u0646\u0638\u06cc\u0645 \u0627\u0645\u06a9\u0627\u0646 \u067e\u0630\u06cc\u0631 \u0627\u0633\u062a." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/insteon/translations/hu.json b/homeassistant/components/insteon/translations/hu.json index 462fae3e1cb..8444aa97655 100644 --- a/homeassistant/components/insteon/translations/hu.json +++ b/homeassistant/components/insteon/translations/hu.json @@ -31,6 +31,7 @@ "data": { "device": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" }, + "description": "Konfigur\u00e1lja az Insteon PowerLink modemet (PLM).", "title": "Insteon PLM" }, "user": { @@ -44,16 +45,28 @@ }, "options": { "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "input_error": "\u00c9rv\u00e9nytelen bejegyz\u00e9sek, ellen\u0151rizze \u00e9rt\u00e9keket.", + "select_single": "V\u00e1lassz egy lehet\u0151s\u00e9get" }, "step": { "add_override": { + "data": { + "address": "Eszk\u00f6z c\u00edme (azaz 1a2b3c)", + "cat": "Eszk\u00f6zkateg\u00f3ria (azaz 0x10)", + "subcat": "Eszk\u00f6z alkateg\u00f3ria (azaz 0x0a)" + }, + "description": "Eszk\u00f6z-fel\u00fclb\u00edr\u00e1l\u00e1s hozz\u00e1ad\u00e1sa.", "title": "Insteon" }, "add_x10": { "data": { + "housecode": "H\u00e1zk\u00f3d (a - p)", + "platform": "Platform", + "steps": "F\u00e9nyer\u0151-szab\u00e1lyoz\u00e1si l\u00e9p\u00e9sek (csak k\u00f6nny\u0171 eszk\u00f6z\u00f6k eset\u00e9n, alap\u00e9rtelmezett 22)", "unitcode": "Egys\u00e9gk\u00f3d (1 - 16)" }, + "description": "M\u00f3dos\u00edtsa az Insteon Hub jelszav\u00e1t.", "title": "Insteon" }, "change_hub_config": { @@ -63,15 +76,25 @@ "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "M\u00f3dos\u00edtsa az Insteon Hub csatlakoz\u00e1si adatait. A m\u00f3dos\u00edt\u00e1s elv\u00e9gz\u00e9se ut\u00e1n \u00fajra kell ind\u00edtania a Home Assistant alkalmaz\u00e1st. Ez nem v\u00e1ltoztatja meg a Hub konfigur\u00e1ci\u00f3j\u00e1t. A Hub konfigur\u00e1ci\u00f3j\u00e1nak m\u00f3dos\u00edt\u00e1s\u00e1hoz haszn\u00e1lja a Hub alkalmaz\u00e1st.", "title": "Insteon" }, "init": { "data": { - "add_x10": "Adjon hozz\u00e1 egy X10 eszk\u00f6zt." + "add_override": "Eszk\u00f6z-fel\u00fclb\u00edr\u00e1l\u00e1s hozz\u00e1ad\u00e1sa.", + "add_x10": "Adjon hozz\u00e1 egy X10 eszk\u00f6zt.", + "change_hub_config": "M\u00f3dos\u00edtsa a Hub konfigur\u00e1ci\u00f3j\u00e1t.", + "remove_override": "Egy eszk\u00f6z fel\u00fclb\u00edr\u00e1lat\u00e1nak elt\u00e1vol\u00edt\u00e1sa.", + "remove_x10": "T\u00e1vol\u00edtson el egy X10 eszk\u00f6zt." }, + "description": "V\u00e1lasszon egy be\u00e1ll\u00edt\u00e1st.", "title": "Insteon" }, "remove_override": { + "data": { + "address": "V\u00e1lassza ki az elt\u00e1vol\u00edtani k\u00edv\u00e1nt eszk\u00f6z c\u00edm\u00e9t" + }, + "description": "T\u00e1vol\u00edtsa el az eszk\u00f6z fel\u00fclb\u00edr\u00e1l\u00e1s\u00e1t", "title": "Insteon" }, "remove_x10": { diff --git a/homeassistant/components/ipp/translations/hu.json b/homeassistant/components/ipp/translations/hu.json index 8c988eff551..a024cfb2e56 100644 --- a/homeassistant/components/ipp/translations/hu.json +++ b/homeassistant/components/ipp/translations/hu.json @@ -3,7 +3,11 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "connection_upgrade": "Nem siker\u00fclt csatlakozni a nyomtat\u00f3hoz, mert a kapcsolat friss\u00edt\u00e9se sz\u00fcks\u00e9ges." + "connection_upgrade": "Nem siker\u00fclt csatlakozni a nyomtat\u00f3hoz, mert a kapcsolat friss\u00edt\u00e9se sz\u00fcks\u00e9ges.", + "ipp_error": "IPP hiba t\u00f6rt\u00e9nt.", + "ipp_version_error": "A nyomtat\u00f3 nem t\u00e1mogatja az IPP verzi\u00f3t.", + "parse_error": "Nem siker\u00fclt elemezni a nyomtat\u00f3 v\u00e1lasz\u00e1t.", + "unique_id_required": "Az eszk\u00f6zb\u0151l hi\u00e1nyzik a felfedez\u00e9shez sz\u00fcks\u00e9ges egyedi azonos\u00edt\u00f3." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -13,14 +17,18 @@ "step": { "user": { "data": { + "base_path": "Relat\u00edv \u00fatvonal a nyomtat\u00f3hoz", "host": "Hoszt", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" - } + }, + "description": "\u00c1ll\u00edtsa be a nyomtat\u00f3t az Internet Printing Protocol (IPP) protokollon kereszt\u00fcl, hogy integr\u00e1lhat\u00f3 legyen a Home Assistant seg\u00edts\u00e9g\u00e9vel.", + "title": "Kapcsolja \u00f6ssze a nyomtat\u00f3t" }, "zeroconf_confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?" + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?", + "title": "Felfedezett nyomtat\u00f3" } } } diff --git a/homeassistant/components/iqvia/translations/hu.json b/homeassistant/components/iqvia/translations/hu.json index f5301e874ea..0ae420e47aa 100644 --- a/homeassistant/components/iqvia/translations/hu.json +++ b/homeassistant/components/iqvia/translations/hu.json @@ -2,6 +2,18 @@ "config": { "abort": { "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "invalid_zip_code": "Az ir\u00e1ny\u00edt\u00f3sz\u00e1m \u00e9rv\u00e9nytelen" + }, + "step": { + "user": { + "data": { + "zip_code": "Ir\u00e1ny\u00edt\u00f3sz\u00e1m" + }, + "description": "T\u00f6ltse ki amerikai vagy kanadai ir\u00e1ny\u00edt\u00f3sz\u00e1m\u00e1t.", + "title": "IQVIA" + } } } } \ No newline at end of file diff --git a/homeassistant/components/islamic_prayer_times/translations/hu.json b/homeassistant/components/islamic_prayer_times/translations/hu.json index 065747fb39d..5bad8174b9a 100644 --- a/homeassistant/components/islamic_prayer_times/translations/hu.json +++ b/homeassistant/components/islamic_prayer_times/translations/hu.json @@ -2,6 +2,22 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "step": { + "user": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani az iszl\u00e1m imaid\u0151ket?", + "title": "\u00c1ll\u00edtsa be az iszl\u00e1m imaid\u0151t" + } } - } + }, + "options": { + "step": { + "init": { + "data": { + "calculation_method": "Az ima sz\u00e1m\u00edt\u00e1si m\u00f3dszere" + } + } + } + }, + "title": "Iszl\u00e1m ima id\u0151k" } \ No newline at end of file diff --git a/homeassistant/components/isy994/translations/hu.json b/homeassistant/components/isy994/translations/hu.json index 065be706d0f..dab85300e6d 100644 --- a/homeassistant/components/isy994/translations/hu.json +++ b/homeassistant/components/isy994/translations/hu.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "invalid_host": "A gazdag\u00e9p bejegyz\u00e9se nem volt teljes URL-form\u00e1tumban, p\u00e9ld\u00e1ul: http://192.168.10.100:80", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "flow_title": "{name} ({host})", @@ -14,14 +15,24 @@ "data": { "host": "URL", "password": "Jelsz\u00f3", + "tls": "Az ISY vez\u00e9rl\u0151 TLS verzi\u00f3ja.", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A gazdag\u00e9p bejegyz\u00e9s\u00e9nek teljes URL form\u00e1tumban kell lennie, pl. Http://192.168.10.100:80", + "title": "Csatlakozzon az ISY994-hez" } } }, "options": { "step": { "init": { + "data": { + "ignore_string": "Figyelmen k\u00edv\u00fcl hagyja a karakterl\u00e1ncot", + "restore_light_state": "F\u00e9nyer\u0151 vissza\u00e1ll\u00edt\u00e1sa", + "sensor_string": "Csom\u00f3pont \u00e9rz\u00e9kel\u0151 karakterl\u00e1nc", + "variable_sensor_string": "V\u00e1ltoz\u00f3 \u00e9rz\u00e9kel\u0151 karakterl\u00e1nc" + }, + "description": "\u00c1ll\u00edtsa be az ISY integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sait:\n \u2022 Csom\u00f3pont -\u00e9rz\u00e9kel\u0151 karakterl\u00e1nc: B\u00e1rmely eszk\u00f6z vagy mappa, amelynek nev\u00e9ben \u201eNode Sensor String\u201d szerepel, \u00e9rz\u00e9kel\u0151k\u00e9nt vagy bin\u00e1ris \u00e9rz\u00e9kel\u0151k\u00e9nt fog kezelni.\n \u2022 Karakterl\u00e1nc figyelmen k\u00edv\u00fcl hagy\u00e1sa: Minden olyan eszk\u00f6z, amelynek a neve \u201eIgnore String\u201d, figyelmen k\u00edv\u00fcl marad.\n \u2022 V\u00e1ltoz\u00f3 \u00e9rz\u00e9kel\u0151 karakterl\u00e1nc: B\u00e1rmely v\u00e1ltoz\u00f3, amely tartalmazza a \u201eV\u00e1ltoz\u00f3 \u00e9rz\u00e9kel\u0151 karakterl\u00e1ncot\u201d, hozz\u00e1ad\u00f3dik \u00e9rz\u00e9kel\u0151k\u00e9nt.\n \u2022 F\u00e9nyer\u0151ss\u00e9g vissza\u00e1ll\u00edt\u00e1sa: Ha enged\u00e9lyezve van, akkor az el\u0151z\u0151 f\u00e9nyer\u0151 vissza\u00e1ll, amikor a k\u00e9sz\u00fcl\u00e9ket be\u00e9p\u00edtett On-Level helyett bekapcsolja.", "title": "ISY994 Be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/juicenet/translations/hu.json b/homeassistant/components/juicenet/translations/hu.json index f04a8c1e6ca..63e6086190b 100644 --- a/homeassistant/components/juicenet/translations/hu.json +++ b/homeassistant/components/juicenet/translations/hu.json @@ -13,6 +13,7 @@ "data": { "api_token": "API Token" }, + "description": "Sz\u00fcks\u00e9ge lesz az API Tokenre a https://home.juice.net/Manage webhelyen.", "title": "Csatlakoz\u00e1s a JuiceNethez" } } diff --git a/homeassistant/components/konnected/translations/hu.json b/homeassistant/components/konnected/translations/hu.json index 507e5d258f2..1ad58223b88 100644 --- a/homeassistant/components/konnected/translations/hu.json +++ b/homeassistant/components/konnected/translations/hu.json @@ -3,38 +3,107 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "not_konn_panel": "Nem felismert Konnected.io eszk\u00f6z", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { + "confirm": { + "description": "Modell: {model}\nAzonos\u00edt\u00f3: {id}\nGazdag\u00e9p: {host}\nPort: {port} \n\n Az IO \u00e9s a panel viselked\u00e9s\u00e9t a Konnected Alarm Panel be\u00e1ll\u00edt\u00e1saiban konfigur\u00e1lhatja.", + "title": "Konnected eszk\u00f6z k\u00e9sz" + }, + "import_confirm": { + "description": "A konfigur\u00e1ci\u00f3s.yaml f\u00e1jlban felfedezt\u00fcnk egy Konnected Alarm Panel-t {id} Ez a folyamat lehet\u0151v\u00e9 teszi, hogy import\u00e1lja azt egy konfigur\u00e1ci\u00f3s bejegyz\u00e9sbe.", + "title": "Konnected eszk\u00f6z import\u00e1l\u00e1sa" + }, "user": { "data": { "host": "IP c\u00edm", "port": "Port" - } + }, + "description": "K\u00e9rj\u00fck, adja meg a Konnected Panel gazdag\u00e9p\u00e9nek adatait." } } }, "options": { + "abort": { + "not_konn_panel": "Nem felismert Konnected.io eszk\u00f6z" + }, + "error": { + "bad_host": "\u00c9rv\u00e9nytelen fel\u00fclb\u00edr\u00e1l\u00e1si API host URL", + "one": "\u00dcres", + "other": "\u00dcres" + }, "step": { "options_binary": { "data": { - "name": "N\u00e9v (nem k\u00f6telez\u0151)" - } + "inverse": "Invert\u00e1lja a nyitott/z\u00e1rt \u00e1llapotot", + "name": "N\u00e9v (nem k\u00f6telez\u0151)", + "type": "Bin\u00e1ris \u00e9rz\u00e9kel\u0151 t\u00edpusa" + }, + "description": "{zone} opci\u00f3k", + "title": "Bin\u00e1ris \u00e9rz\u00e9kel\u0151 konfigur\u00e1l\u00e1sa" }, "options_digital": { "data": { "name": "N\u00e9v (nem k\u00f6telez\u0151)", "poll_interval": "Lek\u00e9rdez\u00e9si id\u0151k\u00f6z (perc) (opcion\u00e1lis)", "type": "\u00c9rz\u00e9kel\u0151 t\u00edpusa" - } + }, + "description": "{zone} opci\u00f3k", + "title": "Digit\u00e1lis \u00e9rz\u00e9kel\u0151 konfigur\u00e1l\u00e1sa" + }, + "options_io": { + "data": { + "1": "1. z\u00f3na", + "2": "2. z\u00f3na", + "3": "3. z\u00f3na", + "4": "4. z\u00f3na", + "5": "5. z\u00f3na", + "6": "6. z\u00f3na", + "7": "7. z\u00f3na", + "out": "KI" + }, + "description": "{model} felfedez\u00e9se {host}-n\u00e1l. V\u00e1lassza ki az al\u00e1bbi I/O alapkonfigur\u00e1ci\u00f3j\u00e1t - az I/O-t\u00f3l f\u00fcgg\u0151en lehet\u0151v\u00e9 teheti bin\u00e1ris \u00e9rz\u00e9kel\u0151k (nyitott/k\u00f6zeli \u00e9rintkez\u0151k), digit\u00e1lis \u00e9rz\u00e9kel\u0151k (dht \u00e9s ds18b20) vagy kapcsolhat\u00f3 kimenetek sz\u00e1m\u00e1ra. A r\u00e9szletes be\u00e1ll\u00edt\u00e1sokat a k\u00f6vetkez\u0151 l\u00e9p\u00e9sekben konfigur\u00e1lhatja.", + "title": "I/O konfigur\u00e1l\u00e1sa" + }, + "options_io_ext": { + "data": { + "10": "10. z\u00f3na", + "11": "11. z\u00f3na", + "12": "12. z\u00f3na", + "8": "8. z\u00f3na", + "9": "9. z\u00f3na", + "alarm1": "RIASZT\u00c1S1", + "alarm2_out2": "KI2/RIASZT\u00c1S2", + "out1": "KI1" + }, + "description": "V\u00e1lassza ki a fennmarad\u00f3 I/O konfigur\u00e1ci\u00f3j\u00e1t al\u00e1bb. A k\u00f6vetkez\u0151 l\u00e9p\u00e9sekben konfigur\u00e1lhatja a r\u00e9szletes be\u00e1ll\u00edt\u00e1sokat.", + "title": "B\u0151v\u00edtett I/O konfigur\u00e1l\u00e1sa" + }, + "options_misc": { + "data": { + "api_host": "API host URL fel\u00fclb\u00edr\u00e1l\u00e1sa (opcion\u00e1lis)", + "blink": "A panel LED villog\u00e1sa \u00e1llapotv\u00e1ltoz\u00e1skor", + "discovery": "V\u00e1laszoljon a h\u00e1l\u00f3zaton \u00e9rkez\u0151 felder\u00edt\u00e9si k\u00e9r\u00e9sekre", + "override_api_host": "Az alap\u00e9rtelmezett Home Assistant API gazdag\u00e9p-URL fel\u00fcl\u00edr\u00e1sa" + }, + "description": "K\u00e9rj\u00fck, v\u00e1lassza ki a k\u00edv\u00e1nt viselked\u00e9st a panelhez", + "title": "Egy\u00e9b be\u00e1ll\u00edt\u00e1sa" }, "options_switch": { "data": { - "name": "N\u00e9v (nem k\u00f6telez\u0151)" - } + "activation": "Kimenet bekapcsolt \u00e1llapotban", + "momentary": "Impulzus id\u0151tartama (ms) (opcion\u00e1lis)", + "more_states": "Tov\u00e1bbi \u00e1llapotok konfigur\u00e1l\u00e1sa ehhez a z\u00f3n\u00e1hoz", + "name": "N\u00e9v (nem k\u00f6telez\u0151)", + "pause": "Sz\u00fcnet impulzusok k\u00f6z\u00f6tt (ms) (opcion\u00e1lis)", + "repeat": "Ism\u00e9tl\u00e9si id\u0151k (-1 = v\u00e9gtelen) (opcion\u00e1lis)" + }, + "description": "{zone} opci\u00f3k: \u00e1llapot {state}", + "title": "Kapcsolhat\u00f3 kimenet konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/kraken/translations/nl.json b/homeassistant/components/kraken/translations/nl.json index 7de89d6b2dc..25fe63bebd5 100644 --- a/homeassistant/components/kraken/translations/nl.json +++ b/homeassistant/components/kraken/translations/nl.json @@ -9,10 +9,6 @@ }, "step": { "user": { - "data": { - "one": "Leeg", - "other": "Leeg" - }, "description": "Wil je beginnen met instellen?" } } diff --git a/homeassistant/components/life360/translations/hu.json b/homeassistant/components/life360/translations/hu.json index 603efee6d9d..5dbd2898971 100644 --- a/homeassistant/components/life360/translations/hu.json +++ b/homeassistant/components/life360/translations/hu.json @@ -18,7 +18,9 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A speci\u00e1lis be\u00e1ll\u00edt\u00e1sok megad\u00e1s\u00e1hoz l\u00e1sd a [Life360 dokument\u00e1ci\u00f3]({docs_url}) c\u00edm\u0171 r\u00e9szt.\n \u00c9rdemes ezt megtenni a fi\u00f3kok hozz\u00e1ad\u00e1sa el\u0151tt.", + "title": "Life360 fi\u00f3kadatok" } } } diff --git a/homeassistant/components/litejet/translations/hu.json b/homeassistant/components/litejet/translations/hu.json index 3ee53c086bf..910d34cdc1a 100644 --- a/homeassistant/components/litejet/translations/hu.json +++ b/homeassistant/components/litejet/translations/hu.json @@ -15,5 +15,15 @@ "title": "Csatlakoz\u00e1s a LiteJet-hez" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Alap\u00e9rtelmezett \u00e1tmenet (m\u00e1sodperc)" + }, + "title": "A LiteJet konfigur\u00e1l\u00e1sa" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/logi_circle/translations/hu.json b/homeassistant/components/logi_circle/translations/hu.json index 9c788350de4..73522a59519 100644 --- a/homeassistant/components/logi_circle/translations/hu.json +++ b/homeassistant/components/logi_circle/translations/hu.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "external_error": "Kiv\u00e9tel t\u00f6rt\u00e9nt egy m\u00e1sik folyamatb\u00f3l.", + "external_setup": "LogiCircle sikeresen konfigur\u00e1lva egy m\u00e1sik folyamatb\u00f3l.", "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t." }, "error": { @@ -10,10 +12,15 @@ "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, "step": { + "auth": { + "description": "K\u00e9rj\u00fck, k\u00f6vesse az al\u00e1bbi linket, \u00e9s ** Fogadja el ** a LogiCircle -fi\u00f3kj\u00e1hoz val\u00f3 hozz\u00e1f\u00e9r\u00e9st, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot. \n\n [Link] ({authorization_url})", + "title": "Hiteles\u00edt\u00e9s a LogiCircle seg\u00edts\u00e9g\u00e9vel" + }, "user": { "data": { "flow_impl": "Szolg\u00e1ltat\u00f3" }, + "description": "V\u00e1lassza ki, melyik hiteles\u00edt\u00e9si szolg\u00e1ltat\u00f3n kereszt\u00fcl szeretn\u00e9 hiteles\u00edteni a LogiCircle szolg\u00e1ltat\u00e1st.", "title": "Hiteles\u00edt\u00e9si Szolg\u00e1ltat\u00f3" } } diff --git a/homeassistant/components/lutron_caseta/translations/hu.json b/homeassistant/components/lutron_caseta/translations/hu.json index 905fc05bf8e..0e8960530e3 100644 --- a/homeassistant/components/lutron_caseta/translations/hu.json +++ b/homeassistant/components/lutron_caseta/translations/hu.json @@ -10,6 +10,10 @@ }, "flow_title": "{name} ({host})", "step": { + "import_failed": { + "description": "Nem siker\u00fclt be\u00e1ll\u00edtani a bridge-t ({host}) a configuration.yaml f\u00e1jlb\u00f3l import\u00e1lva.", + "title": "Nem siker\u00fclt import\u00e1lni a Cas\u00e9ta h\u00edd konfigur\u00e1ci\u00f3j\u00e1t." + }, "link": { "description": "A(z) {name} {host} p\u00e1ros\u00edt\u00e1s\u00e1hoz az \u0171rlap elk\u00fcld\u00e9se ut\u00e1n nyomja meg a h\u00edd h\u00e1tulj\u00e1n tal\u00e1lhat\u00f3 fekete gombot.", "title": "P\u00e1ros\u00edtsd a h\u00edddal" diff --git a/homeassistant/components/melcloud/translations/hu.json b/homeassistant/components/melcloud/translations/hu.json index 7f81269c700..5744b71c780 100644 --- a/homeassistant/components/melcloud/translations/hu.json +++ b/homeassistant/components/melcloud/translations/hu.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "A MELCloud integr\u00e1ci\u00f3 m\u00e1r be van \u00e1ll\u00edtva ehhez az e-mailhez. A hozz\u00e1f\u00e9r\u00e9si token friss\u00edtve lett." + }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", @@ -10,7 +13,9 @@ "data": { "password": "Jelsz\u00f3", "username": "E-mail" - } + }, + "description": "Csatlakozzon a MELCloud-fi\u00f3kj\u00e1val.", + "title": "Csatlakozzon a MELCloudhoz" } } } diff --git a/homeassistant/components/minecraft_server/translations/hu.json b/homeassistant/components/minecraft_server/translations/hu.json index 247c1ffc1c3..ef3c228d2d5 100644 --- a/homeassistant/components/minecraft_server/translations/hu.json +++ b/homeassistant/components/minecraft_server/translations/hu.json @@ -4,7 +4,9 @@ "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" }, "error": { - "cannot_connect": "Nem siker\u00fclt csatlakozni a szerverhez. K\u00e9rj\u00fck, ellen\u0151rizze a gazdag\u00e9pet \u00e9s a portot, majd pr\u00f3b\u00e1lkozzon \u00fajra. Gondoskodjon arr\u00f3l, hogy a szerveren legal\u00e1bb a Minecraft 1.7-es verzi\u00f3j\u00e1t futtassa." + "cannot_connect": "Nem siker\u00fclt csatlakozni a szerverhez. K\u00e9rj\u00fck, ellen\u0151rizze a gazdag\u00e9pet \u00e9s a portot, majd pr\u00f3b\u00e1lkozzon \u00fajra. Gondoskodjon arr\u00f3l, hogy a szerveren legal\u00e1bb a Minecraft 1.7-es verzi\u00f3j\u00e1t futtassa.", + "invalid_ip": "Az IP -c\u00edm \u00e9rv\u00e9nytelen (a MAC -c\u00edmet nem siker\u00fclt meghat\u00e1rozni). K\u00e9rj\u00fck, jav\u00edtsa ki, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", + "invalid_port": "A portnak 1024 \u00e9s 65535 k\u00f6z\u00f6tt kell lennie. K\u00e9rj\u00fck, jav\u00edtsa ki, \u00e9s pr\u00f3b\u00e1lja \u00fajra." }, "step": { "user": { @@ -12,6 +14,7 @@ "host": "Hoszt", "name": "N\u00e9v" }, + "description": "\u00c1ll\u00edtsa be a Minecraft Server p\u00e9ld\u00e1nyt, hogy lehet\u0151v\u00e9 tegye a megfigyel\u00e9st.", "title": "Kapcsold \u00f6ssze a Minecraft szervered" } } diff --git a/homeassistant/components/monoprice/translations/hu.json b/homeassistant/components/monoprice/translations/hu.json index a845f862160..fd11a8fbc0f 100644 --- a/homeassistant/components/monoprice/translations/hu.json +++ b/homeassistant/components/monoprice/translations/hu.json @@ -10,8 +10,30 @@ "step": { "user": { "data": { - "port": "Port" - } + "port": "Port", + "source_1": "Forr\u00e1s neve #1", + "source_2": "Forr\u00e1s neve #2", + "source_3": "Forr\u00e1s neve #3", + "source_4": "Forr\u00e1s neve #4", + "source_5": "Forr\u00e1s neve #5", + "source_6": "Forr\u00e1s neve #6" + }, + "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "source_1": "Forr\u00e1s neve #1", + "source_2": "Forr\u00e1s neve #2", + "source_3": "Forr\u00e1s neve #3", + "source_4": "Forr\u00e1s neve #4", + "source_5": "Forr\u00e1s neve #5", + "source_6": "Forr\u00e1s neve #6" + }, + "title": "Forr\u00e1sok konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/mqtt/translations/hu.json b/homeassistant/components/mqtt/translations/hu.json index 84c4a40f082..a519cab55d3 100644 --- a/homeassistant/components/mqtt/translations/hu.json +++ b/homeassistant/components/mqtt/translations/hu.json @@ -50,6 +50,8 @@ }, "options": { "error": { + "bad_birth": "\u00c9rv\u00e9nytelen sz\u00fclet\u00e9si t\u00e9ma.", + "bad_will": "\u00c9rv\u00e9nytelen t\u00e9ma.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { @@ -59,9 +61,25 @@ "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "K\u00e9rlek, add meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait.", + "title": "Br\u00f3ker opci\u00f3k" }, "options": { + "data": { + "birth_enable": "Sz\u00fclet\u00e9si \u00fczenet enged\u00e9lyez\u00e9se", + "birth_payload": "Sz\u00fclet\u00e9si \u00fczenet", + "birth_qos": "Sz\u00fclet\u00e9si \u00fczenet QoS", + "birth_retain": "A sz\u00fclet\u00e9si \u00fczenet meg\u0151rz\u00e9se", + "birth_topic": "Sz\u00fclet\u00e9si \u00fczenet t\u00e9m\u00e1ja", + "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se", + "will_enable": "Enged\u00e9lyez\u00e9si \u00fczenet", + "will_payload": "\u00dczenet", + "will_qos": "QoS \u00fczenet", + "will_retain": "\u00dczenet megtart\u00e1sa", + "will_topic": "\u00dczenet t\u00e9m\u00e1ja" + }, + "description": "Felfedez\u00e9s - Ha a felfedez\u00e9s enged\u00e9lyezve van (aj\u00e1nlott), a Home Assistant automatikusan felfedezi azokat az eszk\u00f6z\u00f6ket \u00e9s entit\u00e1sokat, amelyek k\u00f6zz\u00e9teszik konfigur\u00e1ci\u00f3jukat az MQTT br\u00f3keren. Ha a felfedez\u00e9s le van tiltva, minden konfigur\u00e1ci\u00f3t manu\u00e1lisan kell elv\u00e9gezni.\nSz\u00fclet\u00e9si \u00fczenet - A sz\u00fclet\u00e9si \u00fczenetet minden alkalommal elk\u00fcldi, amikor a Home Assistant (\u00fajra) csatlakozik az MQTT br\u00f3kerhez.\nAkarat \u00fczenet - Az akarat\u00fczenet minden alkalommal el lesz k\u00fcldve, amikor a Home Assistant elvesz\u00edti a kapcsolatot a k\u00f6zvet\u00edt\u0151vel, mind takar\u00edt\u00e1s eset\u00e9n (pl. A Home Assistant le\u00e1ll\u00edt\u00e1sa), mind tiszt\u00e1talans\u00e1g eset\u00e9n (pl. Home Assistant \u00f6sszeomlik vagy megszakad a h\u00e1l\u00f3zati kapcsolata) bontani.", "title": "MQTT opci\u00f3k" } } diff --git a/homeassistant/components/myq/translations/hu.json b/homeassistant/components/myq/translations/hu.json index 59338cf43ae..f50099f023b 100644 --- a/homeassistant/components/myq/translations/hu.json +++ b/homeassistant/components/myq/translations/hu.json @@ -21,7 +21,8 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon a MyQ Gateway-hez" } } } diff --git a/homeassistant/components/netatmo/translations/hu.json b/homeassistant/components/netatmo/translations/hu.json index 0e6536bb0ad..48f084f84c2 100644 --- a/homeassistant/components/netatmo/translations/hu.json +++ b/homeassistant/components/netatmo/translations/hu.json @@ -41,14 +41,24 @@ "step": { "public_weather": { "data": { - "area_name": "A ter\u00fclet neve" - } + "area_name": "A ter\u00fclet neve", + "lat_ne": "Sz\u00e9less\u00e9g \u00c9szakkeleti sarok", + "lat_sw": "Sz\u00e9less\u00e9g D\u00e9lnyugati sarok", + "lon_ne": "Hossz\u00fas\u00e1g \u00c9szakkeleti sarok", + "lon_sw": "Hossz\u00fas\u00e1g D\u00e9lnyugati sarok", + "mode": "Sz\u00e1m\u00edt\u00e1s", + "show_on_map": "Mutasd a t\u00e9rk\u00e9pen" + }, + "description": "\u00c1ll\u00edtson be egy nyilv\u00e1nos id\u0151j\u00e1r\u00e1s-\u00e9rz\u00e9kel\u0151t egy ter\u00fclethez.", + "title": "Netatmo nyilv\u00e1nos id\u0151j\u00e1r\u00e1s-\u00e9rz\u00e9kel\u0151" }, "public_weather_areas": { "data": { "new_area": "Ter\u00fclet neve", "weather_areas": "Id\u0151j\u00e1r\u00e1si ter\u00fcletek" - } + }, + "description": "\u00c1ll\u00edtsa be a nyilv\u00e1nos id\u0151j\u00e1r\u00e1s-\u00e9rz\u00e9kel\u0151ket.", + "title": "Netatmo nyilv\u00e1nos id\u0151j\u00e1r\u00e1s-\u00e9rz\u00e9kel\u0151" } } } diff --git a/homeassistant/components/nfandroidtv/translations/hu.json b/homeassistant/components/nfandroidtv/translations/hu.json new file mode 100644 index 00000000000..e7dea95e4d0 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "host": "H\u00e1zigazda", + "name": "N\u00e9v" + }, + "description": "Ehhez az integr\u00e1ci\u00f3hoz az \u00c9rtes\u00edt\u00e9sek az Android TV alkalmaz\u00e1shoz sz\u00fcks\u00e9ges. \n\nAndroid TV eset\u00e9n: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nA Fire TV eset\u00e9ben: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\nBe kell \u00e1ll\u00edtania a DHCP -foglal\u00e1st az \u00fatv\u00e1laszt\u00f3n (l\u00e1sd az \u00fatv\u00e1laszt\u00f3 felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t), vagy egy statikus IP -c\u00edmet az eszk\u00f6z\u00f6n. Ha nem, az eszk\u00f6z v\u00e9g\u00fcl el\u00e9rhetetlenn\u00e9 v\u00e1lik.", + "title": "\u00c9rtes\u00edt\u00e9sek Android TV / Fire TV eset\u00e9n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuheat/translations/hu.json b/homeassistant/components/nuheat/translations/hu.json index e6e7174e325..873b03cebff 100644 --- a/homeassistant/components/nuheat/translations/hu.json +++ b/homeassistant/components/nuheat/translations/hu.json @@ -15,7 +15,9 @@ "password": "Jelsz\u00f3", "serial_number": "A termoszt\u00e1t sorozatsz\u00e1ma.", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A termoszt\u00e1t numerikus sorozatsz\u00e1m\u00e1t vagy azonos\u00edt\u00f3j\u00e1t meg kell szereznie, ha bejelentkezik a https://MyNuHeat.com oldalra, \u00e9s kiv\u00e1lasztja a termoszt\u00e1tot.", + "title": "Csatlakozzon a NuHeat-hez" } } } diff --git a/homeassistant/components/nut/translations/hu.json b/homeassistant/components/nut/translations/hu.json index a7bad455dc3..bfc8e01c11a 100644 --- a/homeassistant/components/nut/translations/hu.json +++ b/homeassistant/components/nut/translations/hu.json @@ -8,13 +8,27 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "resources": { + "data": { + "resources": "Forr\u00e1sok" + }, + "title": "V\u00e1lassza ki a nyomon k\u00f6vetend\u0151 er\u0151forr\u00e1sokat" + }, + "ups": { + "data": { + "alias": "\u00c1ln\u00e9v", + "resources": "Forr\u00e1sok" + }, + "title": "V\u00e1lassza ki a fel\u00fcgyelni k\u00edv\u00e1nt UPS-t" + }, "user": { "data": { "host": "Hoszt", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon a NUT szerverhez" } } }, @@ -22,6 +36,15 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "init": { + "data": { + "resources": "Forr\u00e1sok", + "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)" + }, + "description": "V\u00e1lassza az \u00c9rz\u00e9kel\u0151 er\u0151forr\u00e1sokat." + } } } } \ No newline at end of file diff --git a/homeassistant/components/nws/translations/hu.json b/homeassistant/components/nws/translations/hu.json index 1d674cacc7e..ec9bf3f4988 100644 --- a/homeassistant/components/nws/translations/hu.json +++ b/homeassistant/components/nws/translations/hu.json @@ -12,8 +12,11 @@ "data": { "api_key": "API kulcs", "latitude": "Sz\u00e9less\u00e9g", - "longitude": "Hossz\u00fas\u00e1g" - } + "longitude": "Hossz\u00fas\u00e1g", + "station": "METAR \u00e1llom\u00e1s k\u00f3dja" + }, + "description": "Ha a METAR \u00e1llom\u00e1s k\u00f3dja nincs megadva, a sz\u00e9less\u00e9gi \u00e9s hossz\u00fas\u00e1gi fokokat haszn\u00e1lja a legk\u00f6zelebbi \u00e1llom\u00e1s megkeres\u00e9s\u00e9hez. Egyel\u0151re az API-kulcs b\u00e1rmi lehet. Javasoljuk, hogy \u00e9rv\u00e9nyes e -mail c\u00edmet haszn\u00e1ljon.", + "title": "Csatlakozzon az National Weather Service-hez" } } } diff --git a/homeassistant/components/onvif/translations/hu.json b/homeassistant/components/onvif/translations/hu.json index e2b63a6c9d8..c43df53ae9f 100644 --- a/homeassistant/components/onvif/translations/hu.json +++ b/homeassistant/components/onvif/translations/hu.json @@ -53,7 +53,19 @@ "data": { "auto": "Automatikus keres\u00e9s" }, - "description": "A k\u00fcld\u00e9s gombra kattintva olyan ONVIF-eszk\u00f6z\u00f6ket keres\u00fcnk a h\u00e1l\u00f3zat\u00e1ban, amelyek t\u00e1mogatj\u00e1k az S profilt.\n\nEgyes gy\u00e1rt\u00f3k alap\u00e9rtelmez\u00e9s szerint elkezdt\u00e9k letiltani az ONVIF-et. Ellen\u0151rizze, hogy az ONVIF enged\u00e9lyezve van-e a kamera konfigur\u00e1ci\u00f3j\u00e1ban." + "description": "A k\u00fcld\u00e9s gombra kattintva olyan ONVIF-eszk\u00f6z\u00f6ket keres\u00fcnk a h\u00e1l\u00f3zat\u00e1ban, amelyek t\u00e1mogatj\u00e1k az S profilt.\n\nEgyes gy\u00e1rt\u00f3k alap\u00e9rtelmez\u00e9s szerint elkezdt\u00e9k letiltani az ONVIF-et. Ellen\u0151rizze, hogy az ONVIF enged\u00e9lyezve van-e a kamera konfigur\u00e1ci\u00f3j\u00e1ban.", + "title": "ONVIF eszk\u00f6z be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "onvif_devices": { + "data": { + "extra_arguments": "Extra FFMPEG opci\u00f3k", + "rtsp_transport": "RTSP sz\u00e1ll\u00edt\u00e1si mechanizmus" + }, + "title": "ONVIF eszk\u00f6z opci\u00f3i" } } } diff --git a/homeassistant/components/opentherm_gw/translations/hu.json b/homeassistant/components/opentherm_gw/translations/hu.json index 77112bd8929..3127dc523ce 100644 --- a/homeassistant/components/opentherm_gw/translations/hu.json +++ b/homeassistant/components/opentherm_gw/translations/hu.json @@ -24,7 +24,8 @@ "read_precision": "Pontoss\u00e1g olvas\u00e1sa", "set_precision": "Pontoss\u00e1g be\u00e1ll\u00edt\u00e1sa", "temporary_override_mode": "Ideiglenes be\u00e1ll\u00edt\u00e1s fel\u00fclb\u00edr\u00e1l\u00e1si m\u00f3dja" - } + }, + "description": "Opci\u00f3k az OpenTherm \u00e1tj\u00e1r\u00f3hoz" } } } diff --git a/homeassistant/components/ovo_energy/translations/hu.json b/homeassistant/components/ovo_energy/translations/hu.json index 143d1a8dc18..2c794b5cd9d 100644 --- a/homeassistant/components/ovo_energy/translations/hu.json +++ b/homeassistant/components/ovo_energy/translations/hu.json @@ -19,6 +19,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "\u00c1ll\u00edtson be egy OVO Energy p\u00e9ld\u00e1nyt az energiafelhaszn\u00e1l\u00e1s el\u00e9r\u00e9s\u00e9hez.", "title": "OVO Energy azonos\u00edt\u00f3 megad\u00e1sa" } } diff --git a/homeassistant/components/ozw/translations/hu.json b/homeassistant/components/ozw/translations/hu.json index 70934bf3472..a43f234c909 100644 --- a/homeassistant/components/ozw/translations/hu.json +++ b/homeassistant/components/ozw/translations/hu.json @@ -6,6 +6,7 @@ "addon_set_config_failed": "Nem siker\u00fclt be\u00e1ll\u00edtani az OpenZWave konfigur\u00e1ci\u00f3t.", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "mqtt_required": "Az MQTT integr\u00e1ci\u00f3 nincs be\u00e1ll\u00edtva", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "error": { diff --git a/homeassistant/components/panasonic_viera/translations/hu.json b/homeassistant/components/panasonic_viera/translations/hu.json index cfc0be387d0..df520bb1ca5 100644 --- a/homeassistant/components/panasonic_viera/translations/hu.json +++ b/homeassistant/components/panasonic_viera/translations/hu.json @@ -22,7 +22,8 @@ "host": "IP c\u00edm", "name": "N\u00e9v" }, - "description": "Add meg a Panasonic Viera TV-hez tartoz\u00f3 IP c\u00edmet" + "description": "Add meg a Panasonic Viera TV-hez tartoz\u00f3 IP c\u00edmet", + "title": "A TV be\u00e1ll\u00edt\u00e1sa" } } } diff --git a/homeassistant/components/plex/translations/hu.json b/homeassistant/components/plex/translations/hu.json index 9168f070609..c0ecbe3e02c 100644 --- a/homeassistant/components/plex/translations/hu.json +++ b/homeassistant/components/plex/translations/hu.json @@ -10,8 +10,10 @@ }, "error": { "faulty_credentials": "A hiteles\u00edt\u00e9s sikertelen", + "host_or_token": "Legal\u00e1bb egyet kell megadnia a Gazdag\u00e9p vagy a Token k\u00f6z\u00fcl", "no_servers": "Nincs szerver csatlakoztatva a fi\u00f3khoz", - "not_found": "A Plex szerver nem tal\u00e1lhat\u00f3" + "not_found": "A Plex szerver nem tal\u00e1lhat\u00f3", + "ssl_error": "SSL tan\u00fas\u00edtv\u00e1ny probl\u00e9ma" }, "flow_title": "{name} ({host})", "step": { @@ -22,7 +24,8 @@ "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "token": "Token (opcion\u00e1lis)", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" - } + }, + "title": "K\u00e9zi Plex konfigur\u00e1ci\u00f3" }, "select_server": { "data": { @@ -32,9 +35,13 @@ "title": "Plex-kiszolg\u00e1l\u00f3 kiv\u00e1laszt\u00e1sa" }, "user": { + "description": "Folytassa a [plex.tv] (https://plex.tv) oldalt a Plex szerver \u00f6sszekapcsol\u00e1s\u00e1hoz.", "title": "Plex Media Server" }, "user_advanced": { + "data": { + "setup_method": "Be\u00e1ll\u00edt\u00e1si m\u00f3dszer" + }, "title": "Plex Media Server" } } @@ -43,7 +50,9 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Hagyja figyelmen k\u00edv\u00fcl az \u00faj kezelt/megosztott felhaszn\u00e1l\u00f3kat", "ignore_plex_web_clients": "Plex Web kliensek figyelmen k\u00edv\u00fcl hagy\u00e1sa", + "monitored_users": "Megfigyelt felhaszn\u00e1l\u00f3k", "use_episode_art": "Haszn\u00e1lja az epiz\u00f3d bor\u00edt\u00f3j\u00e1t" }, "description": "Plex media lej\u00e1tsz\u00f3k be\u00e1ll\u00edt\u00e1sai" diff --git a/homeassistant/components/powerwall/translations/hu.json b/homeassistant/components/powerwall/translations/hu.json index 9f12342595a..1102ba78673 100644 --- a/homeassistant/components/powerwall/translations/hu.json +++ b/homeassistant/components/powerwall/translations/hu.json @@ -7,7 +7,8 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", + "wrong_version": "Az powerwall nem t\u00e1mogatott szoftververzi\u00f3t haszn\u00e1l. K\u00e9rj\u00fck, fontolja meg a probl\u00e9ma friss\u00edt\u00e9s\u00e9t vagy jelent\u00e9s\u00e9t, hogy megoldhat\u00f3 legyen." }, "flow_title": "{ip_address}", "step": { @@ -15,7 +16,8 @@ "data": { "ip_address": "IP c\u00edm", "password": "Jelsz\u00f3" - } + }, + "title": "Csatlakoz\u00e1s a powerwallhoz" } } } diff --git a/homeassistant/components/prosegur/translations/hu.json b/homeassistant/components/prosegur/translations/hu.json new file mode 100644 index 00000000000..143ae78d534 --- /dev/null +++ b/homeassistant/components/prosegur/translations/hu.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Hiteles\u00edtse \u00fajra Prosegur-fi\u00f3kkal.", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + }, + "user": { + "data": { + "country": "Orsz\u00e1g", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/hu.json b/homeassistant/components/pvpc_hourly_pricing/translations/hu.json index 0b980bd58e0..1f706862ee1 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/hu.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/hu.json @@ -6,10 +6,13 @@ "step": { "user": { "data": { + "name": "\u00c9rz\u00e9kel\u0151 neve", "power": "Szerz\u0151d\u00e9s szerinti teljes\u00edtm\u00e9ny (kW)", - "power_p3": "Szerz\u0151d\u00f6tt teljes\u00edtm\u00e9ny P3 v\u00f6lgyid\u0151szakra (kW)" + "power_p3": "Szerz\u0151d\u00f6tt teljes\u00edtm\u00e9ny P3 v\u00f6lgyid\u0151szakra (kW)", + "tariff": "Alkalmazand\u00f3 tarifa f\u00f6ldrajzi z\u00f3n\u00e1nk\u00e9nt" }, - "description": "Ez az \u00e9rz\u00e9kel\u0151 a hivatalos API-t haszn\u00e1lja a [villamos energia \u00f3r\u00e1nk\u00e9nti \u00e1raz\u00e1s\u00e1nak (PVPC)] (https://www.esios.ree.es/es/pvpc) megszerz\u00e9s\u00e9hez Spanyolorsz\u00e1gban.\n Pontosabb magyar\u00e1zat\u00e9rt keresse fel az [integr\u00e1ci\u00f3s dokumentumok] oldalt (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/)." + "description": "Ez az \u00e9rz\u00e9kel\u0151 a hivatalos API-t haszn\u00e1lja a [villamos energia \u00f3r\u00e1nk\u00e9nti \u00e1raz\u00e1s\u00e1nak (PVPC)] (https://www.esios.ree.es/es/pvpc) megszerz\u00e9s\u00e9hez Spanyolorsz\u00e1gban.\n Pontosabb magyar\u00e1zat\u00e9rt keresse fel az [integr\u00e1ci\u00f3s dokumentumok] oldalt (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "title": "\u00c9rz\u00e9kel\u0151 be\u00e1ll\u00edt\u00e1sa" } } }, diff --git a/homeassistant/components/rachio/translations/hu.json b/homeassistant/components/rachio/translations/hu.json index 570dd27b5d9..0c6112988d8 100644 --- a/homeassistant/components/rachio/translations/hu.json +++ b/homeassistant/components/rachio/translations/hu.json @@ -12,6 +12,17 @@ "user": { "data": { "api_key": "API kulcs" + }, + "description": "Sz\u00fcks\u00e9ge lesz az API-kulcsra a https://app.rach.io/ webhelyen. L\u00e9pjen a Be\u00e1ll\u00edt\u00e1sok elemre, majd kattintson az \u201eAPI KEY GET\u201d lek\u00e9r\u00e9s\u00e9re.", + "title": "Csatlakozzon a Rachio k\u00e9sz\u00fcl\u00e9khez" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "manual_run_mins": "A fut\u00e1s id\u0151tartama percben a z\u00f3nakapcsol\u00f3 aktiv\u00e1l\u00e1sakor" } } } diff --git a/homeassistant/components/renault/translations/he.json b/homeassistant/components/renault/translations/he.json index d20e2d36a81..25cec1032e9 100644 --- a/homeassistant/components/renault/translations/he.json +++ b/homeassistant/components/renault/translations/he.json @@ -11,7 +11,8 @@ "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "username": "\u05d3\u05d5\u05d0\"\u05dc" - } + }, + "title": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05e8\u05e0\u05d5" } } } diff --git a/homeassistant/components/renault/translations/hu.json b/homeassistant/components/renault/translations/hu.json new file mode 100644 index 00000000000..eeace0b9b85 --- /dev/null +++ b/homeassistant/components/renault/translations/hu.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "kamereon_no_account": "Nem tal\u00e1lhat\u00f3 a Kamereon-fi\u00f3k." + }, + "error": { + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon-fi\u00f3k azonos\u00edt\u00f3ja" + }, + "title": "V\u00e1lassza ki a Kamereon-fi\u00f3k azonos\u00edt\u00f3j\u00e1t" + }, + "user": { + "data": { + "locale": "Helysz\u00edn", + "password": "Jelsz\u00f3", + "username": "Email" + }, + "title": "\u00c1ll\u00edtsa be a Renault hiteles\u00edt\u0151 adatait" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roku/translations/hu.json b/homeassistant/components/roku/translations/hu.json index 5485d9e00ce..b7aa12bfb4d 100644 --- a/homeassistant/components/roku/translations/hu.json +++ b/homeassistant/components/roku/translations/hu.json @@ -19,13 +19,18 @@ "title": "Roku" }, "ssdp_confirm": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + }, "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?", "title": "Roku" }, "user": { "data": { "host": "Hoszt" - } + }, + "description": "Adja meg Roku adatait." } } } diff --git a/homeassistant/components/roomba/translations/hu.json b/homeassistant/components/roomba/translations/hu.json index 2f8d902f4fe..0d76ce920b2 100644 --- a/homeassistant/components/roomba/translations/hu.json +++ b/homeassistant/components/roomba/translations/hu.json @@ -40,10 +40,12 @@ "user": { "data": { "blid": "BLID", + "continuous": "Folyamatos", "delay": "K\u00e9sleltet\u00e9s", "host": "Hoszt", "password": "Jelsz\u00f3" }, + "description": "V\u00e1lasszon Roomba-t vagy Braava-t.", "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" } } diff --git a/homeassistant/components/roon/translations/hu.json b/homeassistant/components/roon/translations/hu.json index 123027a8216..56a8ade165c 100644 --- a/homeassistant/components/roon/translations/hu.json +++ b/homeassistant/components/roon/translations/hu.json @@ -9,12 +9,14 @@ }, "step": { "link": { + "description": "Enged\u00e9lyeznie kell az HomeAssistantot a Roonban. Miut\u00e1n r\u00e1kattintott a K\u00fcld\u00e9s gombra, nyissa meg a Roon Core alkalmaz\u00e1st, nyissa meg a Be\u00e1ll\u00edt\u00e1sokat, \u00e9s enged\u00e9lyezze a HomeAssistant funkci\u00f3t a B\u0151v\u00edtm\u00e9nyek lapon.", "title": "Enged\u00e9lyezze a HomeAssistant alkalmaz\u00e1st Roon-ban" }, "user": { "data": { "host": "Hoszt" - } + }, + "description": "Nem tal\u00e1lta a Roon szervert, adja meg a gazdag\u00e9p nev\u00e9t vagy IP-c\u00edm\u00e9t." } } } diff --git a/homeassistant/components/sense/translations/hu.json b/homeassistant/components/sense/translations/hu.json index 4ecaf2ba0d0..acd67b9e6f9 100644 --- a/homeassistant/components/sense/translations/hu.json +++ b/homeassistant/components/sense/translations/hu.json @@ -13,7 +13,8 @@ "data": { "email": "E-mail", "password": "Jelsz\u00f3" - } + }, + "title": "Csatlakoztassa a Sense Energy Monitort" } } } diff --git a/homeassistant/components/sentry/translations/hu.json b/homeassistant/components/sentry/translations/hu.json index 79188df18b1..43404f72495 100644 --- a/homeassistant/components/sentry/translations/hu.json +++ b/homeassistant/components/sentry/translations/hu.json @@ -25,7 +25,8 @@ "event_custom_components": "Esem\u00e9nyek k\u00fcld\u00e9se egy\u00e9ni \u00f6sszetev\u0151kb\u0151l", "event_handled": "K\u00fcldj\u00f6n kezelt esem\u00e9nyeket", "event_third_party_packages": "K\u00fcldj\u00f6n esem\u00e9nyeket harmadik f\u00e9l csomagjaib\u00f3l", - "tracing": "Enged\u00e9lyezze a teljes\u00edtm\u00e9nyk\u00f6vet\u00e9st" + "tracing": "Enged\u00e9lyezze a teljes\u00edtm\u00e9nyk\u00f6vet\u00e9st", + "tracing_sample_rate": "A mintav\u00e9teli sebess\u00e9g nyomon k\u00f6vet\u00e9se; 0,0 \u00e9s 1,0 k\u00f6z\u00f6tt (1,0 = 100%)" } } } diff --git a/homeassistant/components/shelly/translations/hu.json b/homeassistant/components/shelly/translations/hu.json index 2c8f468aaed..9388e26515a 100644 --- a/homeassistant/components/shelly/translations/hu.json +++ b/homeassistant/components/shelly/translations/hu.json @@ -11,6 +11,9 @@ }, "flow_title": "{name}", "step": { + "confirm_discovery": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a(z) {model} a(z) {host} c\u00edmen? \n\n A jelsz\u00f3val v\u00e9dett akkumul\u00e1toros eszk\u00f6z\u00f6ket fel kell \u00e9breszteni, miel\u0151tt folytatn\u00e1 a be\u00e1ll\u00edt\u00e1st.\n Az elemmel m\u0171k\u00f6d\u0151, jelsz\u00f3val nem v\u00e9dett eszk\u00f6z\u00f6k hozz\u00e1ad\u00e1sra ker\u00fclnek, amikor az eszk\u00f6z fel\u00e9bred, most manu\u00e1lisan \u00e9bresztheti fel az eszk\u00f6zt egy rajta l\u00e9v\u0151 gombbal, vagy v\u00e1rhat a k\u00f6vetkez\u0151 adatfriss\u00edt\u00e9sre." + }, "credentials": { "data": { "password": "Jelsz\u00f3", diff --git a/homeassistant/components/simplisafe/translations/hu.json b/homeassistant/components/simplisafe/translations/hu.json index 8a2deedc534..14faee90ed4 100644 --- a/homeassistant/components/simplisafe/translations/hu.json +++ b/homeassistant/components/simplisafe/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Ez a SimpliSafe-fi\u00f3k m\u00e1r haszn\u00e1latban van.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { @@ -9,10 +10,14 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "mfa": { + "title": "SimpliSafe t\u00f6bbt\u00e9nyez\u0151s hiteles\u00edt\u00e9s" + }, "reauth_confirm": { "data": { "password": "Jelsz\u00f3" }, + "description": "Hozz\u00e1f\u00e9r\u00e9se lej\u00e1rt vagy visszavont\u00e1k. Adja meg jelszav\u00e1t a fi\u00f3k \u00fajb\u00f3li \u00f6sszekapcsol\u00e1s\u00e1hoz.", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" }, "user": { @@ -24,5 +29,15 @@ "title": "T\u00f6ltsd ki az adataid" } } + }, + "options": { + "step": { + "init": { + "data": { + "code": "K\u00f3d (a Home Assistant felhaszn\u00e1l\u00f3i fel\u00fclet\u00e9n haszn\u00e1latos)" + }, + "title": "A SimpliSafe konfigur\u00e1l\u00e1sa" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/smappee/translations/hu.json b/homeassistant/components/smappee/translations/hu.json index 5d3e65bb6fc..5b00dffde9c 100644 --- a/homeassistant/components/smappee/translations/hu.json +++ b/homeassistant/components/smappee/translations/hu.json @@ -2,8 +2,10 @@ "config": { "abort": { "already_configured_device": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_configured_local_device": "A helyi eszk\u00f6z\u00f6k m\u00e1r konfigur\u00e1lva vannak. K\u00e9rj\u00fck, el\u0151sz\u00f6r t\u00e1vol\u00edtsa el ezeket, miel\u0151tt konfigur\u00e1lja a felh\u0151alap\u00fa eszk\u00f6zt.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_mdns": "Nem t\u00e1mogatott eszk\u00f6z a Smappee integr\u00e1ci\u00f3hoz.", "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz." }, @@ -12,15 +14,21 @@ "environment": { "data": { "environment": "K\u00f6rnyezet" - } + }, + "description": "\u00c1ll\u00edtsa be a Smappee k\u00e9sz\u00fcl\u00e9ket az HomeAssistant-al val\u00f3 integr\u00e1ci\u00f3hoz." }, "local": { "data": { "host": "Hoszt" - } + }, + "description": "Adja meg a gazdag\u00e9pet a Smappee helyi integr\u00e1ci\u00f3j\u00e1nak elind\u00edt\u00e1s\u00e1hoz" }, "pick_implementation": { "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" + }, + "zeroconf_confirm": { + "description": "Hozz\u00e1 szeretn\u00e9 adni a \"{serialnumber} serialnumber}\" sorozatsz\u00e1m\u00fa Smappee -eszk\u00f6zt az HomeAssistanthoz?", + "title": "Felfedezett Smappee eszk\u00f6z" } } } diff --git a/homeassistant/components/smarthab/translations/hu.json b/homeassistant/components/smarthab/translations/hu.json index 222c95bba16..2e3cf430a9f 100644 --- a/homeassistant/components/smarthab/translations/hu.json +++ b/homeassistant/components/smarthab/translations/hu.json @@ -2,6 +2,7 @@ "config": { "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "service": "Hiba t\u00f6rt\u00e9nt a SmartHab el\u00e9r\u00e9se k\u00f6zben. A szolg\u00e1ltat\u00e1s le\u00e1llhat. Ellen\u0151rizze a kapcsolatot.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { @@ -10,7 +11,8 @@ "email": "E-mail", "password": "Jelsz\u00f3" }, - "description": "Technikai okokb\u00f3l ne felejtsen el m\u00e1sodlagos fi\u00f3kot haszn\u00e1lni a Home Assistant be\u00e1ll\u00edt\u00e1s\u00e1hoz. A SmartHab alkalmaz\u00e1sb\u00f3l l\u00e9trehozhat egyet." + "description": "Technikai okokb\u00f3l ne felejtsen el m\u00e1sodlagos fi\u00f3kot haszn\u00e1lni a Home Assistant be\u00e1ll\u00edt\u00e1s\u00e1hoz. A SmartHab alkalmaz\u00e1sb\u00f3l l\u00e9trehozhat egyet.", + "title": "A SmartHab be\u00e1ll\u00edt\u00e1sa" } } } diff --git a/homeassistant/components/smartthings/translations/hu.json b/homeassistant/components/smartthings/translations/hu.json index bd6808db322..05e99bef2ea 100644 --- a/homeassistant/components/smartthings/translations/hu.json +++ b/homeassistant/components/smartthings/translations/hu.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "invalid_webhook_url": "A Home Assistant nincs megfelel\u0151en konfigur\u00e1lva a SmartThings friss\u00edt\u00e9seinek fogad\u00e1s\u00e1ra. A webhook URL \u00e9rv\u00e9nytelen:\n > {webhook_url} \n\n K\u00e9rj\u00fck, friss\u00edtse konfigur\u00e1ci\u00f3j\u00e1t az [utas\u00edt\u00e1sok] szerint ({component_url}), ind\u00edtsa \u00fajra a Home Assistant alkalmaz\u00e1st, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", + "no_available_locations": "Nincsenek be\u00e1ll\u00edthat\u00f3 SmartThings helyek a Home Assistant alkalmaz\u00e1sban." + }, "error": { "app_setup_error": "A SmartApp be\u00e1ll\u00edt\u00e1sa nem siker\u00fclt. K\u00e9rlek pr\u00f3b\u00e1ld \u00fajra.", "token_forbidden": "A token nem rendelkezik a sz\u00fcks\u00e9ges OAuth-tartom\u00e1nyokkal.", @@ -8,16 +12,22 @@ "webhook_error": "A SmartThings nem tudta \u00e9rv\u00e9nyes\u00edteni a `base_url`-ben konfigur\u00e1lt v\u00e9gpontot. K\u00e9rlek, tekintsd \u00e1t az \u00f6sszetev\u0151 k\u00f6vetelm\u00e9nyeit." }, "step": { + "authorize": { + "title": "HomeAssistant enged\u00e9lyez\u00e9se" + }, "pat": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token" }, - "description": "K\u00e9rj\u00fck, adjon meg egy SmartThings [Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si tokent]({token_url}), amelyet az [utas\u00edt\u00e1sok]({component_url}) alapj\u00e1n hoztak l\u00e9tre. Ezt haszn\u00e1ljuk a Home Assistant integr\u00e1ci\u00f3j\u00e1nak l\u00e9trehoz\u00e1s\u00e1hoz a SmartThings-fi\u00f3kban." + "description": "K\u00e9rj\u00fck, adjon meg egy SmartThings [Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si tokent]({token_url}), amelyet az [utas\u00edt\u00e1sok]({component_url}) alapj\u00e1n hoztak l\u00e9tre. Ezt haszn\u00e1ljuk a Home Assistant integr\u00e1ci\u00f3j\u00e1nak l\u00e9trehoz\u00e1s\u00e1hoz a SmartThings-fi\u00f3kban.", + "title": "Adja meg a szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si Tokent" }, "select_location": { "data": { "location_id": "Elhelyezked\u00e9s" - } + }, + "description": "K\u00e9rj\u00fck, v\u00e1lassza ki azt a SmartThings helyet, amelyet hozz\u00e1 szeretne adni a Home Assistant szolg\u00e1ltat\u00e1shoz. Ezut\u00e1n \u00faj ablakot nyitunk, \u00e9s megk\u00e9rj\u00fck, hogy jelentkezzen be, \u00e9s enged\u00e9lyezze a Home Assistant integr\u00e1ci\u00f3j\u00e1nak telep\u00edt\u00e9s\u00e9t a kiv\u00e1lasztott helyre.", + "title": "Hely kiv\u00e1laszt\u00e1sa" }, "user": { "description": "K\u00e9rlek add meg a SmartThings [Personal Access Tokent]({token_url}), amit az [instrukci\u00f3k]({component_url}) alapj\u00e1n hozt\u00e1l l\u00e9tre.", diff --git a/homeassistant/components/solaredge/translations/hu.json b/homeassistant/components/solaredge/translations/hu.json index 69e450f55ff..a1a14c76357 100644 --- a/homeassistant/components/solaredge/translations/hu.json +++ b/homeassistant/components/solaredge/translations/hu.json @@ -13,7 +13,8 @@ "user": { "data": { "api_key": "API kulcs", - "name": "Ennek az install\u00e1ci\u00f3nak a neve" + "name": "Ennek az install\u00e1ci\u00f3nak a neve", + "site_id": "A SolarEdge webhelyazonos\u00edt\u00f3ja" }, "title": "Az API param\u00e9terek megad\u00e1sa ehhez a telep\u00edt\u00e9shez" } diff --git a/homeassistant/components/solarlog/translations/hu.json b/homeassistant/components/solarlog/translations/hu.json index dd0ea8033ae..23baa393942 100644 --- a/homeassistant/components/solarlog/translations/hu.json +++ b/homeassistant/components/solarlog/translations/hu.json @@ -10,8 +10,10 @@ "step": { "user": { "data": { - "host": "Hoszt" - } + "host": "Hoszt", + "name": "A Solar-Log szenzorokhoz haszn\u00e1land\u00f3 el\u0151tag" + }, + "title": "Hat\u00e1rozza meg a Solar-Log kapcsolatot" } } } diff --git a/homeassistant/components/soma/translations/hu.json b/homeassistant/components/soma/translations/hu.json index d013cb49fdf..c3e572ebe0a 100644 --- a/homeassistant/components/soma/translations/hu.json +++ b/homeassistant/components/soma/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_setup": "Csak egy Soma-fi\u00f3k konfigur\u00e1lhat\u00f3.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", "connection_error": "Nem siker\u00fclt csatlakozni a SOMA Connecthez.", "missing_configuration": "A Soma \u00f6sszetev\u0151 nincs konfigur\u00e1lva. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3t.", diff --git a/homeassistant/components/somfy_mylink/translations/hu.json b/homeassistant/components/somfy_mylink/translations/hu.json index 5a2a1ee6ab5..093a35b78fe 100644 --- a/homeassistant/components/somfy_mylink/translations/hu.json +++ b/homeassistant/components/somfy_mylink/translations/hu.json @@ -26,6 +26,9 @@ }, "step": { "entity_config": { + "data": { + "reverse": "A bor\u00edt\u00f3 megfordult" + }, "description": "Konfigur\u00e1lja az \u201e {entity_id} \u201d be\u00e1ll\u00edt\u00e1sait", "title": "Entit\u00e1s konfigur\u00e1l\u00e1sa" }, diff --git a/homeassistant/components/speedtestdotnet/translations/hu.json b/homeassistant/components/speedtestdotnet/translations/hu.json index ec08c711e1d..cd08c3bd2d6 100644 --- a/homeassistant/components/speedtestdotnet/translations/hu.json +++ b/homeassistant/components/speedtestdotnet/translations/hu.json @@ -14,6 +14,8 @@ "step": { "init": { "data": { + "manual": "Automatikus friss\u00edt\u00e9s letilt\u00e1sa", + "scan_interval": "Friss\u00edt\u00e9si gyakoris\u00e1g (perc)", "server_name": "V\u00e1laszd ki a teszt szervert" } } diff --git a/homeassistant/components/squeezebox/translations/hu.json b/homeassistant/components/squeezebox/translations/hu.json index e9d7413ebfa..a047dbca45f 100644 --- a/homeassistant/components/squeezebox/translations/hu.json +++ b/homeassistant/components/squeezebox/translations/hu.json @@ -18,7 +18,8 @@ "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Kapcsolati inform\u00e1ci\u00f3k szerkeszt\u00e9se" }, "user": { "data": { diff --git a/homeassistant/components/srp_energy/translations/hu.json b/homeassistant/components/srp_energy/translations/hu.json index 9ade185d831..4d617e09cfc 100644 --- a/homeassistant/components/srp_energy/translations/hu.json +++ b/homeassistant/components/srp_energy/translations/hu.json @@ -13,6 +13,7 @@ "user": { "data": { "id": "A fi\u00f3k azonos\u00edt\u00f3ja", + "is_tou": "A haszn\u00e1lati id\u0151 terv", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/switcher_kis/translations/hu.json b/homeassistant/components/switcher_kis/translations/hu.json new file mode 100644 index 00000000000..c3be866fb85 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/hu.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egyetlen konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "step": { + "confirm": { + "description": "El akarja kezdeni a be\u00e1ll\u00edt\u00e1sokat?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/hu.json b/homeassistant/components/syncthru/translations/hu.json index 56e7c54203d..b82b2587bc6 100644 --- a/homeassistant/components/syncthru/translations/hu.json +++ b/homeassistant/components/syncthru/translations/hu.json @@ -4,7 +4,8 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { - "invalid_url": "\u00c9rv\u00e9nytelen URL" + "invalid_url": "\u00c9rv\u00e9nytelen URL", + "unknown_state": "A nyomtat\u00f3 \u00e1llapota ismeretlen, ellen\u0151rizze az URL-t \u00e9s a h\u00e1l\u00f3zati kapcsolatot" }, "flow_title": "{name}", "step": { diff --git a/homeassistant/components/synology_dsm/translations/hu.json b/homeassistant/components/synology_dsm/translations/hu.json index 7ac507f1efa..01f02e6156d 100644 --- a/homeassistant/components/synology_dsm/translations/hu.json +++ b/homeassistant/components/synology_dsm/translations/hu.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "missing_data": "Hi\u00e1nyz\u00f3 adatok: pr\u00f3b\u00e1lja \u00fajra k\u00e9s\u0151bb vagy m\u00e1s konfigur\u00e1ci\u00f3val", + "otp_failed": "A k\u00e9tl\u00e9pcs\u0151s azonos\u00edt\u00e1s sikertelen, pr\u00f3b\u00e1lkozzon \u00faj jelsz\u00f3val", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "flow_title": "{name} ({host})", @@ -13,7 +16,8 @@ "2sa": { "data": { "otp_code": "K\u00f3d" - } + }, + "title": "Synology DSM: k\u00e9tl\u00e9pcs\u0151s azonos\u00edt\u00e1s" }, "link": { "data": { @@ -23,8 +27,17 @@ "username": "Felhaszn\u00e1l\u00f3n\u00e9v", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" }, + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Indokl\u00e1s: {details}", + "title": "Synology DSM Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, "user": { "data": { "host": "Hoszt", @@ -42,6 +55,7 @@ "step": { "init": { "data": { + "scan_interval": "Percek a vizsg\u00e1latok k\u00f6z\u00f6tt", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s (m\u00e1sodperc)" } } diff --git a/homeassistant/components/tado/translations/hu.json b/homeassistant/components/tado/translations/hu.json index fd8db27da5e..dfde73ce428 100644 --- a/homeassistant/components/tado/translations/hu.json +++ b/homeassistant/components/tado/translations/hu.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "no_homes": "Ehhez a tado-fi\u00f3khoz nincsenek otthonok.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { @@ -13,7 +14,19 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon Tado-fi\u00f3kj\u00e1hoz" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "fallback": "A tartal\u00e9k m\u00f3d enged\u00e9lyez\u00e9se." + }, + "description": "A tartal\u00e9k m\u00f3d intelligens \u00fctemez\u00e9sre v\u00e1lt a k\u00f6vetkez\u0151 \u00fctemez\u00e9s kapcsol\u00f3n\u00e1l, miut\u00e1n manu\u00e1lisan be\u00e1ll\u00edtotta a z\u00f3n\u00e1t.", + "title": "\u00c1ll\u00edtsa be a Tado-t." } } } diff --git a/homeassistant/components/tesla/translations/hu.json b/homeassistant/components/tesla/translations/hu.json index a4622ce7efa..75a93566df5 100644 --- a/homeassistant/components/tesla/translations/hu.json +++ b/homeassistant/components/tesla/translations/hu.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA k\u00f3d (opcion\u00e1lis)", "password": "Jelsz\u00f3", "username": "E-mail" }, @@ -24,6 +25,7 @@ "step": { "init": { "data": { + "enable_wake_on_start": "Az aut\u00f3k \u00e9bred\u00e9sre k\u00e9nyszer\u00edt\u00e9se ind\u00edt\u00e1skor", "scan_interval": "Szkennel\u00e9sek k\u00f6z\u00f6tti m\u00e1sodpercek" } } diff --git a/homeassistant/components/toon/translations/hu.json b/homeassistant/components/toon/translations/hu.json index 6371bf4c6fd..28a987a4512 100644 --- a/homeassistant/components/toon/translations/hu.json +++ b/homeassistant/components/toon/translations/hu.json @@ -1,11 +1,21 @@ { "config": { "abort": { + "already_configured": "A kiv\u00e1lasztott meg\u00e1llapod\u00e1s m\u00e1r konfigur\u00e1lva van.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", "no_agreements": "Ennek a fi\u00f3knak nincsenek Toon kijelz\u0151i.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "unknown_authorize_url_generation": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n." + }, + "step": { + "agreement": { + "data": { + "agreement": "Meg\u00e1llapod\u00e1s" + }, + "description": "V\u00e1lassza ki a hozz\u00e1adni k\u00edv\u00e1nt szerz\u0151d\u00e9sc\u00edmet.", + "title": "V\u00e1lassza ki a meg\u00e1llapod\u00e1st" + } } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/hu.json b/homeassistant/components/totalconnect/translations/hu.json index e9e991d81d4..319611fd2b1 100644 --- a/homeassistant/components/totalconnect/translations/hu.json +++ b/homeassistant/components/totalconnect/translations/hu.json @@ -25,7 +25,8 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Total Connect" } } } diff --git a/homeassistant/components/traccar/translations/hu.json b/homeassistant/components/traccar/translations/hu.json index c4fc027d059..94fc9198921 100644 --- a/homeassistant/components/traccar/translations/hu.json +++ b/homeassistant/components/traccar/translations/hu.json @@ -6,6 +6,12 @@ }, "create_entry": { "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a webhook funkci\u00f3t a Traccar-ban. \n\n Haszn\u00e1lja a k\u00f6vetkez\u0151 URL-t: `{webhook_url}`\n\n Tov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1sd a [dokument\u00e1ci\u00f3t]({docs_url})." + }, + "step": { + "user": { + "description": "Biztosan be\u00e1ll\u00edtja a Traccar szolg\u00e1ltat\u00e1st?", + "title": "A Traccar be\u00e1ll\u00edt\u00e1sa" + } } } } \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/ca.json b/homeassistant/components/tractive/translations/ca.json new file mode 100644 index 00000000000..4854e13a199 --- /dev/null +++ b/homeassistant/components/tractive/translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/en.json b/homeassistant/components/tractive/translations/en.json index 4abfd682903..c85034b0729 100644 --- a/homeassistant/components/tractive/translations/en.json +++ b/homeassistant/components/tractive/translations/en.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "email": "E-Mail", + "email": "Email", "password": "Password" } } diff --git a/homeassistant/components/tractive/translations/et.json b/homeassistant/components/tractive/translations/et.json new file mode 100644 index 00000000000..7e9ab892ed4 --- /dev/null +++ b/homeassistant/components/tractive/translations/et.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/hu.json b/homeassistant/components/tractive/translations/hu.json new file mode 100644 index 00000000000..8830cb61711 --- /dev/null +++ b/homeassistant/components/tractive/translations/hu.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "Ismeretlen hiba" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Jelsz\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/it.json b/homeassistant/components/tractive/translations/it.json new file mode 100644 index 00000000000..484d1e229e2 --- /dev/null +++ b/homeassistant/components/tractive/translations/it.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/pl.json b/homeassistant/components/tractive/translations/pl.json new file mode 100644 index 00000000000..da4e71dc1b7 --- /dev/null +++ b/homeassistant/components/tractive/translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/ru.json b/homeassistant/components/tractive/translations/ru.json new file mode 100644 index 00000000000..155e3a99ba5 --- /dev/null +++ b/homeassistant/components/tractive/translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/zh-Hant.json b/homeassistant/components/tractive/translations/zh-Hant.json new file mode 100644 index 00000000000..64aba47b6b8 --- /dev/null +++ b/homeassistant/components/tractive/translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/transmission/translations/hu.json b/homeassistant/components/transmission/translations/hu.json index 22d4e18df5e..5c968b21ed7 100644 --- a/homeassistant/components/transmission/translations/hu.json +++ b/homeassistant/components/transmission/translations/hu.json @@ -28,7 +28,8 @@ "limit": "Limit", "order": "Sorrend", "scan_interval": "Friss\u00edt\u00e9si gyakoris\u00e1g" - } + }, + "title": "Adja meg az Transmission be\u00e1ll\u00edt\u00e1sokat" } } } diff --git a/homeassistant/components/twentemilieu/translations/hu.json b/homeassistant/components/twentemilieu/translations/hu.json index df83a29ec22..637dadb5baf 100644 --- a/homeassistant/components/twentemilieu/translations/hu.json +++ b/homeassistant/components/twentemilieu/translations/hu.json @@ -4,14 +4,18 @@ "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" }, "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_address": "A c\u00edm nem tal\u00e1lhat\u00f3 a Twente Milieu szolg\u00e1ltat\u00e1si ter\u00fcleten." }, "step": { "user": { "data": { + "house_letter": "H\u00e1zlev\u00e9l/kieg\u00e9sz\u00edt\u0151", "house_number": "h\u00e1zsz\u00e1m", "post_code": "ir\u00e1ny\u00edt\u00f3sz\u00e1m" - } + }, + "description": "\u00c1ll\u00edtsa be a Twente Milieu szolg\u00e1ltat\u00e1st, amely hullad\u00e9kgy\u0171jt\u00e9si inform\u00e1ci\u00f3kat biztos\u00edt a c\u00edm\u00e9re.", + "title": "Twente Milieu" } } } diff --git a/homeassistant/components/unifi/translations/hu.json b/homeassistant/components/unifi/translations/hu.json index 5c174e9939d..22904c8ec7b 100644 --- a/homeassistant/components/unifi/translations/hu.json +++ b/homeassistant/components/unifi/translations/hu.json @@ -7,7 +7,8 @@ }, "error": { "faulty_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "service_unavailable": "Sikertelen csatlakoz\u00e1s" + "service_unavailable": "Sikertelen csatlakoz\u00e1s", + "unknown_client_mac": "Nincs el\u00e9rhet\u0151 \u00fcgyf\u00e9l ezen a MAC-c\u00edmen" }, "flow_title": "{site} ({host})", "step": { @@ -28,18 +29,46 @@ "step": { "client_control": { "data": { - "dpi_restrictions": "Enged\u00e9lyezze a DPI restrikci\u00f3s csoportok vez\u00e9rl\u00e9s\u00e9t" + "block_client": "H\u00e1l\u00f3zathozz\u00e1f\u00e9r\u00e9s vez\u00e9relt \u00fcgyfelek", + "dpi_restrictions": "Enged\u00e9lyezze a DPI restrikci\u00f3s csoportok vez\u00e9rl\u00e9s\u00e9t", + "poe_clients": "Enged\u00e9lyezze az \u00fcgyfelek POE-vez\u00e9rl\u00e9s\u00e9t" }, - "description": "Konfigur\u00e1lja a klienseket\n\n Hozzon l\u00e9tre kapcsol\u00f3kat azokhoz a sorsz\u00e1mokhoz, amelyeknek vez\u00e9relni k\u00edv\u00e1nja a h\u00e1l\u00f3zati hozz\u00e1f\u00e9r\u00e9st." + "description": "Konfigur\u00e1lja a klienseket\n\n Hozzon l\u00e9tre kapcsol\u00f3kat azokhoz a sorsz\u00e1mokhoz, amelyeknek vez\u00e9relni k\u00edv\u00e1nja a h\u00e1l\u00f3zati hozz\u00e1f\u00e9r\u00e9st.", + "title": "UniFi lehet\u0151s\u00e9gek 2/3" + }, + "device_tracker": { + "data": { + "detection_time": "Id\u0151 m\u00e1sodpercben az utols\u00f3 l\u00e1t\u00e1st\u00f3l a t\u00e1vol tart\u00e1sig", + "ignore_wired_bug": "Az UniFi vezet\u00e9kes hibalogika letilt\u00e1sa", + "ssid_filter": "V\u00e1lassza ki az SSID -ket a vezet\u00e9k n\u00e9lk\u00fcli \u00fcgyfelek nyomon k\u00f6vet\u00e9s\u00e9hez", + "track_clients": "K\u00f6vesse nyomon a h\u00e1l\u00f3zati \u00fcgyfeleket", + "track_devices": "H\u00e1l\u00f3zati eszk\u00f6z\u00f6k nyomon k\u00f6vet\u00e9se (Ubiquiti eszk\u00f6z\u00f6k)", + "track_wired_clients": "Vegyen fel vezet\u00e9kes h\u00e1l\u00f3zati \u00fcgyfeleket" + }, + "description": "Eszk\u00f6zk\u00f6vet\u00e9s konfigur\u00e1l\u00e1sa", + "title": "UniFi lehet\u0151s\u00e9gek 1/3" + }, + "init": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + } }, "simple_options": { + "data": { + "block_client": "H\u00e1l\u00f3zathozz\u00e1f\u00e9r\u00e9s vez\u00e9relt \u00fcgyfelek", + "track_clients": "K\u00f6vesse nyomon a h\u00e1l\u00f3zati \u00fcgyfeleket", + "track_devices": "H\u00e1l\u00f3zati eszk\u00f6z\u00f6k nyomon k\u00f6vet\u00e9se (Ubiquiti eszk\u00f6z\u00f6k)" + }, "description": "UniFi integr\u00e1ci\u00f3 konfigur\u00e1l\u00e1sa" }, "statistics_sensors": { "data": { "allow_bandwidth_sensors": "S\u00e1vsz\u00e9less\u00e9g-haszn\u00e1lati \u00e9rz\u00e9kel\u0151k l\u00e9trehoz\u00e1sa a h\u00e1l\u00f3zati \u00fcgyfelek sz\u00e1m\u00e1ra", "allow_uptime_sensors": "\u00dczemid\u0151-\u00e9rz\u00e9kel\u0151k h\u00e1l\u00f3zati \u00fcgyfelek sz\u00e1m\u00e1ra" - } + }, + "description": "Statisztikai \u00e9rz\u00e9kel\u0151k konfigur\u00e1l\u00e1sa", + "title": "UniFi lehet\u0151s\u00e9gek 3/3" } } } diff --git a/homeassistant/components/upb/translations/hu.json b/homeassistant/components/upb/translations/hu.json index b09f497a0e4..58b81af7be8 100644 --- a/homeassistant/components/upb/translations/hu.json +++ b/homeassistant/components/upb/translations/hu.json @@ -5,13 +5,18 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_upb_file": "Hi\u00e1nyz\u00f3 vagy \u00e9rv\u00e9nytelen UPB UPStart export f\u00e1jl, ellen\u0151rizze a f\u00e1jl nev\u00e9t \u00e9s el\u00e9r\u00e9si \u00fatj\u00e1t.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { "user": { "data": { + "address": "C\u00edm (l\u00e1sd a fenti le\u00edr\u00e1st)", + "file_path": "Az UPStart UPB exportf\u00e1jl el\u00e9r\u00e9si \u00fatja \u00e9s neve.", "protocol": "Protokoll" - } + }, + "description": "Csatlakoztasson egy univerz\u00e1lis Powerline Bus Powerline Interface modult (UPB PIM). A c\u00edmsornak a \u201etcp\u201d \u201ec\u00edm [: port]\u201d form\u00e1tum\u00fanak kell lennie. A port nem k\u00f6telez\u0151, \u00e9s alap\u00e9rtelmezett \u00e9rt\u00e9ke 2101. P\u00e9lda: '192.168.1.42'. A soros protokollhoz a c\u00edmnek 'tty [: baud]' form\u00e1tum\u00fanak kell lennie. A baud opcion\u00e1lis, \u00e9s alap\u00e9rtelmezett \u00e9rt\u00e9ke 4800. P\u00e9lda: '/dev/ttyS1'.", + "title": "Csatlakoz\u00e1s az UPB PIM-hez" } } } diff --git a/homeassistant/components/upnp/translations/hu.json b/homeassistant/components/upnp/translations/hu.json index 49756babc8b..8ef3ff8dcc0 100644 --- a/homeassistant/components/upnp/translations/hu.json +++ b/homeassistant/components/upnp/translations/hu.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "incomplete_discovery": "Hi\u00e1nyos felfedez\u00e9s", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" }, "error": { @@ -10,8 +11,16 @@ }, "flow_title": "{name}", "step": { + "init": { + "one": "\u00dcres", + "other": "" + }, + "ssdp_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani ezt az UPnP/IGD eszk\u00f6zt?" + }, "user": { "data": { + "scan_interval": "Friss\u00edt\u00e9si intervallum (m\u00e1sodperc, minimum 30)", "unique_id": "Eszk\u00f6z", "usn": "Eszk\u00f6z" } diff --git a/homeassistant/components/uptimerobot/translations/ca.json b/homeassistant/components/uptimerobot/translations/ca.json new file mode 100644 index 00000000000..ee0d2416cc6 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat", + "unknown": "Error inesperat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_api_key": "Clau API inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/de.json b/homeassistant/components/uptimerobot/translations/de.json new file mode 100644 index 00000000000..81a9960b69c --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "api_key": "API-Schl\u00fcssel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/en.json b/homeassistant/components/uptimerobot/translations/en.json index 99ab9426006..d23431fa888 100644 --- a/homeassistant/components/uptimerobot/translations/en.json +++ b/homeassistant/components/uptimerobot/translations/en.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Account already configured", + "already_configured": "Account is already configured", "unknown": "Unexpected error" }, "error": { diff --git a/homeassistant/components/uptimerobot/translations/et.json b/homeassistant/components/uptimerobot/translations/et.json new file mode 100644 index 00000000000..a0608c5fff6 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_api_key": "Vigane API v\u00f5ti", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "api_key": "API v\u00f5ti" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/he.json b/homeassistant/components/uptimerobot/translations/he.json new file mode 100644 index 00000000000..5b6fc485e04 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/hu.json b/homeassistant/components/uptimerobot/translations/hu.json new file mode 100644 index 00000000000..b9e14001679 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "unknown": "V\u00e1ratlan hiba" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_api_key": "\u00c9rv\u00e9nytelen API-kulcs", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "api_key": "API kulcs" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/it.json b/homeassistant/components/uptimerobot/translations/it.json new file mode 100644 index 00000000000..6b151199afe --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/pl.json b/homeassistant/components/uptimerobot/translations/pl.json new file mode 100644 index 00000000000..ac413226e98 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_api_key": "Nieprawid\u0142owy klucz API", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/ru.json b/homeassistant/components/uptimerobot/translations/ru.json new file mode 100644 index 00000000000..60e7e8530d1 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/zh-Hant.json b/homeassistant/components/uptimerobot/translations/zh-Hant.json new file mode 100644 index 00000000000..c100c6868b9 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_api_key": "API \u5bc6\u9470\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/translations/hu.json b/homeassistant/components/velbus/translations/hu.json index 414ee7e60c6..6bf3ba689f3 100644 --- a/homeassistant/components/velbus/translations/hu.json +++ b/homeassistant/components/velbus/translations/hu.json @@ -6,6 +6,15 @@ "error": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "user": { + "data": { + "name": "Ennek a velbus kapcsolatnak a neve", + "port": "Kapcsolati karakterl\u00e1nc" + }, + "title": "Hat\u00e1rozza meg a velbus kapcsolat t\u00edpus\u00e1t" + } } } } \ No newline at end of file diff --git a/homeassistant/components/vera/translations/hu.json b/homeassistant/components/vera/translations/hu.json new file mode 100644 index 00000000000..1f1e22b9ed8 --- /dev/null +++ b/homeassistant/components/vera/translations/hu.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "cannot_connect": "Nem siker\u00fclt csatlakozni a {base_url}" + }, + "step": { + "user": { + "data": { + "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa a HomeAssistantb\u00f3l.", + "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a HomeAssistant alkalmaz\u00e1sban.", + "vera_controller_url": "Vez\u00e9rl\u0151 URL" + }, + "description": "Adja meg a Vera vez\u00e9rl\u0151 URL-j\u00e9t al\u00e1bb. Ennek \u00edgy kell kin\u00e9znie: http://192.168.1.161:3480.", + "title": "Vera vez\u00e9rl\u0151 be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa a HomeAssistantb\u00f3l.", + "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a HomeAssistant alkalmaz\u00e1sban." + }, + "description": "Az opcion\u00e1lis param\u00e9terekr\u0151l a vera dokument\u00e1ci\u00f3j\u00e1ban olvashat: https://www.home-assistant.io/integrations/vera/. Megjegyz\u00e9s: Az itt v\u00e9grehajtott v\u00e1ltoztat\u00e1sokhoz \u00fajra kell ind\u00edtani a h\u00e1zi asszisztens szervert. Az \u00e9rt\u00e9kek t\u00f6rl\u00e9s\u00e9hez adjon meg egy sz\u00f3k\u00f6zt.", + "title": "Vera vez\u00e9rl\u0151 opci\u00f3k" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/translations/hu.json b/homeassistant/components/vilfo/translations/hu.json index 34db9cf7cc9..4e2ab47a476 100644 --- a/homeassistant/components/vilfo/translations/hu.json +++ b/homeassistant/components/vilfo/translations/hu.json @@ -14,6 +14,7 @@ "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", "host": "Hoszt" }, + "description": "\u00c1ll\u00edtsa be a Vilfo Router integr\u00e1ci\u00f3t. Sz\u00fcks\u00e9ge van a Vilfo Router gazdag\u00e9pnev\u00e9re/IP -c\u00edm\u00e9re \u00e9s egy API hozz\u00e1f\u00e9r\u00e9si jogkivonatra. Ha tov\u00e1bbi inform\u00e1ci\u00f3ra van sz\u00fcks\u00e9ge az integr\u00e1ci\u00f3r\u00f3l \u00e9s a r\u00e9szletekr\u0151l, l\u00e1togasson el a k\u00f6vetkez\u0151 webhelyre: https://www.home-assistant.io/integrations/vilfo", "title": "Csatlakoz\u00e1s a Vilfo routerhez" } } diff --git a/homeassistant/components/vizio/translations/hu.json b/homeassistant/components/vizio/translations/hu.json index 6f0962509f5..edc91cdb31c 100644 --- a/homeassistant/components/vizio/translations/hu.json +++ b/homeassistant/components/vizio/translations/hu.json @@ -6,16 +6,25 @@ "updated_entry": "Ez a bejegyz\u00e9s m\u00e1r be van \u00e1ll\u00edtva, de a konfigur\u00e1ci\u00f3ban defini\u00e1lt n\u00e9v, appok \u00e9s/vagy be\u00e1ll\u00edt\u00e1sok nem egyeznek meg a kor\u00e1bban import\u00e1lt konfigur\u00e1ci\u00f3val, \u00edgy a konfigur\u00e1ci\u00f3s bejegyz\u00e9s ennek megfelel\u0151en friss\u00fclt." }, "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "complete_pairing_failed": "Nem siker\u00fclt befejezni a p\u00e1ros\u00edt\u00e1st. Az \u00fajb\u00f3li elk\u00fcld\u00e9s el\u0151tt gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a megadott PIN-k\u00f3d helyes, a TV tov\u00e1bbra is be van kapcsolva, \u00e9s csatlakozik a h\u00e1l\u00f3zathoz.", + "existing_config_entry_found": "Egy megl\u00e9v\u0151 VIZIO SmartCast Eszk\u00f6z konfigur\u00e1ci\u00f3s bejegyz\u00e9s ugyanazzal a sorozatsz\u00e1mmal m\u00e1r konfigur\u00e1lva van. Ennek konfigur\u00e1l\u00e1s\u00e1hoz t\u00f6r\u00f6lnie kell a megl\u00e9v\u0151 bejegyz\u00e9st." }, "step": { "pair_tv": { "data": { "pin": "PIN-k\u00f3d" - } + }, + "description": "A TV-nek k\u00f3dot kell megjelen\u00edtenie. \u00cdrja be ezt a k\u00f3dot az \u0171rlapba, majd folytassa a k\u00f6vetkez\u0151 l\u00e9p\u00e9ssel a p\u00e1ros\u00edt\u00e1s befejez\u00e9s\u00e9hez.", + "title": "V\u00e9gezze el a p\u00e1ros\u00edt\u00e1si folyamatot" }, "pairing_complete": { - "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik a Home Assistant-hoz." + "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik a Home Assistant-hoz.", + "title": "P\u00e1ros\u00edt\u00e1s k\u00e9sz" + }, + "pairing_complete_import": { + "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik a Home Assistant szolg\u00e1ltat\u00e1shoz. \n\n A Hozz\u00e1f\u00e9r\u00e9si token a \u201e** {access_token} **\u201d.", + "title": "P\u00e1ros\u00edt\u00e1s k\u00e9sz" }, "user": { "data": { @@ -24,6 +33,7 @@ "host": "Hoszt", "name": "N\u00e9v" }, + "description": "A Hozz\u00e1f\u00e9r\u00e9si token csak t\u00e9v\u00e9khez sz\u00fcks\u00e9ges. Ha TV -t konfigur\u00e1l, \u00e9s m\u00e9g nincs Hozz\u00e1f\u00e9r\u00e9si token , hagyja \u00fcresen a p\u00e1ros\u00edt\u00e1si folyamathoz.", "title": "VIZIO SmartCast Eszk\u00f6z" } } @@ -32,8 +42,11 @@ "step": { "init": { "data": { + "apps_to_include_or_exclude": "Alkalmaz\u00e1sok felv\u00e9telre vagy kiz\u00e1r\u00e1sra", + "include_or_exclude": "Alkalmaz\u00e1sok felv\u00e9tele vagy kiz\u00e1r\u00e1sa?", "volume_step": "Hanger\u0151 l\u00e9p\u00e9s nagys\u00e1ga" }, + "description": "Ha rendelkezik Smart TV-vel, opcion\u00e1lisan sz\u0171rheti a forr\u00e1slist\u00e1t \u00fagy, hogy kiv\u00e1lasztja, mely alkalmaz\u00e1sokat k\u00edv\u00e1nja felvenni vagy kiz\u00e1rni a forr\u00e1slist\u00e1b\u00f3l.", "title": "VIZIO SmartCast Eszk\u00f6z be\u00e1ll\u00edt\u00e1sok friss\u00edt\u00e9se" } } diff --git a/homeassistant/components/wemo/translations/hu.json b/homeassistant/components/wemo/translations/hu.json index bcb2f438353..ff9f4dc5f75 100644 --- a/homeassistant/components/wemo/translations/hu.json +++ b/homeassistant/components/wemo/translations/hu.json @@ -3,6 +3,11 @@ "abort": { "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Wemo-t?" + } } }, "device_automation": { diff --git a/homeassistant/components/wiffi/translations/hu.json b/homeassistant/components/wiffi/translations/hu.json index c623f6ddaba..902fabcbc85 100644 --- a/homeassistant/components/wiffi/translations/hu.json +++ b/homeassistant/components/wiffi/translations/hu.json @@ -1,13 +1,15 @@ { "config": { "abort": { + "addr_in_use": "A szerverport m\u00e1r haszn\u00e1latban van.", "start_server_failed": "A szerver ind\u00edt\u00e1sa nem siker\u00fclt." }, "step": { "user": { "data": { "port": "Port" - } + }, + "title": "TCP szerver be\u00e1ll\u00edt\u00e1sa WIFFI eszk\u00f6z\u00f6kh\u00f6z" } } }, diff --git a/homeassistant/components/withings/translations/hu.json b/homeassistant/components/withings/translations/hu.json index ec8c628a485..e26cff027fc 100644 --- a/homeassistant/components/withings/translations/hu.json +++ b/homeassistant/components/withings/translations/hu.json @@ -25,6 +25,7 @@ "title": "Felhaszn\u00e1l\u00f3i profil." }, "reauth": { + "description": "A \u201e{profile}\u201d profilt \u00fajra hiteles\u00edteni kell, hogy tov\u00e1bbra is fogadni tudja a Withings adatokat.", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" } } diff --git a/homeassistant/components/wolflink/translations/hu.json b/homeassistant/components/wolflink/translations/hu.json index c7bb483155d..79d03d91034 100644 --- a/homeassistant/components/wolflink/translations/hu.json +++ b/homeassistant/components/wolflink/translations/hu.json @@ -12,13 +12,15 @@ "device": { "data": { "device_name": "Eszk\u00f6z" - } + }, + "title": "V\u00e1lassza ki a WOLF eszk\u00f6zt" }, "user": { "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "WOLF SmartSet kapcsolat" } } } diff --git a/homeassistant/components/wolflink/translations/sensor.hu.json b/homeassistant/components/wolflink/translations/sensor.hu.json index b393660f35a..34f54e80ae8 100644 --- a/homeassistant/components/wolflink/translations/sensor.hu.json +++ b/homeassistant/components/wolflink/translations/sensor.hu.json @@ -1,9 +1,83 @@ { "state": { "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "F\u00fcstg\u00e1zcsillap\u00edt\u00f3", + "aktiviert": "Aktiv\u00e1lt", + "antilegionellenfunktion": "Anti-legionella funkci\u00f3", + "at_abschaltung": "OT le\u00e1ll\u00edt\u00e1s", + "at_frostschutz": "OT fagyv\u00e9delem", + "aus": "Letiltva", + "auto": "Automatikus", "auto_off_cool": "AutomataKiH\u0171t\u00e9s", + "auto_on_cool": "AutomatikusH\u0171t\u00e9s", "automatik_aus": "Automatikus kikapcsol\u00e1s", - "permanent": "\u00c1lland\u00f3" + "automatik_ein": "Automatikus bekapcsol\u00e1s", + "bereit_keine_ladung": "K\u00e9sz, nincs bet\u00f6ltve", + "betrieb_ohne_brenner": "Munka \u00e9g\u0151 n\u00e9lk\u00fcl", + "cooling": "H\u0171t\u00e9s", + "deaktiviert": "Inakt\u00edv", + "dhw_prior": "DHW Priorit\u00e1s", + "eco": "Takar\u00e9kos", + "ein": "Enged\u00e9lyezve", + "externe_deaktivierung": "K\u00fcls\u0151 deaktiv\u00e1l\u00e1s", + "fernschalter_ein": "T\u00e1vir\u00e1ny\u00edt\u00f3 enged\u00e9lyezve", + "frost_heizkreis": "F\u0171t\u0151k\u00f6r fagy\u00e1s", + "frost_warmwasser": "DHW fagy", + "frostschutz": "Fagyv\u00e9delem", + "gasdruck": "G\u00e1znyom\u00e1s", + "glt_betrieb": "BMS m\u00f3d", + "gradienten_uberwachung": "\u00c1tmenet monitoroz\u00e1s", + "heizbetrieb": "F\u0171t\u00e9si m\u00f3d", + "heizgerat_mit_speicher": "Kaz\u00e1n hengerrel", + "heizung": "F\u0171t\u00e9s", + "initialisierung": "Inicializ\u00e1l\u00e1s", + "kalibration": "Kalibr\u00e1ci\u00f3", + "kalibration_heizbetrieb": "F\u0171t\u00e9si m\u00f3d kalibr\u00e1l\u00e1sa", + "kalibration_kombibetrieb": "Kombin\u00e1lt m\u00f3d kalibr\u00e1l\u00e1sa", + "kalibration_warmwasserbetrieb": "DHW kalibr\u00e1l\u00e1s", + "kaskadenbetrieb": "Kaszk\u00e1d m\u0171k\u00f6d\u00e9s", + "kombibetrieb": "Kombin\u00e1lt m\u00f3d", + "kombigerat": "Kombin\u00e1lt kaz\u00e1n", + "kombigerat_mit_solareinbindung": "Kombin\u00e1lt kaz\u00e1n napelemes integr\u00e1ci\u00f3val", + "mindest_kombizeit": "Minim\u00e1lis kombin\u00e1lt id\u0151", + "nachlauf_heizkreispumpe": "A f\u0171t\u0151k\u00f6r szivatty\u00fa bej\u00e1rat\u00e1sa", + "nachspulen": "Ut\u00f3\u00f6bl\u00edt\u00e9s", + "nur_heizgerat": "Csak kaz\u00e1n", + "parallelbetrieb": "P\u00e1rhuzamos \u00fczemm\u00f3d", + "partymodus": "Party m\u00f3d", + "perm_cooling": "\u00c1lland\u00f3H\u0171t\u00e9s", + "permanent": "\u00c1lland\u00f3", + "permanentbetrieb": "\u00c1lland\u00f3 \u00fczemm\u00f3d", + "reduzierter_betrieb": "Korl\u00e1tozott m\u00f3d", + "rt_abschaltung": "RT le\u00e1ll\u00edt\u00e1s", + "rt_frostschutz": "RT fagyv\u00e9delem", + "ruhekontakt": "Pihen\u0151 kapcsolat", + "schornsteinfeger": "Emisszi\u00f3s vizsg\u00e1lat", + "smart_grid": "SmartGrid", + "smart_home": "OkosOtthon", + "softstart": "L\u00e1gy ind\u00edt\u00e1s", + "solarbetrieb": "Napenergia \u00fczemm\u00f3d", + "sparbetrieb": "Gazdas\u00e1gos m\u00f3d", + "sparen": "Gazdas\u00e1gos", + "spreizung_hoch": "dT t\u00fal sz\u00e9les", + "spreizung_kf": "Spread KF", + "stabilisierung": "Stabiliz\u00e1ci\u00f3", + "standby": "K\u00e9szenl\u00e9t", + "start": "Indul\u00e1s", + "storung": "Hiba", + "taktsperre": "Anti-ciklus", + "telefonfernschalter": "Telefonos t\u00e1vkapcsol\u00f3", + "test": "Teszt", + "tpw": "TPW", + "urlaubsmodus": "Nyaral\u00e1s \u00fczemm\u00f3d", + "ventilprufung": "Szelep teszt", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW gyorsind\u00edt\u00e1s", + "warmwasserbetrieb": "DHW m\u00f3d", + "warmwassernachlauf": "DHW befut\u00e1s", + "warmwasservorrang": "DHW priorit\u00e1s", + "zunden": "Gy\u00fajt\u00e1s" } } } \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.ca.json b/homeassistant/components/xiaomi_miio/translations/select.ca.json new file mode 100644 index 00000000000..bc96de04645 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.ca.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Brillant", + "dim": "Atenua", + "off": "OFF" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.he.json b/homeassistant/components/xiaomi_miio/translations/select.he.json index 0059da60e86..2cffbc3b457 100644 --- a/homeassistant/components/xiaomi_miio/translations/select.he.json +++ b/homeassistant/components/xiaomi_miio/translations/select.he.json @@ -1,6 +1,8 @@ { "state": { "xiaomi_miio__led_brightness": { + "bright": "\u05d1\u05d4\u05d9\u05e8", + "dim": "\u05de\u05e2\u05d5\u05de\u05e2\u05dd", "off": "\u05db\u05d1\u05d5\u05d9" } } diff --git a/homeassistant/components/xiaomi_miio/translations/select.hu.json b/homeassistant/components/xiaomi_miio/translations/select.hu.json new file mode 100644 index 00000000000..4e6df2b4a33 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.hu.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "F\u00e9nyes", + "dim": "Hom\u00e1lyos", + "off": "Ki" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.it.json b/homeassistant/components/xiaomi_miio/translations/select.it.json new file mode 100644 index 00000000000..21e79e41e99 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.it.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Brillante", + "dim": "Fioca", + "off": "Spento" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/hu.json b/homeassistant/components/yale_smart_alarm/translations/hu.json new file mode 100644 index 00000000000..8c60574227d --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "Ter\u00fclet ID", + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + }, + "user": { + "data": { + "area_id": "Ter\u00fclet ID", + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/hu.json b/homeassistant/components/youless/translations/hu.json new file mode 100644 index 00000000000..21c7a7ebe4b --- /dev/null +++ b/homeassistant/components/youless/translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni" + }, + "step": { + "user": { + "data": { + "host": "H\u00e1zigazda", + "name": "N\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/translations/hu.json b/homeassistant/components/zha/translations/hu.json index 2b078092ed7..9722095b548 100644 --- a/homeassistant/components/zha/translations/hu.json +++ b/homeassistant/components/zha/translations/hu.json @@ -8,13 +8,27 @@ }, "flow_title": "{name}", "step": { + "pick_radio": { + "data": { + "radio_type": "R\u00e1di\u00f3 t\u00edpusa" + }, + "description": "V\u00e1lassza ki a Zigbee r\u00e1di\u00f3 t\u00edpus\u00e1t", + "title": "R\u00e1di\u00f3 t\u00edpusa" + }, "port_config": { "data": { - "baudrate": "port sebess\u00e9g" + "baudrate": "port sebess\u00e9g", + "flow_control": "adat\u00e1raml\u00e1s szab\u00e1lyoz\u00e1sa", + "path": "Soros eszk\u00f6z el\u00e9r\u00e9si \u00fatja" }, + "description": "Adja meg a port specifikus be\u00e1ll\u00edt\u00e1sokat", "title": "Be\u00e1ll\u00edt\u00e1sok" }, "user": { + "data": { + "path": "Soros eszk\u00f6z el\u00e9r\u00e9si \u00fatja" + }, + "description": "V\u00e1lassza ki a Zigbee r\u00e1di\u00f3 soros portj\u00e1t", "title": "ZHA" } } @@ -35,11 +49,59 @@ } }, "device_automation": { + "action_type": { + "squawk": "Riaszt\u00e1s", + "warn": "Figyelmeztet\u00e9s" + }, "trigger_subtype": { - "turn_off": "Kikapcsol\u00e1s" + "both_buttons": "Mindk\u00e9t gomb", + "button_1": "Els\u0151 gomb", + "button_2": "M\u00e1sodik gomb", + "button_3": "Harmadik gomb", + "button_4": "Negyedik gomb", + "button_5": "\u00d6t\u00f6dik gomb", + "button_6": "Hatodik gomb", + "close": "Bez\u00e1r\u00e1s", + "dim_down": "S\u00f6t\u00e9t\u00edt", + "dim_up": "Vil\u00e1gos\u00edt", + "face_1": "aktiv\u00e1lt 1 arccal", + "face_2": "aktiv\u00e1lt 2 arccal", + "face_3": "aktiv\u00e1lt 3 arccal", + "face_4": "aktiv\u00e1lt 4 arccal", + "face_5": "aktiv\u00e1lt 5 arccal", + "face_6": "aktiv\u00e1lt 6 arccal", + "face_any": "B\u00e1rmely/meghat\u00e1rozott arc(ok) aktiv\u00e1l\u00e1s\u00e1val", + "left": "Bal", + "open": "Nyitva", + "right": "Jobb", + "turn_off": "Kikapcsol\u00e1s", + "turn_on": "Bekapcsol\u00e1s" }, "trigger_type": { - "device_offline": "Eszk\u00f6z offline" + "device_dropped": "A k\u00e9sz\u00fcl\u00e9k eldobva", + "device_flipped": "Eszk\u00f6z \u00e1tford\u00edtva \"{subtype}\"", + "device_knocked": "Az eszk\u00f6zt le\u00fct\u00f6tt\u00e9k \"{subtype}\"", + "device_offline": "Eszk\u00f6z offline", + "device_rotated": "Eszk\u00f6z elforgatva \"{subtype}\"", + "device_shaken": "A k\u00e9sz\u00fcl\u00e9k megr\u00e1zk\u00f3dott", + "device_slid": "Eszk\u00f6z cs\u00fasztatott \"{subtype}\"", + "device_tilted": "K\u00e9sz\u00fcl\u00e9k megd\u00f6ntve", + "remote_button_alt_double_press": "A \u201e{subtype}\u201d gombra dupl\u00e1n kattintva (Alternat\u00edv m\u00f3d)", + "remote_button_alt_long_press": "\"{subtype}\" gomb folyamatosan nyomva (alternat\u00edv m\u00f3d)", + "remote_button_alt_long_release": "A \u201e{subtype}\u201d gomb elenged\u00e9se hossz\u00fa megnyom\u00e1st k\u00f6vet\u0151en (alternat\u00edv m\u00f3d)", + "remote_button_alt_quadruple_press": "A \u201e{subtype}\u201d gombra n\u00e9gyszer kattintottak (alternat\u00edv m\u00f3d)", + "remote_button_alt_quintuple_press": "\"{subtype}\" gombra \u00f6tsz\u00f6r kattintottak (alternat\u00edv m\u00f3d)", + "remote_button_alt_short_press": "\u201e{subtype}\u201d gomb lenyomva (alternat\u00edv m\u00f3d)", + "remote_button_alt_short_release": "A \"{subtype}\" gomb elengedett (alternat\u00edv m\u00f3d)", + "remote_button_alt_triple_press": "A \u201e{subtype}\u201d gombra h\u00e1romszor kattintottak (alternat\u00edv m\u00f3d)", + "remote_button_double_press": "\"{subtype}\" gombra k\u00e9tszer kattintottak", + "remote_button_long_press": "A \"{subtype}\" gomb folyamatosan lenyomva", + "remote_button_long_release": "A \"{subtype}\" gomb hossz\u00fa megnyom\u00e1s ut\u00e1n elengedve", + "remote_button_quadruple_press": "\"{subtype}\" gombra n\u00e9gyszer kattintottak", + "remote_button_quintuple_press": "\"{subtype}\" gombra \u00f6tsz\u00f6r kattintottak", + "remote_button_short_press": "\"{subtype}\" gomb lenyomva", + "remote_button_short_release": "\"{subtype}\" gomb elengedve", + "remote_button_triple_press": "\"{subtype}\" gombra h\u00e1romszor kattintottak" } } } \ No newline at end of file From 58ccfff067e430f898ab22aac6e772060de513b7 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 6 Aug 2021 05:23:05 +0300 Subject: [PATCH 178/903] Fix Shelly last_reset (#54101) --- homeassistant/components/shelly/entity.py | 1 - homeassistant/components/shelly/sensor.py | 52 ++++++++++++++++------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index a1ce2e671d1..0d23f5abffc 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -285,7 +285,6 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): self._unit: None | str | Callable[[dict], str] = unit self._unique_id: str = f"{super().unique_id}-{self.attribute}" self._name = get_entity_name(wrapper.device, block, self.description.name) - self._last_value: str | None = None @property def unique_id(self) -> str: diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 56e4f63bc75..07e4f4a4fe3 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1,9 +1,12 @@ """Sensor for Shelly.""" from __future__ import annotations -from datetime import datetime +from datetime import timedelta +import logging from typing import Final, cast +import aioshelly + from homeassistant.components import sensor from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry @@ -23,6 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt +from . import ShellyDeviceWrapper from .const import LAST_RESET_NEVER, LAST_RESET_UPTIME, SHAIR_MAX_WORK_HOURS from .entity import ( BlockAttributeDescription, @@ -35,6 +39,8 @@ from .entity import ( ) from .utils import get_device_uptime, temperature_unit +_LOGGER: Final = logging.getLogger(__name__) + SENSORS: Final = { ("device", "battery"): BlockAttributeDescription( name="Battery", @@ -255,9 +261,39 @@ async def async_setup_entry( class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): """Represent a shelly sensor.""" + def __init__( + self, + wrapper: ShellyDeviceWrapper, + block: aioshelly.Block, + attribute: str, + description: BlockAttributeDescription, + ) -> None: + """Initialize sensor.""" + super().__init__(wrapper, block, attribute, description) + self._last_value: float | None = None + + if description.last_reset == LAST_RESET_NEVER: + self._attr_last_reset = dt.utc_from_timestamp(0) + elif description.last_reset == LAST_RESET_UPTIME: + self._attr_last_reset = ( + dt.utcnow() - timedelta(seconds=wrapper.device.status["uptime"]) + ).replace(second=0, microsecond=0) + @property def state(self) -> StateType: """Return value of sensor.""" + if ( + self.description.last_reset == LAST_RESET_UPTIME + and self.attribute_value is not None + ): + value = cast(float, self.attribute_value) + + if self._last_value and self._last_value > value: + self._attr_last_reset = dt.utcnow().replace(second=0, microsecond=0) + _LOGGER.info("Energy reset detected for entity %s", self.name) + + self._last_value = value + return self.attribute_value @property @@ -265,20 +301,6 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): """State class of sensor.""" return self.description.state_class - @property - def last_reset(self) -> datetime | None: - """State class of sensor.""" - if self.description.last_reset == LAST_RESET_UPTIME: - self._last_value = get_device_uptime( - self.wrapper.device.status, self._last_value - ) - return dt.parse_datetime(self._last_value) - - if self.description.last_reset == LAST_RESET_NEVER: - return dt.utc_from_timestamp(0) - - return None - @property def unit_of_measurement(self) -> str | None: """Return unit of sensor.""" From 46ad55455b240e91a2b053a56d522ca9921e29be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Aug 2021 21:24:09 -0500 Subject: [PATCH 179/903] Bump zeroconf to 0.33.3 (#54108) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index ee1e9a8e1ab..7b3cfa1fefd 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.33.2"], + "requirements": ["zeroconf==0.33.3"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cc4bb9d72ac..d435f165f61 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.17 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.33.2 +zeroconf==0.33.3 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 187351dace9..3aec770dc6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2442,7 +2442,7 @@ zeep[async]==4.0.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.33.2 +zeroconf==0.33.3 # homeassistant.components.zha zha-quirks==0.0.59 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e673479b616..10794fe2c94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1347,7 +1347,7 @@ youless-api==0.10 zeep[async]==4.0.0 # homeassistant.components.zeroconf -zeroconf==0.33.2 +zeroconf==0.33.3 # homeassistant.components.zha zha-quirks==0.0.59 From adc9f7549360159e94a594720cca2b20f5d7625f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Aug 2021 21:24:24 -0500 Subject: [PATCH 180/903] Increase time before scene and script HomeKit entities are reset (#54105) --- homeassistant/components/homekit/type_switches.py | 4 +++- tests/components/homekit/test_type_switches.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 381110a4e79..ef9dadff287 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -57,6 +57,8 @@ VALVE_TYPE = { ACTIVATE_ONLY_SWITCH_DOMAINS = {"scene", "script"} +ACTIVATE_ONLY_RESET_SECONDS = 10 + @TYPES.register("Outlet") class Outlet(HomeAccessory): @@ -141,7 +143,7 @@ class Switch(HomeAccessory): self.async_call_service(self._domain, service, params) if self.activate_only: - async_call_later(self.hass, 1, self.reset_switch) + async_call_later(self.hass, ACTIVATE_ONLY_RESET_SECONDS, self.reset_switch) @callback def async_update_state(self, new_state): diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 455f7a6141a..6df1f0182ed 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -329,7 +329,13 @@ async def test_reset_switch(hass, hk_driver, events): future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() + assert acc.char_on.value is True + + future = dt_util.utcnow() + timedelta(seconds=10) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert acc.char_on.value is False + assert len(events) == 1 assert not call_turn_off @@ -367,7 +373,13 @@ async def test_script_switch(hass, hk_driver, events): future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() + assert acc.char_on.value is True + + future = dt_util.utcnow() + timedelta(seconds=10) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert acc.char_on.value is False + assert len(events) == 1 assert not call_turn_off From 582f2ae2f605a2ccbd8f565dd5815e771743f2bf Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 6 Aug 2021 04:24:41 +0200 Subject: [PATCH 181/903] Two fixes (#54102) --- homeassistant/components/fritz/sensor.py | 4 ++-- homeassistant/components/fritz/switch.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index faf2be23164..d7a34564b43 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -81,12 +81,12 @@ def _retrieve_max_kb_s_received_state(status: FritzStatus, last_value: str) -> f def _retrieve_gb_sent_state(status: FritzStatus, last_value: str) -> float: """Return upload total data.""" - return round(status.bytes_sent * 8 / 1000 / 1000 / 1000, 1) # type: ignore[no-any-return] + return round(status.bytes_sent / 1000 / 1000 / 1000, 1) # type: ignore[no-any-return] def _retrieve_gb_received_state(status: FritzStatus, last_value: str) -> float: """Return download total data.""" - return round(status.bytes_received * 8 / 1000 / 1000 / 1000, 1) # type: ignore[no-any-return] + return round(status.bytes_received / 1000 / 1000 / 1000, 1) # type: ignore[no-any-return] class SensorData(TypedDict, total=False): diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 10eb6553dbd..da17bef7159 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -13,7 +13,6 @@ from fritzconnection.core.exceptions import ( FritzSecurityError, FritzServiceError, ) -import slugify as unicode_slug import xmltodict from homeassistant.components.network import async_get_source_ip @@ -248,10 +247,18 @@ def wifi_entities_list( ) if network_info: ssid = network_info["NewSSID"] - if unicode_slug.slugify(ssid, lowercase=False) in networks.values(): + _LOGGER.debug("SSID from device: <%s>", ssid) + if ( + slugify( + ssid, + ) + in [slugify(v) for v in networks.values()] + ): + _LOGGER.debug("SSID duplicated, adding suffix") networks[i] = f'{ssid} {std_table[network_info["NewStandard"]]}' else: networks[i] = ssid + _LOGGER.debug("SSID normalized: <%s>", networks[i]) return [ FritzBoxWifiSwitch(fritzbox_tools, device_friendly_name, net, network_name) From 02d691816518cd9404e44b0dc8f7791597911892 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Fri, 6 Aug 2021 06:13:47 +0200 Subject: [PATCH 182/903] Run coordinator config_entry_first_refresh in rituals_perfume_genie setup (#54080) --- homeassistant/components/rituals_perfume_genie/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index ee2a517a3f7..8a9ed5d94a3 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hublot = device.hublot coordinator = RitualsDataUpdateCoordinator(hass, device) - await coordinator.async_refresh() + await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id][DEVICES][hublot] = device hass.data[DOMAIN][entry.entry_id][COORDINATORS][hublot] = coordinator From 8ead20a76b8ab9550f5fb0da4cf2e9cb57b19715 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 6 Aug 2021 06:26:02 +0200 Subject: [PATCH 183/903] Test knx sensor (#54090) --- tests/components/knx/test_sensor.py | 95 +++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/components/knx/test_sensor.py diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py new file mode 100644 index 00000000000..16ea5e8d385 --- /dev/null +++ b/tests/components/knx/test_sensor.py @@ -0,0 +1,95 @@ +"""Test KNX sensor.""" +from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE +from homeassistant.components.knx.schema import SensorSchema +from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + +from tests.common import async_capture_events + + +async def test_sensor(hass: HomeAssistant, knx: KNXTestKit): + """Test simple KNX sensor.""" + + await knx.setup_integration( + { + SensorSchema.PLATFORM_NAME: { + CONF_NAME: "test", + CONF_STATE_ADDRESS: "1/1/1", + CONF_TYPE: "current", # 2 byte unsigned int + } + } + ) + assert len(hass.states.async_all()) == 1 + state = hass.states.get("sensor.test") + assert state.state is STATE_UNKNOWN + + # StateUpdater initialize state + await knx.assert_read("1/1/1") + await knx.receive_response("1/1/1", (0, 40)) + state = hass.states.get("sensor.test") + assert state.state == "40" + + # update from KNX + await knx.receive_write("1/1/1", (0x03, 0xE8)) + state = hass.states.get("sensor.test") + assert state.state == "1000" + + # don't answer to GroupValueRead requests + await knx.receive_read("1/1/1") + await knx.assert_no_telegram() + + +async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX sensor with always_callback.""" + + events = async_capture_events(hass, "state_changed") + await knx.setup_integration( + { + SensorSchema.PLATFORM_NAME: [ + { + CONF_NAME: "test_normal", + CONF_STATE_ADDRESS: "1/1/1", + CONF_SYNC_STATE: False, + CONF_TYPE: "percentU8", + }, + { + CONF_NAME: "test_always", + CONF_STATE_ADDRESS: "2/2/2", + SensorSchema.CONF_ALWAYS_CALLBACK: True, + CONF_SYNC_STATE: False, + CONF_TYPE: "percentU8", + }, + ] + } + ) + assert len(hass.states.async_all()) == 2 + # state changes form None to "unknown" + assert len(events) == 2 + + # receive initial telegram + await knx.receive_write("1/1/1", (0x42,)) + await knx.receive_write("2/2/2", (0x42,)) + await hass.async_block_till_done() + assert len(events) == 4 + + # receive second telegram with identical payload + # always_callback shall force state_changed event + await knx.receive_write("1/1/1", (0x42,)) + await knx.receive_write("2/2/2", (0x42,)) + await hass.async_block_till_done() + assert len(events) == 5 + + # receive telegram with different payload + await knx.receive_write("1/1/1", (0xFA,)) + await knx.receive_write("2/2/2", (0xFA,)) + await hass.async_block_till_done() + assert len(events) == 7 + + # receive telegram with second payload again + # always_callback shall force state_changed event + await knx.receive_write("1/1/1", (0xFA,)) + await knx.receive_write("2/2/2", (0xFA,)) + await hass.async_block_till_done() + assert len(events) == 8 From ab34ef475eaff94df76c9d2044fb19ef4c2c5c34 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 6 Aug 2021 06:33:20 +0200 Subject: [PATCH 184/903] Test KNX binary sensor (#53820) * test binary_sensor * test binary_sensor with reset_after --- tests/components/knx/test_binary_sensor.py | 205 +++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 tests/components/knx/test_binary_sensor.py diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py new file mode 100644 index 00000000000..48b871b85e4 --- /dev/null +++ b/tests/components/knx/test_binary_sensor.py @@ -0,0 +1,205 @@ +"""Test KNX binary sensor.""" +from datetime import timedelta + +from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE +from homeassistant.components.knx.schema import BinarySensorSchema +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from .conftest import KNXTestKit + +from tests.common import async_capture_events, async_fire_time_changed + + +async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX binary sensor and inverted binary_sensor.""" + await knx.setup_integration( + { + BinarySensorSchema.PLATFORM_NAME: [ + { + CONF_NAME: "test_normal", + CONF_STATE_ADDRESS: "1/1/1", + }, + { + CONF_NAME: "test_invert", + CONF_STATE_ADDRESS: "2/2/2", + BinarySensorSchema.CONF_INVERT: True, + }, + ] + } + ) + assert len(hass.states.async_all()) == 2 + + # StateUpdater initialize state + await knx.assert_read("1/1/1") + await knx.assert_read("2/2/2") + await knx.receive_response("1/1/1", True) + await knx.receive_response("2/2/2", False) + state_normal = hass.states.get("binary_sensor.test_normal") + state_invert = hass.states.get("binary_sensor.test_invert") + assert state_normal.state is STATE_ON + assert state_invert.state is STATE_ON + + # receive OFF telegram + await knx.receive_write("1/1/1", False) + await knx.receive_write("2/2/2", True) + state_normal = hass.states.get("binary_sensor.test_normal") + state_invert = hass.states.get("binary_sensor.test_invert") + assert state_normal.state is STATE_OFF + assert state_invert.state is STATE_OFF + + # receive ON telegram + await knx.receive_write("1/1/1", True) + await knx.receive_write("2/2/2", False) + state_normal = hass.states.get("binary_sensor.test_normal") + state_invert = hass.states.get("binary_sensor.test_invert") + assert state_normal.state is STATE_ON + assert state_invert.state is STATE_ON + + # binary_sensor does not respond to read + await knx.receive_read("1/1/1") + await knx.receive_read("2/2/2") + await knx.assert_telegram_count(0) + + +async def test_binary_sensor_ignore_internal_state( + hass: HomeAssistant, knx: KNXTestKit +): + """Test KNX binary_sensor with ignore_internal_state.""" + events = async_capture_events(hass, "state_changed") + + await knx.setup_integration( + { + BinarySensorSchema.PLATFORM_NAME: [ + { + CONF_NAME: "test_normal", + CONF_STATE_ADDRESS: "1/1/1", + CONF_SYNC_STATE: False, + }, + { + CONF_NAME: "test_ignore", + CONF_STATE_ADDRESS: "2/2/2", + BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE: True, + CONF_SYNC_STATE: False, + }, + ] + } + ) + assert len(hass.states.async_all()) == 2 + # binary_sensor defaults to STATE_OFF - state change form None + assert len(events) == 2 + + # receive initial ON telegram + await knx.receive_write("1/1/1", True) + await knx.receive_write("2/2/2", True) + await hass.async_block_till_done() + assert len(events) == 4 + + # receive second ON telegram - ignore_internal_state shall force state_changed event + await knx.receive_write("1/1/1", True) + await knx.receive_write("2/2/2", True) + await hass.async_block_till_done() + assert len(events) == 5 + + # receive first OFF telegram + await knx.receive_write("1/1/1", False) + await knx.receive_write("2/2/2", False) + await hass.async_block_till_done() + assert len(events) == 7 + + # receive second OFF telegram - ignore_internal_state shall force state_changed event + await knx.receive_write("1/1/1", False) + await knx.receive_write("2/2/2", False) + await hass.async_block_till_done() + assert len(events) == 8 + + +async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX binary_sensor with context timeout.""" + async_fire_time_changed(hass, dt.utcnow()) + events = async_capture_events(hass, "state_changed") + + await knx.setup_integration( + { + BinarySensorSchema.PLATFORM_NAME: [ + { + CONF_NAME: "test", + CONF_STATE_ADDRESS: "2/2/2", + BinarySensorSchema.CONF_CONTEXT_TIMEOUT: 1, + CONF_SYNC_STATE: False, + }, + ] + } + ) + assert len(hass.states.async_all()) == 1 + assert len(events) == 1 + events.pop() + + # receive initial ON telegram + await knx.receive_write("2/2/2", True) + await hass.async_block_till_done() + # no change yet - still in 1 sec context (additional async_block_till_done needed for time change) + assert len(events) == 0 + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_OFF + assert state.attributes.get("counter") == 0 + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + # state changed twice after context timeout - once to ON with counter 1 and once to counter 0 + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON + assert state.attributes.get("counter") == 0 + # additional async_block_till_done needed event capture + await hass.async_block_till_done() + assert len(events) == 2 + assert events.pop(0).data.get("new_state").attributes.get("counter") == 1 + assert events.pop(0).data.get("new_state").attributes.get("counter") == 0 + + # receive 2 telegrams in context + await knx.receive_write("2/2/2", True) + await knx.receive_write("2/2/2", True) + assert len(events) == 0 + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON + assert state.attributes.get("counter") == 0 + await hass.async_block_till_done() + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON + assert state.attributes.get("counter") == 0 + await hass.async_block_till_done() + assert len(events) == 2 + assert events.pop(0).data.get("new_state").attributes.get("counter") == 2 + assert events.pop(0).data.get("new_state").attributes.get("counter") == 0 + + +async def test_binary_sensor_reset(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX binary_sensor with reset_after function.""" + async_fire_time_changed(hass, dt.utcnow()) + + await knx.setup_integration( + { + BinarySensorSchema.PLATFORM_NAME: [ + { + CONF_NAME: "test", + CONF_STATE_ADDRESS: "2/2/2", + BinarySensorSchema.CONF_RESET_AFTER: 1, + CONF_SYNC_STATE: False, + }, + ] + } + ) + assert len(hass.states.async_all()) == 1 + + # receive ON telegram + await knx.receive_write("2/2/2", True) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + # state reset after after timeout + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_OFF From 1cc3ffe20dc4b03d816bf9f68a37bc42e0df2f5d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Aug 2021 21:41:50 -0700 Subject: [PATCH 185/903] Fix jinja warning (#54109) --- homeassistant/helpers/template.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 66354aa7aa6..08b3956a490 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -22,7 +22,7 @@ from urllib.parse import urlencode as urllib_urlencode import weakref import jinja2 -from jinja2 import contextfunction, pass_context +from jinja2 import pass_context from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace import voluptuous as vol @@ -1521,7 +1521,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): def wrapper(*args, **kwargs): return func(hass, *args[1:], **kwargs) - return contextfunction(wrapper) + return pass_context(wrapper) self.globals["device_entities"] = hassfunction(device_entities) self.filters["device_entities"] = pass_context(self.globals["device_entities"]) From 19adce844cbdd425530df01f1c1d36cdb113cad9 Mon Sep 17 00:00:00 2001 From: Oscar Calvo <2091582+ocalvo@users.noreply.github.com> Date: Fri, 6 Aug 2021 03:18:29 -0700 Subject: [PATCH 186/903] Gracefully handle additional GSM errors (#54114) --- homeassistant/components/sms/gateway.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 51667ef8f77..5003f7019ca 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -25,6 +25,10 @@ class Gateway: await self._worker.set_incoming_sms_async() except gammu.ERR_NOTSUPPORTED: _LOGGER.warning("Your phone does not support incoming SMS notifications!") + except gammu.GSMError: + _LOGGER.warning( + "GSM error, your phone does not support incoming SMS notifications!" + ) else: await self._worker.set_incoming_callback_async(self.sms_callback) From 206073632fdd70a92ee6c3c4ad0ba9267da2762e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 6 Aug 2021 14:59:00 +0200 Subject: [PATCH 187/903] Fix sensor PLATFORM_SCHEMA for ebox and enphase_envoy (#54142) * Fix sensor PLATFORM_SCHEMA * fix pylint --- homeassistant/components/ebox/sensor.py | 4 +++- homeassistant/components/enphase_envoy/sensor.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index e27c6fe0772..e98dea45929 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -122,10 +122,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), ) +SENSOR_TYPE_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_VARIABLES): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_TYPE_KEYS)] ), vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 29d273401f4..3af5cd1ec0c 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -1,4 +1,5 @@ """Support for Enphase Envoy solar energy monitor.""" +from __future__ import annotations import logging @@ -22,14 +23,15 @@ ICON = "mdi:flash" CONST_DEFAULT_HOST = "envoy" _LOGGER = logging.getLogger(__name__) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSORS] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_IP_ADDRESS, default=CONST_DEFAULT_HOST): cv.string, vol.Optional(CONF_USERNAME, default="envoy"): cv.string, vol.Optional(CONF_PASSWORD, default=""): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( - cv.ensure_list, [vol.In(list(SENSORS))] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=""): cv.string, } From 5f790f6bd9178f600da17215048e60d2eccf1788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Aug 2021 11:15:35 -0500 Subject: [PATCH 188/903] Fetch interface index from network integration instead of socket.if_nametoindex in zeroconf (#54152) --- homeassistant/components/network/models.py | 1 + homeassistant/components/network/util.py | 1 + homeassistant/components/zeroconf/__init__.py | 5 ++- tests/components/network/test_init.py | 32 +++++++++++++++++++ tests/components/zeroconf/test_init.py | 4 +++ 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/network/models.py b/homeassistant/components/network/models.py index a007eb8636d..d3fbc824489 100644 --- a/homeassistant/components/network/models.py +++ b/homeassistant/components/network/models.py @@ -24,6 +24,7 @@ class Adapter(TypedDict): """Configured network adapters.""" name: str + index: int enabled: bool auto: bool default: bool diff --git a/homeassistant/components/network/util.py b/homeassistant/components/network/util.py index eece4b38548..f8b33b3df90 100644 --- a/homeassistant/components/network/util.py +++ b/homeassistant/components/network/util.py @@ -116,6 +116,7 @@ def _ifaddr_adapter_to_ha( return { "name": adapter.nice_name, + "index": adapter.index, "enabled": False, "auto": auto, "default": default, diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 4c4c81aff32..cdb46318578 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -155,9 +155,8 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: for ipv4 in ipv4s if not ipaddress.ip_address(ipv4["address"]).is_loopback ) - if adapter["ipv6"]: - ifi = socket.if_nametoindex(adapter["name"]) - interfaces.append(ifi) + if adapter["ipv6"] and adapter["index"] not in interfaces: + interfaces.append(adapter["index"]) ipv6 = True if not any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index bc4c543842f..6a85f5ea9e8 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -21,15 +21,19 @@ 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] @@ -51,6 +55,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route(hass, hass_sto assert network_obj.adapters == [ { "auto": False, + "index": 1, "default": False, "enabled": False, "ipv4": [], @@ -65,6 +70,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route(hass, hass_sto "name": "eth0", }, { + "index": 0, "auto": False, "default": False, "enabled": False, @@ -73,6 +79,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route(hass, hass_sto "name": "lo0", }, { + "index": 2, "auto": True, "default": True, "enabled": True, @@ -81,6 +88,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route(hass, hass_sto "name": "eth1", }, { + "index": 3, "auto": False, "default": False, "enabled": False, @@ -107,6 +115,7 @@ async def test_async_detect_interfaces_setting_loopback_route(hass, hass_storage assert network_obj.configured_adapters == [] assert network_obj.adapters == [ { + "index": 1, "auto": True, "default": False, "enabled": True, @@ -122,6 +131,7 @@ async def test_async_detect_interfaces_setting_loopback_route(hass, hass_storage "name": "eth0", }, { + "index": 0, "auto": False, "default": True, "enabled": False, @@ -130,6 +140,7 @@ async def test_async_detect_interfaces_setting_loopback_route(hass, hass_storage "name": "lo0", }, { + "index": 2, "auto": True, "default": False, "enabled": True, @@ -138,6 +149,7 @@ async def test_async_detect_interfaces_setting_loopback_route(hass, hass_storage "name": "eth1", }, { + "index": 3, "auto": False, "default": False, "enabled": False, @@ -165,6 +177,7 @@ async def test_async_detect_interfaces_setting_empty_route(hass, hass_storage): assert network_obj.adapters == [ { "auto": True, + "index": 1, "default": False, "enabled": True, "ipv4": [], @@ -180,6 +193,7 @@ async def test_async_detect_interfaces_setting_empty_route(hass, hass_storage): }, { "auto": False, + "index": 0, "default": False, "enabled": False, "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], @@ -188,6 +202,7 @@ async def test_async_detect_interfaces_setting_empty_route(hass, hass_storage): }, { "auto": True, + "index": 2, "default": False, "enabled": True, "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], @@ -196,6 +211,7 @@ async def test_async_detect_interfaces_setting_empty_route(hass, hass_storage): }, { "auto": False, + "index": 3, "default": False, "enabled": False, "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], @@ -222,6 +238,7 @@ async def test_async_detect_interfaces_setting_exception(hass, hass_storage): assert network_obj.adapters == [ { "auto": True, + "index": 1, "default": False, "enabled": True, "ipv4": [], @@ -237,6 +254,7 @@ async def test_async_detect_interfaces_setting_exception(hass, hass_storage): }, { "auto": False, + "index": 0, "default": False, "enabled": False, "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], @@ -245,6 +263,7 @@ async def test_async_detect_interfaces_setting_exception(hass, hass_storage): }, { "auto": True, + "index": 2, "default": False, "enabled": True, "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], @@ -253,6 +272,7 @@ async def test_async_detect_interfaces_setting_exception(hass, hass_storage): }, { "auto": False, + "index": 3, "default": False, "enabled": False, "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], @@ -285,6 +305,7 @@ async def test_interfaces_configured_from_storage(hass, hass_storage): assert network_obj.adapters == [ { "auto": False, + "index": 1, "default": False, "enabled": True, "ipv4": [], @@ -300,6 +321,7 @@ async def test_interfaces_configured_from_storage(hass, hass_storage): }, { "auto": False, + "index": 0, "default": False, "enabled": False, "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], @@ -308,6 +330,7 @@ async def test_interfaces_configured_from_storage(hass, hass_storage): }, { "auto": True, + "index": 2, "default": True, "enabled": True, "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], @@ -316,6 +339,7 @@ async def test_interfaces_configured_from_storage(hass, hass_storage): }, { "auto": False, + "index": 3, "default": False, "enabled": True, "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], @@ -356,6 +380,7 @@ async def test_interfaces_configured_from_storage_websocket_update( assert response["result"][ATTR_ADAPTERS] == [ { "auto": False, + "index": 1, "default": False, "enabled": True, "ipv4": [], @@ -371,6 +396,7 @@ async def test_interfaces_configured_from_storage_websocket_update( }, { "auto": False, + "index": 0, "default": False, "enabled": False, "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], @@ -379,6 +405,7 @@ async def test_interfaces_configured_from_storage_websocket_update( }, { "auto": True, + "index": 2, "default": True, "enabled": True, "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], @@ -387,6 +414,7 @@ async def test_interfaces_configured_from_storage_websocket_update( }, { "auto": False, + "index": 3, "default": False, "enabled": True, "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], @@ -407,6 +435,7 @@ async def test_interfaces_configured_from_storage_websocket_update( assert response["result"][ATTR_ADAPTERS] == [ { "auto": False, + "index": 1, "default": False, "enabled": False, "ipv4": [], @@ -422,6 +451,7 @@ async def test_interfaces_configured_from_storage_websocket_update( }, { "auto": False, + "index": 0, "default": False, "enabled": False, "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], @@ -430,6 +460,7 @@ async def test_interfaces_configured_from_storage_websocket_update( }, { "auto": True, + "index": 2, "default": True, "enabled": True, "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], @@ -438,6 +469,7 @@ async def test_interfaces_configured_from_storage_websocket_update( }, { "auto": False, + "index": 3, "default": False, "enabled": False, "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 3b8cf883a13..e1e346621fe 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -725,6 +725,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route( _ADAPTERS_WITH_MANUAL_CONFIG = [ { "auto": True, + "index": 1, "default": False, "enabled": True, "ipv4": [], @@ -746,6 +747,7 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ }, { "auto": True, + "index": 2, "default": False, "enabled": True, "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], @@ -754,6 +756,7 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ }, { "auto": True, + "index": 3, "default": False, "enabled": True, "ipv4": [{"address": "172.16.1.5", "network_prefix": 23}], @@ -769,6 +772,7 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ }, { "auto": False, + "index": 4, "default": False, "enabled": False, "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], From 2db278a7a7ef7042d95fa4af08c159079f81b195 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 6 Aug 2021 12:29:52 -0400 Subject: [PATCH 189/903] Fix Squeezebox dhcp discovery (#54137) * Fix Squeezebox dhcp discovery and allow ignore * Test ignoring known Squeezebox players * Fix linter errors --- .../components/squeezebox/browse_media.py | 1 - .../components/squeezebox/config_flow.py | 45 ++++++++++++------- .../components/squeezebox/media_player.py | 9 ++-- .../components/squeezebox/test_config_flow.py | 38 ++++++++++++---- 4 files changed, 62 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 4c0ec186707..294a1105a71 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -137,7 +137,6 @@ async def build_item_response(entity, player, payload): async def library_payload(player): """Create response payload to describe contents of library.""" - library_info = { "title": "Music Library", "media_class": MEDIA_CLASS_DIRECTORY, diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 1f1c23942db..4b05588e281 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -5,8 +5,9 @@ import logging from pysqueezebox import Server, async_discover import voluptuous as vol -from homeassistant import config_entries -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.dhcp import MAC_ADDRESS +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -15,6 +16,8 @@ from homeassistant.const import ( HTTP_UNAUTHORIZED, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_registry import async_get from .const import DEFAULT_PORT, DOMAIN @@ -166,28 +169,18 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason=error) return self.async_create_entry(title=config[CONF_HOST], data=config) - async def async_step_discovery(self, discovery_info): - """Handle discovery.""" - _LOGGER.debug("Reached discovery flow with info: %s", discovery_info) + async def async_step_integration_discovery(self, discovery_info): + """Handle discovery of a server.""" + _LOGGER.debug("Reached server discovery flow with info: %s", discovery_info) if "uuid" in discovery_info: await self.async_set_unique_id(discovery_info.pop("uuid")) self._abort_if_unique_id_configured() else: # attempt to connect to server and determine uuid. will fail if # password required - - if CONF_HOST not in discovery_info and IP_ADDRESS in discovery_info: - discovery_info[CONF_HOST] = discovery_info[IP_ADDRESS] - - if CONF_PORT not in discovery_info: - discovery_info[CONF_PORT] = DEFAULT_PORT - error = await self._validate_input(discovery_info) if error: - if MAC_ADDRESS in discovery_info: - await self.async_set_unique_id(discovery_info[MAC_ADDRESS]) - else: - await self._async_handle_discovery_without_unique_id() + await self._async_handle_discovery_without_unique_id() # update schema with suggested values from discovery self.data_schema = _base_schema(discovery_info) @@ -195,3 +188,23 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context.update({"title_placeholders": {"host": discovery_info[CONF_HOST]}}) return await self.async_step_edit() + + async def async_step_dhcp(self, discovery_info): + """Handle dhcp discovery of a Squeezebox player.""" + _LOGGER.debug( + "Reached dhcp discovery of a player with info: %s", discovery_info + ) + await self.async_set_unique_id(format_mac(discovery_info[MAC_ADDRESS])) + self._abort_if_unique_id_configured() + + _LOGGER.debug("Configuring dhcp player with unique id: %s", self.unique_id) + + registry = async_get(self.hass) + + # if we have detected this player, do nothing. if not, there must be a server out there for us to configure, so start the normal user flow (which tries to autodetect server) + if registry.async_get_entity_id(MP_DOMAIN, DOMAIN, self.unique_id) is not None: + # this player is already known, so do nothing other than mark as configured + raise data_entry_flow.AbortFlow("already_configured") + + # if the player is unknown, then we likely need to configure its server + return await self.async_step_user() diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index baf8a011c65..1ba406097d7 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -27,7 +27,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) -from homeassistant.config_entries import SOURCE_DISCOVERY +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY from homeassistant.const import ( ATTR_COMMAND, CONF_HOST, @@ -43,6 +43,7 @@ from homeassistant.const import ( from homeassistant.core import callback 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 format_mac from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -127,7 +128,7 @@ async def start_server_discovery(hass): asyncio.create_task( hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_DISCOVERY}, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, data={ CONF_HOST: server.host, CONF_PORT: int(server.port), @@ -146,7 +147,6 @@ async def start_server_discovery(hass): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up squeezebox platform from platform entry in configuration.yaml (deprecated).""" - if config: await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config @@ -283,7 +283,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): @property def unique_id(self): """Return a unique ID.""" - return self._player.player_id + return format_mac(self._player.player_id) @property def available(self): @@ -573,7 +573,6 @@ class SqueezeBoxEntity(MediaPlayerEntity): async def async_browse_media(self, media_content_type=None, media_content_id=None): """Implement the websocket media browsing helper.""" - _LOGGER.debug( "Reached async_browse_media with content_type %s and content_id %s", media_content_type, diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index e740ea671cd..9460e2235be 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -178,7 +178,7 @@ async def test_discovery(hass): ): result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DISCOVERY}, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={CONF_HOST: HOST, CONF_PORT: PORT, "uuid": UUID}, ) assert result["type"] == RESULT_TYPE_FORM @@ -190,7 +190,7 @@ async def test_discovery_no_uuid(hass): with patch("pysqueezebox.Server.async_query", new=patch_async_query_unauthorized): result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DISCOVERY}, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={CONF_HOST: HOST, CONF_PORT: PORT}, ) assert result["type"] == RESULT_TYPE_FORM @@ -199,9 +199,8 @@ async def test_discovery_no_uuid(hass): async def test_dhcp_discovery(hass): """Test we can process discovery from dhcp.""" - with patch( - "pysqueezebox.Server.async_query", - return_value={"uuid": UUID}, + with patch("pysqueezebox.Server.async_query", return_value={"uuid": UUID},), patch( + "homeassistant.components.squeezebox.config_flow.async_discover", mock_discover ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -216,9 +215,12 @@ async def test_dhcp_discovery(hass): assert result["step_id"] == "edit" -async def test_dhcp_discovery_no_connection(hass): - """Test we can process discovery from dhcp without connecting to squeezebox server.""" - with patch("pysqueezebox.Server.async_query", new=patch_async_query_unauthorized): +async def test_dhcp_discovery_no_server_found(hass): + """Test we can handle dhcp discovery when no server is found.""" + with patch( + "homeassistant.components.squeezebox.config_flow.async_discover", + mock_failed_discover, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -229,7 +231,25 @@ async def test_dhcp_discovery_no_connection(hass): }, ) assert result["type"] == RESULT_TYPE_FORM - assert result["step_id"] == "edit" + assert result["step_id"] == "user" + + +async def test_dhcp_discovery_existing_player(hass): + """Test that we properly ignore known players during dhcp discover.""" + with patch( + "homeassistant.helpers.entity_registry.EntityRegistry.async_get_entity_id", + return_value="test_entity", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={ + IP_ADDRESS: "1.1.1.1", + MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", + HOSTNAME: "any", + }, + ) + assert result["type"] == RESULT_TYPE_ABORT async def test_import(hass): From d842fc288fce35f90222528682e783ec5cba0597 Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Fri, 6 Aug 2021 17:34:21 +0100 Subject: [PATCH 190/903] Ignore Coinbase vault wallets (#54133) * Exclude vault balances * Update option flow validation * Update test name * Add missed check * Fix dangerous default --- .../components/coinbase/config_flow.py | 8 +++- homeassistant/components/coinbase/const.py | 2 + homeassistant/components/coinbase/sensor.py | 16 ++++++-- tests/components/coinbase/common.py | 10 ++--- tests/components/coinbase/const.py | 14 ++++++- tests/components/coinbase/test_config_flow.py | 4 +- tests/components/coinbase/test_init.py | 41 +++++++++++++++---- 7 files changed, 74 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 4ea36dad266..5901aeeed9a 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -14,6 +14,8 @@ from . import get_accounts from .const import ( API_ACCOUNT_CURRENCY, API_RATES, + API_RESOURCE_TYPE, + API_TYPE_VAULT, CONF_CURRENCIES, CONF_EXCHANGE_BASE, CONF_EXCHANGE_RATES, @@ -65,7 +67,11 @@ async def validate_options( accounts = await hass.async_add_executor_job(get_accounts, client) - accounts_currencies = [account[API_ACCOUNT_CURRENCY] for account in accounts] + accounts_currencies = [ + account[API_ACCOUNT_CURRENCY] + for account in accounts + if account[API_RESOURCE_TYPE] != API_TYPE_VAULT + ] available_rates = await hass.async_add_executor_job(client.get_exchange_rates) if CONF_CURRENCIES in options: for currency in options[CONF_CURRENCIES]: diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index a7ed0b15986..dc2922d1531 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -18,6 +18,8 @@ API_ACCOUNT_NATIVE_BALANCE = "native_balance" API_ACCOUNT_NAME = "name" API_ACCOUNTS_DATA = "data" API_RATES = "rates" +API_RESOURCE_TYPE = "type" +API_TYPE_VAULT = "vault" WALLETS = { "1INCH": "1INCH", diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index c86f21bac1d..f836a604f6a 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -12,6 +12,8 @@ from .const import ( API_ACCOUNT_NAME, API_ACCOUNT_NATIVE_BALANCE, API_RATES, + API_RESOURCE_TYPE, + API_TYPE_VAULT, CONF_CURRENCIES, CONF_EXCHANGE_RATES, DOMAIN, @@ -41,7 +43,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] provided_currencies = [ - account[API_ACCOUNT_CURRENCY] for account in instance.accounts + account[API_ACCOUNT_CURRENCY] + for account in instance.accounts + if account[API_RESOURCE_TYPE] != API_TYPE_VAULT ] desired_currencies = [] @@ -82,7 +86,10 @@ class AccountSensor(SensorEntity): self._coinbase_data = coinbase_data self._currency = currency for account in coinbase_data.accounts: - if account[API_ACCOUNT_CURRENCY] == currency: + if ( + account[API_ACCOUNT_CURRENCY] == currency + and account[API_RESOURCE_TYPE] != API_TYPE_VAULT + ): self._name = f"Coinbase {account[API_ACCOUNT_NAME]}" self._id = ( f"coinbase-{account[API_ACCOUNT_ID]}-wallet-" @@ -135,7 +142,10 @@ class AccountSensor(SensorEntity): """Get the latest state of the sensor.""" self._coinbase_data.update() for account in self._coinbase_data.accounts: - if account[API_ACCOUNT_CURRENCY] == self._currency: + if ( + account[API_ACCOUNT_CURRENCY] == self._currency + and account[API_RESOURCE_TYPE] != API_TYPE_VAULT + ): self._state = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ API_ACCOUNT_AMOUNT diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py index 5fcab6605bd..231a5128585 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -6,7 +6,7 @@ from homeassistant.components.coinbase.const import ( ) from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN -from .const import GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2, MOCK_ACCOUNTS_RESPONSE +from .const import GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2, MOCK_ACCOUNTS_RESPONSE from tests.common import MockConfigEntry @@ -60,11 +60,11 @@ def mock_get_exchange_rates(): """Return a heavily reduced mock list of exchange rates for testing.""" return { "currency": "USD", - "rates": {GOOD_EXCHNAGE_RATE_2: "0.109", GOOD_EXCHNAGE_RATE: "0.00002"}, + "rates": {GOOD_EXCHANGE_RATE_2: "0.109", GOOD_EXCHANGE_RATE: "0.00002"}, } -async def init_mock_coinbase(hass): +async def init_mock_coinbase(hass, currencies=None, rates=None): """Init Coinbase integration for testing.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -72,8 +72,8 @@ async def init_mock_coinbase(hass): title="Test User", data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, options={ - CONF_CURRENCIES: [], - CONF_EXCHANGE_RATES: [], + CONF_CURRENCIES: currencies or [], + CONF_EXCHANGE_RATES: rates or [], }, ) config_entry.add_to_hass(hass) diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py index 7d36d0be9a7..082c986aa59 100644 --- a/tests/components/coinbase/const.py +++ b/tests/components/coinbase/const.py @@ -3,8 +3,8 @@ GOOD_CURRENCY = "BTC" GOOD_CURRENCY_2 = "USD" GOOD_CURRENCY_3 = "EUR" -GOOD_EXCHNAGE_RATE = "BTC" -GOOD_EXCHNAGE_RATE_2 = "ATOM" +GOOD_EXCHANGE_RATE = "BTC" +GOOD_EXCHANGE_RATE_2 = "ATOM" BAD_CURRENCY = "ETH" BAD_EXCHANGE_RATE = "ETH" @@ -15,6 +15,15 @@ MOCK_ACCOUNTS_RESPONSE = [ "id": "123456789", "name": "BTC Wallet", "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, + "type": "wallet", + }, + { + "balance": {"amount": "100.00", "currency": GOOD_CURRENCY}, + "currency": GOOD_CURRENCY, + "id": "abcdefg", + "name": "BTC Vault", + "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, + "type": "vault", }, { "balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, @@ -22,5 +31,6 @@ MOCK_ACCOUNTS_RESPONSE = [ "id": "987654321", "name": "USD Wallet", "native_balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, + "type": "fiat", }, ] diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index d153cecc249..fa13648ee71 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -19,7 +19,7 @@ from .common import ( mock_get_exchange_rates, mocked_get_accounts, ) -from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHNAGE_RATE +from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE from tests.common import MockConfigEntry @@ -160,7 +160,7 @@ async def test_option_form(hass): result["flow_id"], user_input={ CONF_CURRENCIES: [GOOD_CURRENCY], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE], + CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE], }, ) assert result2["type"] == "create_entry" diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py index 36f0ff95472..efb5ba85f73 100644 --- a/tests/components/coinbase/test_init.py +++ b/tests/components/coinbase/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components.coinbase.const import ( + API_TYPE_VAULT, CONF_CURRENCIES, CONF_EXCHANGE_RATES, CONF_YAML_API_TOKEN, @@ -22,8 +23,8 @@ from .common import ( from .const import ( GOOD_CURRENCY, GOOD_CURRENCY_2, - GOOD_EXCHNAGE_RATE, - GOOD_EXCHNAGE_RATE_2, + GOOD_EXCHANGE_RATE, + GOOD_EXCHANGE_RATE_2, ) @@ -34,7 +35,7 @@ async def test_setup(hass): CONF_API_KEY: "123456", CONF_YAML_API_TOKEN: "AbCDeF", CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2], + CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2], } } with patch( @@ -54,7 +55,7 @@ async def test_setup(hass): assert entries[0].source == config_entries.SOURCE_IMPORT assert entries[0].options == { CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2], + CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2], } @@ -103,7 +104,7 @@ async def test_option_updates(hass: HomeAssistant): result["flow_id"], user_input={ CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2], + CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2], }, ) await hass.async_block_till_done() @@ -126,7 +127,7 @@ async def test_option_updates(hass: HomeAssistant): ] assert currencies == [GOOD_CURRENCY, GOOD_CURRENCY_2] - assert rates == [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2] + assert rates == [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2] result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() @@ -134,7 +135,7 @@ async def test_option_updates(hass: HomeAssistant): result["flow_id"], user_input={ CONF_CURRENCIES: [GOOD_CURRENCY], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE], + CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE], }, ) await hass.async_block_till_done() @@ -157,4 +158,28 @@ async def test_option_updates(hass: HomeAssistant): ] assert currencies == [GOOD_CURRENCY] - assert rates == [GOOD_EXCHNAGE_RATE] + assert rates == [GOOD_EXCHANGE_RATE] + + +async def test_ignore_vaults_wallets(hass: HomeAssistant): + """Test vaults are ignored in wallet sensors.""" + + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + config_entry = await init_mock_coinbase(hass, currencies=[GOOD_CURRENCY]) + await hass.async_block_till_done() + + registry = entity_registry.async_get(hass) + entities = entity_registry.async_entries_for_config_entry( + registry, config_entry.entry_id + ) + assert len(entities) == 1 + entity = entities[0] + assert API_TYPE_VAULT not in entity.original_name.lower() From 483a4535c8659bdf79199fee86fd66fc548b1301 Mon Sep 17 00:00:00 2001 From: Niccolo Zapponi Date: Fri, 6 Aug 2021 17:34:42 +0100 Subject: [PATCH 191/903] Handle software version being None when setting up HomeKit accessories (#54130) * Convert all HomeKit service info to string prior to checking for max length * Added check for None software version * Added test case for numeric version number * Update tests/components/homekit/test_accessories.py Co-authored-by: J. Nick Koston * Fix style & none version test * Fix test * revert other change since it should be covered by the format_sw_version fix Co-authored-by: J. Nick Koston --- .../components/homekit/accessories.py | 3 +- tests/components/homekit/test_accessories.py | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index c1f5078e2d6..c3fac44486c 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -238,9 +238,10 @@ class HomeAccessory(Accessory): model = self.config[ATTR_MODEL] else: model = domain.title() + sw_version = None if self.config.get(ATTR_SW_VERSION) is not None: sw_version = format_sw_version(self.config[ATTR_SW_VERSION]) - else: + if sw_version is None: sw_version = __version__ self.set_info_service( diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 5904d1c11c6..975864b42d5 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -133,6 +133,39 @@ async def test_home_accessory(hass, hk_driver): ) assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == "0.4.3" + acc4 = HomeAccessory( + hass, + hk_driver, + "Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length", + entity_id2, + 3, + { + ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", + ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", + ATTR_SW_VERSION: "will_not_match_regex", + ATTR_INTEGRATION: "luxe that exceeds the maximum maximum maximum maximum maximum maximum length", + }, + ) + assert acc4.available is False + serv = acc4.services[0] # SERV_ACCESSORY_INFO + assert ( + serv.get_characteristic(CHAR_NAME).value + == "Home Accessory that exceeds the maximum maximum maximum maximum " + ) + assert ( + serv.get_characteristic(CHAR_MANUFACTURER).value + == "Lux Brands that exceeds the maximum maximum maximum maximum maxi" + ) + assert ( + serv.get_characteristic(CHAR_MODEL).value + == "Awesome Model that exceeds the maximum maximum maximum maximum m" + ) + assert ( + serv.get_characteristic(CHAR_SERIAL_NUMBER).value + == "light.accessory_that_exceeds_the_maximum_maximum_maximum_maximum" + ) + assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == hass_version + hass.states.async_set(entity_id, "on") await hass.async_block_till_done() with patch( From ddbd4558271fad4dfa24935f2eddeac287e91c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zoe=20=E2=9C=A8?= <87827343+zoeisnowooze@users.noreply.github.com> Date: Fri, 6 Aug 2021 12:56:27 -0400 Subject: [PATCH 192/903] Add statistics support for the PVOutput sensor (#54149) --- homeassistant/components/pvoutput/sensor.py | 42 ++++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index 5744dbfff9a..305615e4b2c 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -1,7 +1,10 @@ """Support for getting collected information from PVOutput.""" +from __future__ import annotations + from collections import namedtuple -from datetime import timedelta +from datetime import datetime, timedelta import logging +from typing import cast import voluptuous as vol @@ -9,6 +12,7 @@ from homeassistant.components.rest.data import RestData from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, SensorEntity, ) from homeassistant.const import ( @@ -22,6 +26,8 @@ from homeassistant.const import ( ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) _ENDPOINT = "http://pvoutput.org/service/r2/getstatus.jsp" @@ -68,12 +74,15 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities([PvoutputSensor(rest, name)]) -class PvoutputSensor(SensorEntity): +class PvoutputSensor(SensorEntity, RestoreEntity): """Representation of a PVOutput sensor.""" + _attr_state_class = STATE_CLASS_MEASUREMENT _attr_device_class = DEVICE_CLASS_ENERGY _attr_unit_of_measurement = ENERGY_WATT_HOUR + _old_state: int | None = None + def __init__(self, rest, name): """Initialize a PVOutput sensor.""" self.rest = rest @@ -120,8 +129,37 @@ class PvoutputSensor(SensorEntity): await self.rest.async_update() self._async_update_from_rest_data() + new_state: int | None = None + state = cast("str | None", self.state) + if state is not None: + new_state = int(state) + + did_reset = False + if new_state is None: + did_reset = False + elif self._old_state is None: + did_reset = True + elif new_state == 0: + did_reset = self._old_state != 0 + elif new_state < self._old_state: + did_reset = True + + if did_reset: + self._attr_last_reset = dt_util.utcnow() + + if new_state is not None: + self._old_state = new_state + async def async_added_to_hass(self): """Ensure the data from the initial update is reflected in the state.""" + last_state = await self.async_get_last_state() + if last_state is not None: + if "last_reset" in last_state.attributes: + self._attr_last_reset = dt_util.as_utc( + datetime.fromisoformat(last_state.attributes["last_reset"]) + ) + self._old_state = int(last_state.state) + self._async_update_from_rest_data() @callback From 13e7cd237eacfa2d9b33e4d225de90236b12882c Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 6 Aug 2021 18:56:51 +0200 Subject: [PATCH 193/903] Convert to using sensor descriptors (#54115) --- homeassistant/components/rfxtrx/__init__.py | 46 ---- .../components/rfxtrx/binary_sensor.py | 60 +++-- homeassistant/components/rfxtrx/sensor.py | 233 ++++++++++++++---- 3 files changed, 232 insertions(+), 107 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 44e1d537408..34b7c01600a 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -1,7 +1,6 @@ """Support for RFXtrx devices.""" import asyncio import binascii -from collections import OrderedDict import copy import functools import logging @@ -22,20 +21,7 @@ from homeassistant.const import ( CONF_DEVICES, CONF_HOST, CONF_PORT, - DEGREE, - ELECTRIC_CURRENT_AMPERE, - ELECTRIC_POTENTIAL_VOLT, - ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_STOP, - LENGTH_MILLIMETERS, - PERCENTAGE, - POWER_WATT, - PRECIPITATION_MILLIMETERS_PER_HOUR, - PRESSURE_HPA, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - SPEED_METERS_PER_SECOND, - TEMP_CELSIUS, - UV_INDEX, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -66,38 +52,6 @@ DEFAULT_SIGNAL_REPETITIONS = 1 SIGNAL_EVENT = f"{DOMAIN}_event" -DATA_TYPES = OrderedDict( - [ - ("Temperature", TEMP_CELSIUS), - ("Temperature2", TEMP_CELSIUS), - ("Humidity", PERCENTAGE), - ("Barometer", PRESSURE_HPA), - ("Wind direction", DEGREE), - ("Rain rate", PRECIPITATION_MILLIMETERS_PER_HOUR), - ("Energy usage", POWER_WATT), - ("Total usage", ENERGY_KILO_WATT_HOUR), - ("Sound", None), - ("Sensor Status", None), - ("Counter value", "count"), - ("UV", UV_INDEX), - ("Humidity status", None), - ("Forecast", None), - ("Forecast numeric", None), - ("Rain total", LENGTH_MILLIMETERS), - ("Wind average speed", SPEED_METERS_PER_SECOND), - ("Wind gust", SPEED_METERS_PER_SECOND), - ("Chill", TEMP_CELSIUS), - ("Count", "count"), - ("Current Ch. 1", ELECTRIC_CURRENT_AMPERE), - ("Current Ch. 2", ELECTRIC_CURRENT_AMPERE), - ("Current Ch. 3", ELECTRIC_CURRENT_AMPERE), - ("Voltage", ELECTRIC_POTENTIAL_VOLT), - ("Current", ELECTRIC_CURRENT_AMPERE), - ("Battery numeric", PERCENTAGE), - ("Rssi numeric", SIGNAL_STRENGTH_DECIBELS_MILLIWATT), - ] -) - _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 9e3d24cdb6a..d697c56f7e8 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -1,4 +1,7 @@ """Support for RFXtrx binary sensors.""" +from __future__ import annotations + +from dataclasses import replace import logging import RFXtrx as rfxtrxmod @@ -7,6 +10,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_SMOKE, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import ( CONF_COMMAND_OFF, @@ -51,13 +55,30 @@ SENSOR_STATUS_OFF = [ "Normal Tamper", ] -DEVICE_TYPE_DEVICE_CLASS = { - "X10 Security Motion Detector": DEVICE_CLASS_MOTION, - "KD101 Smoke Detector": DEVICE_CLASS_SMOKE, - "Visonic Powercode Motion Detector": DEVICE_CLASS_MOTION, - "Alecto SA30 Smoke Detector": DEVICE_CLASS_SMOKE, - "RM174RF Smoke Detector": DEVICE_CLASS_SMOKE, -} +SENSOR_TYPES = ( + BinarySensorEntityDescription( + key="X10 Security Motion Detector", + device_class=DEVICE_CLASS_MOTION, + ), + BinarySensorEntityDescription( + key="KD101 Smoke Detector", + device_class=DEVICE_CLASS_SMOKE, + ), + BinarySensorEntityDescription( + key="Visonic Powercode Motion Detector", + device_class=DEVICE_CLASS_MOTION, + ), + BinarySensorEntityDescription( + key="Alecto SA30 Smoke Detector", + device_class=DEVICE_CLASS_SMOKE, + ), + BinarySensorEntityDescription( + key="RM174RF Smoke Detector", + device_class=DEVICE_CLASS_SMOKE, + ), +) + +SENSOR_TYPES_DICT = {desc.key: desc for desc in SENSOR_TYPES} def supported(event): @@ -85,6 +106,14 @@ async def async_setup_entry( discovery_info = config_entry.data + def get_sensor_description(type_string: str, device_class: str | None = None): + description = SENSOR_TYPES_DICT.get(type_string) + if description is None: + description = BinarySensorEntityDescription(key=type_string) + if device_class: + description = replace(description, device_class=device) + return description + for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): event = get_rfx_object(packet_id) if event is None: @@ -107,9 +136,8 @@ async def async_setup_entry( device = RfxtrxBinarySensor( event.device, device_id, - entity_info.get( - CONF_DEVICE_CLASS, - DEVICE_TYPE_DEVICE_CLASS.get(event.device.type_string), + get_sensor_description( + event.device.type_string, entity_info.get(CONF_DEVICE_CLASS) ), entity_info.get(CONF_OFF_DELAY), entity_info.get(CONF_DATA_BITS), @@ -137,11 +165,12 @@ async def async_setup_entry( event.device.subtype, "".join(f"{x:02x}" for x in event.data), ) + sensor = RfxtrxBinarySensor( event.device, device_id, event=event, - device_class=DEVICE_TYPE_DEVICE_CLASS.get(event.device.type_string), + entity_description=get_sensor_description(event.device.type_string), ) async_add_entities([sensor]) @@ -156,7 +185,7 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): self, device, device_id, - device_class=None, + entity_description, off_delay=None, data_bits=None, cmd_on=None, @@ -165,7 +194,7 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): ): """Initialize the RFXtrx sensor.""" super().__init__(device, device_id, event=event) - self._device_class = device_class + self.entity_description = entity_description self._data_bits = data_bits self._off_delay = off_delay self._state = None @@ -190,11 +219,6 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): """We should force updates. Repeated states have meaning.""" return True - @property - def device_class(self): - """Return the sensor class.""" - return self._device_class - @property def is_on(self): """Return true if the sensor state is True.""" diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 72cd9f6bbf6..8b9d5e5c389 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -1,5 +1,9 @@ """Support for RFXtrx sensors.""" +from __future__ import annotations + +from dataclasses import dataclass import logging +from typing import Callable from RFXtrx import ControlEvent, SensorEvent @@ -8,21 +12,36 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import ( CONF_DEVICES, + DEGREE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + LENGTH_MILLIMETERS, + PERCENTAGE, + POWER_WATT, + PRECIPITATION_MILLIMETERS_PER_HOUR, + PRESSURE_HPA, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, + UV_INDEX, ) from homeassistant.core import callback +from homeassistant.util import dt from . import ( CONF_DATA_BITS, - DATA_TYPES, RfxtrxEntity, connect_auto_add, get_device_id, @@ -47,25 +66,161 @@ def _rssi_convert(value): return f"{value*8-120}" -DEVICE_CLASSES = { - "Barometer": DEVICE_CLASS_PRESSURE, - "Battery numeric": DEVICE_CLASS_BATTERY, - "Current Ch. 1": DEVICE_CLASS_CURRENT, - "Current Ch. 2": DEVICE_CLASS_CURRENT, - "Current Ch. 3": DEVICE_CLASS_CURRENT, - "Energy usage": DEVICE_CLASS_POWER, - "Humidity": DEVICE_CLASS_HUMIDITY, - "Rssi numeric": DEVICE_CLASS_SIGNAL_STRENGTH, - "Temperature": DEVICE_CLASS_TEMPERATURE, - "Total usage": DEVICE_CLASS_ENERGY, - "Voltage": DEVICE_CLASS_VOLTAGE, -} +@dataclass +class RfxtrxSensorEntityDescription(SensorEntityDescription): + """Description of sensor entities.""" + + convert: Callable = lambda x: x -CONVERT_FUNCTIONS = { - "Battery numeric": _battery_convert, - "Rssi numeric": _rssi_convert, -} +SENSOR_TYPES = ( + RfxtrxSensorEntityDescription( + key="Barameter", + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=PRESSURE_HPA, + ), + RfxtrxSensorEntityDescription( + key="Battery numeric", + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=PERCENTAGE, + convert=_battery_convert, + ), + RfxtrxSensorEntityDescription( + key="Current", + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + ), + RfxtrxSensorEntityDescription( + key="Current Ch. 1", + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + ), + RfxtrxSensorEntityDescription( + key="Current Ch. 2", + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + ), + RfxtrxSensorEntityDescription( + key="Current Ch. 3", + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + ), + RfxtrxSensorEntityDescription( + key="Energy usage", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=POWER_WATT, + ), + RfxtrxSensorEntityDescription( + key="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=PERCENTAGE, + ), + RfxtrxSensorEntityDescription( + key="Rssi numeric", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + convert=_rssi_convert, + ), + RfxtrxSensorEntityDescription( + key="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=TEMP_CELSIUS, + ), + RfxtrxSensorEntityDescription( + key="Temperature2", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=TEMP_CELSIUS, + ), + RfxtrxSensorEntityDescription( + key="Total usage", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + RfxtrxSensorEntityDescription( + key="Voltage", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + ), + RfxtrxSensorEntityDescription( + key="Wind direction", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=DEGREE, + ), + RfxtrxSensorEntityDescription( + key="Rain rate", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + RfxtrxSensorEntityDescription( + key="Sound", + ), + RfxtrxSensorEntityDescription( + key="Sensor Status", + ), + RfxtrxSensorEntityDescription( + key="Count", + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + unit_of_measurement="count", + ), + RfxtrxSensorEntityDescription( + key="Counter value", + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + unit_of_measurement="count", + ), + RfxtrxSensorEntityDescription( + key="Chill", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=TEMP_CELSIUS, + ), + RfxtrxSensorEntityDescription( + key="Wind average speed", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=SPEED_METERS_PER_SECOND, + ), + RfxtrxSensorEntityDescription( + key="Wind gust", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=SPEED_METERS_PER_SECOND, + ), + RfxtrxSensorEntityDescription( + key="Rain total", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=LENGTH_MILLIMETERS, + ), + RfxtrxSensorEntityDescription( + key="Forecast", + ), + RfxtrxSensorEntityDescription( + key="Forecast numeric", + ), + RfxtrxSensorEntityDescription( + key="Humidity status", + ), + RfxtrxSensorEntityDescription( + key="UV", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UV_INDEX, + ), +) + +SENSOR_TYPES_DICT = {desc.key: desc for desc in SENSOR_TYPES} async def async_setup_entry( @@ -92,13 +247,13 @@ async def async_setup_entry( device_id = get_device_id( event.device, data_bits=entity_info.get(CONF_DATA_BITS) ) - for data_type in set(event.values) & set(DATA_TYPES): + for data_type in set(event.values) & set(SENSOR_TYPES_DICT): data_id = (*device_id, data_type) if data_id in data_ids: continue data_ids.add(data_id) - entity = RfxtrxSensor(event.device, device_id, data_type) + entity = RfxtrxSensor(event.device, device_id, SENSOR_TYPES_DICT[data_type]) entities.append(entity) async_add_entities(entities) @@ -109,7 +264,7 @@ async def async_setup_entry( if not supported(event): return - for data_type in set(event.values) & set(DATA_TYPES): + for data_type in set(event.values) & set(SENSOR_TYPES_DICT): data_id = (*device_id, data_type) if data_id in data_ids: continue @@ -123,7 +278,9 @@ async def async_setup_entry( "".join(f"{x:02x}" for x in event.data), ) - entity = RfxtrxSensor(event.device, device_id, data_type, event=event) + entity = RfxtrxSensor( + event.device, device_id, SENSOR_TYPES_DICT[data_type], event=event + ) async_add_entities([entity]) # Subscribe to main RFXtrx events @@ -133,16 +290,16 @@ async def async_setup_entry( class RfxtrxSensor(RfxtrxEntity, SensorEntity): """Representation of a RFXtrx sensor.""" - def __init__(self, device, device_id, data_type, event=None): + entity_description: RfxtrxSensorEntityDescription + + def __init__(self, device, device_id, entity_description, event=None): """Initialize the sensor.""" super().__init__(device, device_id, event=event) - self.data_type = data_type - self._unit_of_measurement = DATA_TYPES.get(data_type) - self._name = f"{device.type_string} {device.id_string} {data_type}" - self._unique_id = "_".join(x for x in (*self._device_id, data_type)) - - self._device_class = DEVICE_CLASSES.get(data_type) - self._convert_fun = CONVERT_FUNCTIONS.get(data_type, lambda x: x) + self.entity_description = entity_description + self._name = f"{device.type_string} {device.id_string} {entity_description.key}" + self._unique_id = "_".join( + x for x in (*self._device_id, entity_description.key) + ) async def async_added_to_hass(self): """Restore device state.""" @@ -160,13 +317,8 @@ class RfxtrxSensor(RfxtrxEntity, SensorEntity): """Return the state of the sensor.""" if not self._event: return None - value = self._event.values.get(self.data_type) - return self._convert_fun(value) - - @property - def unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit_of_measurement + value = self._event.values.get(self.entity_description.key) + return self.entity_description.convert(value) @property def should_poll(self): @@ -178,18 +330,13 @@ class RfxtrxSensor(RfxtrxEntity, SensorEntity): """We should force updates. Repeated states have meaning.""" return True - @property - def device_class(self): - """Return a device class for sensor.""" - return self._device_class - @callback def _handle_event(self, event, device_id): """Check if event applies to me and update.""" if device_id != self._device_id: return - if self.data_type not in event.values: + if self.entity_description.key not in event.values: return _LOGGER.debug( From acc0288f4cbf82868d3fcd51a1a0581f598c4ae3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Aug 2021 14:48:00 -0500 Subject: [PATCH 194/903] Bump zeroconf to 0.33.4 to ensure zeroconf can startup when ipv6 is disabled (#54165) Changelog: https://github.com/jstasiak/python-zeroconf/compare/0.33.3...0.33.4 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 7b3cfa1fefd..1847a1c806b 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.33.3"], + "requirements": ["zeroconf==0.33.4"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d435f165f61..617a057743b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.17 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.33.3 +zeroconf==0.33.4 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 3aec770dc6b..5dc4d86d603 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2442,7 +2442,7 @@ zeep[async]==4.0.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.33.3 +zeroconf==0.33.4 # homeassistant.components.zha zha-quirks==0.0.59 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10794fe2c94..83dfc9695de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1347,7 +1347,7 @@ youless-api==0.10 zeep[async]==4.0.0 # homeassistant.components.zeroconf -zeroconf==0.33.3 +zeroconf==0.33.4 # homeassistant.components.zha zha-quirks==0.0.59 From 099a1de92b7b19f8b550300e437aac53d410d1c4 Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Sat, 7 Aug 2021 00:43:39 +0200 Subject: [PATCH 195/903] Use SensorEntityDescription for AsusWRT sensors (#54111) --- homeassistant/components/asuswrt/sensor.py | 175 ++++++++++++--------- 1 file changed, 100 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 679ae832394..6c0671b53cb 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -1,11 +1,16 @@ """Asuswrt status sensors.""" from __future__ import annotations +from dataclasses import dataclass import logging from numbers import Number from typing import Any -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_GIGABYTES, DATA_RATE_MEGABITS_PER_SECOND from homeassistant.core import HomeAssistant @@ -14,6 +19,7 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util import dt as dt_util from .const import ( DATA_ASUSWRT, @@ -25,62 +31,83 @@ from .const import ( ) from .router import KEY_COORDINATOR, KEY_SENSORS, AsusWrtRouter + +@dataclass +class AsusWrtSensorEntityDescription(SensorEntityDescription): + """A class that describes AsusWrt sensor entities.""" + + factor: int | None = None + precision: int = 2 + + DEFAULT_PREFIX = "Asuswrt" - -SENSOR_DEVICE_CLASS = "device_class" -SENSOR_ICON = "icon" -SENSOR_NAME = "name" -SENSOR_UNIT = "unit" -SENSOR_FACTOR = "factor" -SENSOR_DEFAULT_ENABLED = "default_enabled" - UNIT_DEVICES = "Devices" -CONNECTION_SENSORS = { - SENSORS_CONNECTED_DEVICE[0]: { - SENSOR_NAME: "Devices Connected", - SENSOR_UNIT: UNIT_DEVICES, - SENSOR_FACTOR: 0, - SENSOR_ICON: "mdi:router-network", - SENSOR_DEFAULT_ENABLED: True, - }, - SENSORS_RATES[0]: { - SENSOR_NAME: "Download Speed", - SENSOR_UNIT: DATA_RATE_MEGABITS_PER_SECOND, - SENSOR_FACTOR: 125000, - SENSOR_ICON: "mdi:download-network", - }, - SENSORS_RATES[1]: { - SENSOR_NAME: "Upload Speed", - SENSOR_UNIT: DATA_RATE_MEGABITS_PER_SECOND, - SENSOR_FACTOR: 125000, - SENSOR_ICON: "mdi:upload-network", - }, - SENSORS_BYTES[0]: { - SENSOR_NAME: "Download", - SENSOR_UNIT: DATA_GIGABYTES, - SENSOR_FACTOR: 1000000000, - SENSOR_ICON: "mdi:download", - }, - SENSORS_BYTES[1]: { - SENSOR_NAME: "Upload", - SENSOR_UNIT: DATA_GIGABYTES, - SENSOR_FACTOR: 1000000000, - SENSOR_ICON: "mdi:upload", - }, - SENSORS_LOAD_AVG[0]: { - SENSOR_NAME: "Load Avg (1m)", - SENSOR_ICON: "mdi:cpu-32-bit", - }, - SENSORS_LOAD_AVG[1]: { - SENSOR_NAME: "Load Avg (5m)", - SENSOR_ICON: "mdi:cpu-32-bit", - }, - SENSORS_LOAD_AVG[2]: { - SENSOR_NAME: "Load Avg (15m)", - SENSOR_ICON: "mdi:cpu-32-bit", - }, -} +CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( + AsusWrtSensorEntityDescription( + key=SENSORS_CONNECTED_DEVICE[0], + name="Devices Connected", + icon="mdi:router-network", + unit_of_measurement=UNIT_DEVICES, + entity_registry_enabled_default=True, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_RATES[0], + name="Download Speed", + icon="mdi:download-network", + unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + entity_registry_enabled_default=False, + factor=125000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_RATES[1], + name="Upload Speed", + icon="mdi:upload-network", + unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + entity_registry_enabled_default=False, + factor=125000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_BYTES[0], + name="Download", + icon="mdi:download", + unit_of_measurement=DATA_GIGABYTES, + entity_registry_enabled_default=False, + factor=1000000000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_BYTES[1], + name="Upload", + icon="mdi:upload", + unit_of_measurement=DATA_GIGABYTES, + entity_registry_enabled_default=False, + factor=1000000000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_LOAD_AVG[0], + name="Load Avg (1m)", + icon="mdi:cpu-32-bit", + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_LOAD_AVG[1], + name="Load Avg (5m)", + icon="mdi:cpu-32-bit", + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_LOAD_AVG[2], + name="Load Avg (15m)", + icon="mdi:cpu-32-bit", + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), +) _LOGGER = logging.getLogger(__name__) @@ -95,13 +122,13 @@ async def async_setup_entry( for sensor_data in router.sensors_coordinator.values(): coordinator = sensor_data[KEY_COORDINATOR] sensors = sensor_data[KEY_SENSORS] - for sensor_key in sensors: - if sensor_key in CONNECTION_SENSORS: - entities.append( - AsusWrtSensor( - coordinator, router, sensor_key, CONNECTION_SENSORS[sensor_key] - ) - ) + entities.extend( + [ + AsusWrtSensor(coordinator, router, sensor_descr) + for sensor_descr in CONNECTION_SENSORS + if sensor_descr.key in sensors + ] + ) async_add_entities(entities, True) @@ -113,31 +140,29 @@ class AsusWrtSensor(CoordinatorEntity, SensorEntity): self, coordinator: DataUpdateCoordinator, router: AsusWrtRouter, - sensor_type: str, - sensor_def: dict[str, Any], + description: AsusWrtSensorEntityDescription, ) -> None: """Initialize a AsusWrt sensor.""" super().__init__(coordinator) self._router = router - self._sensor_type = sensor_type - self._attr_name = f"{DEFAULT_PREFIX} {sensor_def[SENSOR_NAME]}" - self._factor = sensor_def.get(SENSOR_FACTOR) + self.entity_description = description + + self._attr_name = f"{DEFAULT_PREFIX} {description.name}" self._attr_unique_id = f"{DOMAIN} {self.name}" - self._attr_entity_registry_enabled_default = sensor_def.get( - SENSOR_DEFAULT_ENABLED, False - ) - self._attr_unit_of_measurement = sensor_def.get(SENSOR_UNIT) - self._attr_icon = sensor_def.get(SENSOR_ICON) - self._attr_device_class = sensor_def.get(SENSOR_DEVICE_CLASS) + self._attr_state_class = STATE_CLASS_MEASUREMENT + + if description.unit_of_measurement == DATA_GIGABYTES: + self._attr_last_reset = dt_util.utc_from_timestamp(0) @property def state(self) -> str: """Return current state.""" - state = self.coordinator.data.get(self._sensor_type) + descr = self.entity_description + state = self.coordinator.data.get(descr.key) if state is None: return None - if self._factor and isinstance(state, Number): - return round(state / self._factor, 2) + if descr.factor and isinstance(state, Number): + return round(state / descr.factor, descr.precision) return state @property From 98bcdc2cf5536c0f88f27fb02296078a3f878791 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 7 Aug 2021 00:10:12 +0000 Subject: [PATCH 196/903] [ci skip] Translation update --- .../accuweather/translations/hu.json | 3 +- .../components/adax/translations/no.json | 20 +++++++++++ .../airvisual/translations/sensor.no.json | 14 +++++++- .../alarm_control_panel/translations/no.json | 4 +++ .../components/arcam_fmj/translations/hu.json | 5 +++ .../components/co2signal/translations/no.json | 34 +++++++++++++++++++ .../components/coinbase/translations/no.json | 1 + .../components/energy/translations/no.json | 3 ++ .../components/enocean/translations/hu.json | 6 ++-- .../components/flipr/translations/no.json | 30 ++++++++++++++++ .../flunearyou/translations/hu.json | 3 +- .../forecast_solar/translations/no.json | 2 +- .../forked_daapd/translations/hu.json | 20 +++++++++-- .../growatt_server/translations/no.json | 1 + .../homeassistant/translations/no.json | 1 + .../components/homekit/translations/no.json | 2 +- .../components/honeywell/translations/no.json | 17 ++++++++++ .../huawei_lte/translations/no.json | 5 +-- .../components/hue/translations/hu.json | 3 +- .../components/light/translations/hu.json | 1 + .../components/litejet/translations/no.json | 10 ++++++ .../motion_blinds/translations/hu.json | 11 ++++-- .../nfandroidtv/translations/no.json | 21 ++++++++++++ .../nmap_tracker/translations/no.json | 4 ++- .../components/powerwall/translations/hu.json | 1 + .../progettihwsw/translations/hu.json | 3 +- .../components/prosegur/translations/no.json | 11 ++++++ .../components/renault/translations/no.json | 17 +++++++++- .../components/rfxtrx/translations/hu.json | 3 +- .../components/sentry/translations/hu.json | 2 ++ .../simplisafe/translations/hu.json | 2 ++ .../simplisafe/translations/no.json | 2 +- .../somfy_mylink/translations/hu.json | 1 + .../components/sonos/translations/no.json | 1 + .../switcher_kis/translations/no.json | 13 +++++++ .../components/syncthru/translations/hu.json | 1 + .../synology_dsm/translations/no.json | 11 +++++- .../components/tesla/translations/no.json | 1 + .../components/toon/translations/hu.json | 3 ++ .../components/tractive/translations/de.json | 19 +++++++++++ .../components/tractive/translations/no.json | 19 +++++++++++ .../uptimerobot/translations/de.json | 4 ++- .../uptimerobot/translations/no.json | 20 +++++++++++ .../wolflink/translations/sensor.hu.json | 4 +++ .../xiaomi_miio/translations/select.no.json | 9 +++++ .../yale_smart_alarm/translations/no.json | 8 +++++ .../components/youless/translations/no.json | 4 +++ .../components/zha/translations/nn.json | 3 ++ .../components/zwave_js/translations/no.json | 15 ++++++++ 49 files changed, 376 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/adax/translations/no.json create mode 100644 homeassistant/components/co2signal/translations/no.json create mode 100644 homeassistant/components/energy/translations/no.json create mode 100644 homeassistant/components/flipr/translations/no.json create mode 100644 homeassistant/components/honeywell/translations/no.json create mode 100644 homeassistant/components/nfandroidtv/translations/no.json create mode 100644 homeassistant/components/switcher_kis/translations/no.json create mode 100644 homeassistant/components/tractive/translations/de.json create mode 100644 homeassistant/components/tractive/translations/no.json create mode 100644 homeassistant/components/uptimerobot/translations/no.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.no.json diff --git a/homeassistant/components/accuweather/translations/hu.json b/homeassistant/components/accuweather/translations/hu.json index 3cb78005d46..7b4d270f78b 100644 --- a/homeassistant/components/accuweather/translations/hu.json +++ b/homeassistant/components/accuweather/translations/hu.json @@ -5,7 +5,8 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs" + "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs", + "requests_exceeded": "T\u00fall\u00e9pt\u00e9k az Accuweather API-hoz beny\u00fajtott k\u00e9relmek megengedett sz\u00e1m\u00e1t. Meg kell v\u00e1rnia vagy m\u00f3dos\u00edtania kell az API-kulcsot." }, "step": { "user": { diff --git a/homeassistant/components/adax/translations/no.json b/homeassistant/components/adax/translations/no.json new file mode 100644 index 00000000000..33c54b57093 --- /dev/null +++ b/homeassistant/components/adax/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning" + }, + "step": { + "user": { + "data": { + "account_id": "Konto-ID", + "host": "Vert", + "password": "Passord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.no.json b/homeassistant/components/airvisual/translations/sensor.no.json index 86c95f8e8f2..cf142ad9f1a 100644 --- a/homeassistant/components/airvisual/translations/sensor.no.json +++ b/homeassistant/components/airvisual/translations/sensor.no.json @@ -1,8 +1,20 @@ { "state": { "airvisual__pollutant_label": { + "co": "Karbonmonoksid", + "n2": "Nitrogendioksid", + "o3": "Ozon", "p1": "PM10", - "p2": "PM2.5" + "p2": "PM2.5", + "s2": "Svoveldioksid" + }, + "airvisual__pollutant_level": { + "good": "Bra", + "hazardous": "Farlig", + "moderate": "Moderat", + "unhealthy": "Usunt", + "unhealthy_sensitive": "Usunt for sensitive grupper", + "very_unhealthy": "Veldig usunt" } } } \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/no.json b/homeassistant/components/alarm_control_panel/translations/no.json index 465dd250086..ad8ed2c9c74 100644 --- a/homeassistant/components/alarm_control_panel/translations/no.json +++ b/homeassistant/components/alarm_control_panel/translations/no.json @@ -4,6 +4,7 @@ "arm_away": "Aktiver {entity_name} borte", "arm_home": "Aktiver {entity_name} hjemme", "arm_night": "Aktiver {entity_name} natt", + "arm_vacation": "{entity_name} ferie", "disarm": "Deaktiver {entity_name}", "trigger": "Utl\u00f8ser {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} er aktivert borte", "is_armed_home": "{entity_name} er aktivert hjemme", "is_armed_night": "{entity_name} er aktivert natt", + "is_armed_vacation": "{entity_name} er armert ferie", "is_disarmed": "{entity_name} er deaktivert", "is_triggered": "{entity_name} er utl\u00f8st" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} aktivert borte", "armed_home": "{entity_name} aktivert hjemme", "armed_night": "{entity_name} aktivert natt", + "armed_vacation": "{entity_name} armert ferie", "disarmed": "{entity_name} deaktivert", "triggered": "{entity_name} utl\u00f8st" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Armert tilpasset unntak", "armed_home": "Armert hjemme", "armed_night": "Armert natt", + "armed_vacation": "Armert ferie", "arming": "Armerer", "disarmed": "Avsl\u00e5tt", "disarming": "Disarmer", diff --git a/homeassistant/components/arcam_fmj/translations/hu.json b/homeassistant/components/arcam_fmj/translations/hu.json index dfccbbe7143..9539ad39bed 100644 --- a/homeassistant/components/arcam_fmj/translations/hu.json +++ b/homeassistant/components/arcam_fmj/translations/hu.json @@ -22,5 +22,10 @@ "description": "K\u00e9rj\u00fck, adja meg az eszk\u00f6z gazdag\u00e9pnev\u00e9t vagy IP-c\u00edm\u00e9t." } } + }, + "device_automation": { + "trigger_type": { + "turn_on": "{entity_name} bekapcsol\u00e1s\u00e1t k\u00e9rt\u00e9k" + } } } \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/no.json b/homeassistant/components/co2signal/translations/no.json new file mode 100644 index 00000000000..bb56f0c1364 --- /dev/null +++ b/homeassistant/components/co2signal/translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "api_ratelimit": "API Ratelimit overskredet", + "unknown": "Uventet feil" + }, + "error": { + "api_ratelimit": "API Ratelimit overskredet", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breddegrad", + "longitude": "Lengdegrad" + } + }, + "country": { + "data": { + "country_code": "Landskode" + } + }, + "user": { + "data": { + "api_key": "Tilgangstoken", + "location": "Hent data for" + }, + "description": "Bes\u00f8k https://co2signal.com/ for \u00e5 be om et token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/no.json b/homeassistant/components/coinbase/translations/no.json index 747049fbd5c..78cf46d717a 100644 --- a/homeassistant/components/coinbase/translations/no.json +++ b/homeassistant/components/coinbase/translations/no.json @@ -31,6 +31,7 @@ "init": { "data": { "account_balance_currencies": "Lommeboksaldoer som skal rapporteres.", + "exchange_base": "Standardvaluta for valutakurssensorer.", "exchange_rate_currencies": "Valutakurser som skal rapporteres." }, "description": "Juster Coinbase-alternativer" diff --git a/homeassistant/components/energy/translations/no.json b/homeassistant/components/energy/translations/no.json new file mode 100644 index 00000000000..168ae4ae877 --- /dev/null +++ b/homeassistant/components/energy/translations/no.json @@ -0,0 +1,3 @@ +{ + "title": "Energi" +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/hu.json b/homeassistant/components/enocean/translations/hu.json index 9cc6843682c..bfb6cb0499d 100644 --- a/homeassistant/components/enocean/translations/hu.json +++ b/homeassistant/components/enocean/translations/hu.json @@ -11,12 +11,14 @@ "detect": { "data": { "path": "USB dongle el\u00e9r\u00e9si \u00fatja" - } + }, + "title": "V\u00e1lassza ki az ENOcean-dongle el\u00e9r\u00e9si \u00fatvonal\u00e1t." }, "manual": { "data": { "path": "USB dongle el\u00e9r\u00e9si \u00fatja" - } + }, + "title": "Adja meg az ENOcean dongle el\u00e9r\u00e9si \u00fatvonal\u00e1t" } } } diff --git a/homeassistant/components/flipr/translations/no.json b/homeassistant/components/flipr/translations/no.json new file mode 100644 index 00000000000..550b0bae058 --- /dev/null +++ b/homeassistant/components/flipr/translations/no.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "no_flipr_id_found": "Ingen flipr -ID er knyttet til kontoen din forel\u00f8pig. Du b\u00f8r bekrefte at den fungerer med Flipr -mobilappen f\u00f8rst.", + "unknown": "Uventet feil" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Velg din Flipr -ID i listen", + "title": "Velg din Flipr" + }, + "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "description": "Koble til ved hjelp av Flipr-kontoen din.", + "title": "Koble til Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/hu.json b/homeassistant/components/flunearyou/translations/hu.json index b9ef1712ced..a67bc91a2a1 100644 --- a/homeassistant/components/flunearyou/translations/hu.json +++ b/homeassistant/components/flunearyou/translations/hu.json @@ -12,7 +12,8 @@ "latitude": "Sz\u00e9less\u00e9g", "longitude": "Hossz\u00fas\u00e1g" }, - "description": "Figyelje a felhaszn\u00e1l\u00f3alap\u00fa \u00e9s a CDC jelent\u00e9seket egy p\u00e1r koordin\u00e1t\u00e1ra." + "description": "Figyelje a felhaszn\u00e1l\u00f3alap\u00fa \u00e9s a CDC jelent\u00e9seket egy p\u00e1r koordin\u00e1t\u00e1ra.", + "title": "Flu Near You weboldal konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/forecast_solar/translations/no.json b/homeassistant/components/forecast_solar/translations/no.json index 5ee0691ecda..1504727c1ae 100644 --- a/homeassistant/components/forecast_solar/translations/no.json +++ b/homeassistant/components/forecast_solar/translations/no.json @@ -24,7 +24,7 @@ "declination": "Deklinasjon (0 = horisontal, 90 = vertikal)", "modules power": "Total Watt-toppeffekt i solcellemodulene dine" }, - "description": "Disse verdiene tillater justering av Solar.Forecast-resultatet. Se dokumentasjonen er et felt som er uklart." + "description": "Disse verdiene tillater justering av Solar.Forecast -resultatet. Se dokumentasjonen hvis et felt er uklart." } } } diff --git a/homeassistant/components/forked_daapd/translations/hu.json b/homeassistant/components/forked_daapd/translations/hu.json index aac95b2956a..bbf8cb560ff 100644 --- a/homeassistant/components/forked_daapd/translations/hu.json +++ b/homeassistant/components/forked_daapd/translations/hu.json @@ -9,7 +9,8 @@ "unknown_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", "websocket_not_enabled": "forked-daapd szerver websocket nincs enged\u00e9lyezve.", "wrong_host_or_port": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a gazdag\u00e9pet \u00e9s a portot.", - "wrong_password": "Helytelen jelsz\u00f3." + "wrong_password": "Helytelen jelsz\u00f3.", + "wrong_server_type": "A forked-daapd integr\u00e1ci\u00f3hoz forked-daapd szerver sz\u00fcks\u00e9ges, amelynek verzi\u00f3ja> = 27.0." }, "flow_title": "{name} ({host})", "step": { @@ -19,7 +20,22 @@ "name": "Megjelen\u00edt\u00e9si n\u00e9v", "password": "API jelsz\u00f3 (hagyja \u00fcresen, ha nincs jelsz\u00f3)", "port": "API port" - } + }, + "title": "\u00c1ll\u00edtsa be a forked-daapd eszk\u00f6zt" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "librespot_java_port": "Port librespot-java cs\u0151 vez\u00e9rl\u00e9s (ha van)", + "max_playlists": "Forr\u00e1sk\u00e9nt haszn\u00e1lt lej\u00e1tsz\u00e1si list\u00e1k maxim\u00e1lis sz\u00e1ma", + "tts_pause_time": "M\u00e1sodpercek a TTS el\u0151tti \u00e9s ut\u00e1ni sz\u00fcnethez", + "tts_volume": "TTS hanger\u0151 (lebeg\u0151 a [0,1] tartom\u00e1nyban)" + }, + "description": "A forked-daapd integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sai.", + "title": "A forked-daapd be\u00e1ll\u00edt\u00e1sainak konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/growatt_server/translations/no.json b/homeassistant/components/growatt_server/translations/no.json index dee1e989465..8977a7e86a3 100644 --- a/homeassistant/components/growatt_server/translations/no.json +++ b/homeassistant/components/growatt_server/translations/no.json @@ -17,6 +17,7 @@ "data": { "name": "Navn", "password": "Passord", + "url": "URL", "username": "Brukernavn" }, "title": "Skriv inn Growatt-informasjonen din" diff --git a/homeassistant/components/homeassistant/translations/no.json b/homeassistant/components/homeassistant/translations/no.json index 325bb53db15..675c02a6b66 100644 --- a/homeassistant/components/homeassistant/translations/no.json +++ b/homeassistant/components/homeassistant/translations/no.json @@ -10,6 +10,7 @@ "os_version": "Operativsystemversjon", "python_version": "Python versjon", "timezone": "Tidssone", + "user": "Bruker", "version": "Versjon", "virtualenv": "Virtuelt milj\u00f8" } diff --git a/homeassistant/components/homekit/translations/no.json b/homeassistant/components/homekit/translations/no.json index 7de5494c56a..2a4f1497e2f 100644 --- a/homeassistant/components/homekit/translations/no.json +++ b/homeassistant/components/homekit/translations/no.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domener \u00e5 inkludere" }, - "description": "Velg domenene som skal inkluderes. Alle st\u00f8ttede enheter i domenet vil bli inkludert. Det opprettes en egen HomeKit-forekomst i tilbeh\u00f8rsmodus for hver tv-mediaspiller og kamera.", + "description": "Velg domenene som skal inkluderes. Alle enheter som st\u00f8ttes p\u00e5 domenet vil bli inkludert. En egen HomeKit -forekomst i tilbeh\u00f8rsmodus vil bli opprettet for hver tv -mediespiller, aktivitetsbasert fjernkontroll, l\u00e5s og kamera.", "title": "Velg domener som skal inkluderes" } } diff --git a/homeassistant/components/honeywell/translations/no.json b/homeassistant/components/honeywell/translations/no.json new file mode 100644 index 00000000000..97d31d34961 --- /dev/null +++ b/homeassistant/components/honeywell/translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ugyldig godkjenning" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Vennligst skriv inn legitimasjonen som brukes for \u00e5 logge deg p\u00e5 mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/no.json b/homeassistant/components/huawei_lte/translations/no.json index a328858c57f..3c8b26ab0cd 100644 --- a/homeassistant/components/huawei_lte/translations/no.json +++ b/homeassistant/components/huawei_lte/translations/no.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Brukernavn" }, - "description": "Fyll inn detaljer for enhetstilgang. Spesifisering av brukernavn og passord er valgfritt, men gir st\u00f8tte for flere integrasjonsfunksjoner. P\u00e5 en annen side kan bruk av en autorisert tilkobling f\u00f8re til problemer med tilgang til enhetens webgrensesnitt utenfor Home Assistant mens integrasjonen er aktiv, og omvendt.", + "description": "Angi enhetsadgangsdetaljer.", "title": "Konfigurer Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Navn p\u00e5 varslingstjeneste (endring krever omstart)", "recipient": "Mottakere av SMS-varsling", "track_new_devices": "Spor nye enheter", - "track_wired_clients": "Spor kablede nettverksklienter" + "track_wired_clients": "Spor kablede nettverksklienter", + "unauthenticated_mode": "Uautentisert modus (endring krever omlasting)" } } } diff --git a/homeassistant/components/hue/translations/hu.json b/homeassistant/components/hue/translations/hu.json index 91321f9c6fd..30084ee9940 100644 --- a/homeassistant/components/hue/translations/hu.json +++ b/homeassistant/components/hue/translations/hu.json @@ -58,7 +58,8 @@ "step": { "init": { "data": { - "allow_hue_groups": "Hue csoportok enged\u00e9lyez\u00e9se" + "allow_hue_groups": "Hue csoportok enged\u00e9lyez\u00e9se", + "allow_unreachable": "Hagyja, hogy az el\u00e9rhetetlen izz\u00f3k helyesen jelents\u00e9k \u00e1llapotukat" } } } diff --git a/homeassistant/components/light/translations/hu.json b/homeassistant/components/light/translations/hu.json index ad215a5ba4c..1ac835fd1af 100644 --- a/homeassistant/components/light/translations/hu.json +++ b/homeassistant/components/light/translations/hu.json @@ -3,6 +3,7 @@ "action_type": { "brightness_decrease": "{entity_name} f\u00e9nyerej\u00e9nek cs\u00f6kkent\u00e9se", "brightness_increase": "{entity_name} f\u00e9nyerej\u00e9nek n\u00f6vel\u00e9se", + "flash": "Vaku {entity_name}", "toggle": "{entity_name} fel/lekapcsol\u00e1sa", "turn_off": "{entity_name} lekapcsol\u00e1sa", "turn_on": "{entity_name} felkapcsol\u00e1sa" diff --git a/homeassistant/components/litejet/translations/no.json b/homeassistant/components/litejet/translations/no.json index d3206ca2897..f6e2379900d 100644 --- a/homeassistant/components/litejet/translations/no.json +++ b/homeassistant/components/litejet/translations/no.json @@ -15,5 +15,15 @@ "title": "Koble til LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Standard overgang (sekunder)" + }, + "title": "Konfigurer LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motion_blinds/translations/hu.json b/homeassistant/components/motion_blinds/translations/hu.json index 19f0c70c4d6..a2560e5fa79 100644 --- a/homeassistant/components/motion_blinds/translations/hu.json +++ b/homeassistant/components/motion_blinds/translations/hu.json @@ -8,24 +8,29 @@ "error": { "discovery_error": "Nem siker\u00fclt felfedezni a Motion Gateway-t" }, + "flow_title": "Mozg\u00f3 red\u0151ny", "step": { "connect": { "data": { "api_key": "API kulcs" }, - "description": "Sz\u00fcks\u00e9ge lesz a 16 karakteres API kulcsra, \u00fatmutat\u00e1s\u00e9rt l\u00e1sd: https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key" + "description": "Sz\u00fcks\u00e9ge lesz a 16 karakteres API kulcsra, \u00fatmutat\u00e1s\u00e9rt l\u00e1sd: https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key", + "title": "Mozg\u00f3 red\u0151ny" }, "select": { "data": { "select_ip": "IP c\u00edm" - } + }, + "description": "Futtassa \u00fajra a be\u00e1ll\u00edt\u00e1st, ha tov\u00e1bbi Motion Gateway-eket szeretne csatlakoztatni", + "title": "V\u00e1lassza ki a csatlakoztatni k\u00edv\u00e1nt Motion Gateway-t" }, "user": { "data": { "api_key": "API kulcs", "host": "IP c\u00edm" }, - "description": "Csatlakozzon a Motion Gateway-hez, ha az IP-c\u00edm nincs be\u00e1ll\u00edtva, akkor az automatikus felder\u00edt\u00e9st haszn\u00e1lja" + "description": "Csatlakozzon a Motion Gateway-hez, ha az IP-c\u00edm nincs be\u00e1ll\u00edtva, akkor az automatikus felder\u00edt\u00e9st haszn\u00e1lja", + "title": "Mozg\u00f3 red\u0151ny" } } } diff --git a/homeassistant/components/nfandroidtv/translations/no.json b/homeassistant/components/nfandroidtv/translations/no.json new file mode 100644 index 00000000000..e8aea574c96 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "name": "Navn" + }, + "description": "Denne integrasjonen krever Notifications for Android TV -appen. \n\n For Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\n For Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\n Du b\u00f8r konfigurere enten DHCP -reservasjon p\u00e5 ruteren din (se brukerh\u00e5ndboken til ruteren din) eller en statisk IP -adresse p\u00e5 enheten. Hvis ikke, vil enheten til slutt bli utilgjengelig.", + "title": "Varsler for Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/no.json b/homeassistant/components/nmap_tracker/translations/no.json index 487d15c910f..03a241bc3a2 100644 --- a/homeassistant/components/nmap_tracker/translations/no.json +++ b/homeassistant/components/nmap_tracker/translations/no.json @@ -28,7 +28,9 @@ "exclude": "Nettverksadresser (kommaseparert) for \u00e5 ekskludere fra skanning", "home_interval": "Minimum antall minutter mellom skanninger av aktive enheter (lagre batteri)", "hosts": "Nettverksadresser (kommaseparert) for \u00e5 skanne", - "scan_options": "R\u00e5 konfigurerbare skannealternativer for Nmap" + "interval_seconds": "Skanneintervall", + "scan_options": "R\u00e5 konfigurerbare skannealternativer for Nmap", + "track_new_devices": "Spor nye enheter" }, "description": "Konfigurer verter som skal skannes av Nmap. Nettverksadresse og ekskluderer kan v\u00e6re IP-adresser (192.168.1.1), IP-nettverk (192.168.0.0/24) eller IP-omr\u00e5der (192.168.1.0-32)." } diff --git a/homeassistant/components/powerwall/translations/hu.json b/homeassistant/components/powerwall/translations/hu.json index 1102ba78673..d5bc30e7d11 100644 --- a/homeassistant/components/powerwall/translations/hu.json +++ b/homeassistant/components/powerwall/translations/hu.json @@ -17,6 +17,7 @@ "ip_address": "IP c\u00edm", "password": "Jelsz\u00f3" }, + "description": "A jelsz\u00f3 \u00e1ltal\u00e1ban a Biztons\u00e1gi ment\u00e9s k\u00f6zponti egys\u00e9g sorozatsz\u00e1m\u00e1nak utols\u00f3 5 karaktere, \u00e9s megtal\u00e1lhat\u00f3 a Tesla alkalmaz\u00e1sban, vagy a jelsz\u00f3 utols\u00f3 5 karaktere a Biztons\u00e1gi ment\u00e9s k\u00f6zponti egys\u00e9g 2 ajtaj\u00e1ban.", "title": "Csatlakoz\u00e1s a powerwallhoz" } } diff --git a/homeassistant/components/progettihwsw/translations/hu.json b/homeassistant/components/progettihwsw/translations/hu.json index 76af6fb124f..fea70ec88ac 100644 --- a/homeassistant/components/progettihwsw/translations/hu.json +++ b/homeassistant/components/progettihwsw/translations/hu.json @@ -33,7 +33,8 @@ "data": { "host": "Hoszt", "port": "Port" - } + }, + "title": "\u00c1ll\u00edtsa be" } } } diff --git a/homeassistant/components/prosegur/translations/no.json b/homeassistant/components/prosegur/translations/no.json index 5732bb920b2..73bacd26c14 100644 --- a/homeassistant/components/prosegur/translations/no.json +++ b/homeassistant/components/prosegur/translations/no.json @@ -1,14 +1,25 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, "step": { "reauth_confirm": { "data": { + "description": "Autentiser p\u00e5 nytt med Prosegur-kontoen.", "password": "Passord", "username": "Brukernavn" } }, "user": { "data": { + "country": "Land", "password": "Passord", "username": "Brukernavn" } diff --git a/homeassistant/components/renault/translations/no.json b/homeassistant/components/renault/translations/no.json index f367c8c540d..4675f939fdd 100644 --- a/homeassistant/components/renault/translations/no.json +++ b/homeassistant/components/renault/translations/no.json @@ -1,11 +1,26 @@ { "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "kamereon_no_account": "Kan ikke finne Kamereon -kontoen." + }, + "error": { + "invalid_credentials": "Ugyldig godkjenning" + }, "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon -konto -ID" + }, + "title": "Velg Kamereon -konto -ID" + }, "user": { "data": { + "locale": "Lokal", "password": "Passord", "username": "E-Post" - } + }, + "title": "Angi Renault-legitimasjon" } } } diff --git a/homeassistant/components/rfxtrx/translations/hu.json b/homeassistant/components/rfxtrx/translations/hu.json index d8a27a3173b..5b953c1260e 100644 --- a/homeassistant/components/rfxtrx/translations/hu.json +++ b/homeassistant/components/rfxtrx/translations/hu.json @@ -69,7 +69,8 @@ "off_delay": "Kikapcsol\u00e1si k\u00e9sleltet\u00e9s", "off_delay_enabled": "Kikapcsol\u00e1si k\u00e9sleltet\u00e9s enged\u00e9lyez\u00e9se", "replace_device": "V\u00e1lassza ki a cser\u00e9lni k\u00edv\u00e1nt eszk\u00f6zt", - "signal_repetitions": "A jelism\u00e9tl\u00e9sek sz\u00e1ma" + "signal_repetitions": "A jelism\u00e9tl\u00e9sek sz\u00e1ma", + "venetian_blind_mode": "Velencei red\u0151ny \u00fczemm\u00f3d" }, "title": "Konfigur\u00e1lja az eszk\u00f6z be\u00e1ll\u00edt\u00e1sait" } diff --git a/homeassistant/components/sentry/translations/hu.json b/homeassistant/components/sentry/translations/hu.json index 43404f72495..df07c41449e 100644 --- a/homeassistant/components/sentry/translations/hu.json +++ b/homeassistant/components/sentry/translations/hu.json @@ -25,6 +25,8 @@ "event_custom_components": "Esem\u00e9nyek k\u00fcld\u00e9se egy\u00e9ni \u00f6sszetev\u0151kb\u0151l", "event_handled": "K\u00fcldj\u00f6n kezelt esem\u00e9nyeket", "event_third_party_packages": "K\u00fcldj\u00f6n esem\u00e9nyeket harmadik f\u00e9l csomagjaib\u00f3l", + "logging_event_level": "A napl\u00f3szint\u0171 Sentry esem\u00e9ny regisztr\u00e1l\u00e1sa", + "logging_level": "A napl\u00f3szint\u0171 Sentry a napl\u00f3k t\u00f6red\u00e9keinek r\u00f6gz\u00edt\u00e9se", "tracing": "Enged\u00e9lyezze a teljes\u00edtm\u00e9nyk\u00f6vet\u00e9st", "tracing_sample_rate": "A mintav\u00e9teli sebess\u00e9g nyomon k\u00f6vet\u00e9se; 0,0 \u00e9s 1,0 k\u00f6z\u00f6tt (1,0 = 100%)" } diff --git a/homeassistant/components/simplisafe/translations/hu.json b/homeassistant/components/simplisafe/translations/hu.json index 14faee90ed4..f7c1b5afd9d 100644 --- a/homeassistant/components/simplisafe/translations/hu.json +++ b/homeassistant/components/simplisafe/translations/hu.json @@ -7,10 +7,12 @@ "error": { "identifier_exists": "Fi\u00f3k m\u00e1r regisztr\u00e1lva van", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "still_awaiting_mfa": "M\u00e9g v\u00e1r az MFA e-mail kattint\u00e1sra", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { "mfa": { + "description": "Ellen\u0151rizze e-mailj\u00e9ben a SimpliSafe linkj\u00e9t. A link ellen\u0151rz\u00e9se ut\u00e1n t\u00e9rjen vissza ide, \u00e9s fejezze be az integr\u00e1ci\u00f3 telep\u00edt\u00e9s\u00e9t.", "title": "SimpliSafe t\u00f6bbt\u00e9nyez\u0151s hiteles\u00edt\u00e9s" }, "reauth_confirm": { diff --git a/homeassistant/components/simplisafe/translations/no.json b/homeassistant/components/simplisafe/translations/no.json index bc82715ad63..acd8adf0792 100644 --- a/homeassistant/components/simplisafe/translations/no.json +++ b/homeassistant/components/simplisafe/translations/no.json @@ -19,7 +19,7 @@ "data": { "password": "Passord" }, - "description": "Din tilgang har utl\u00f8pt eller blitt tilbakekalt. Skriv inn passordet ditt for \u00e5 koble kontoen din p\u00e5 nytt.", + "description": "Tilgangen din har utl\u00f8pt eller blitt tilbakekalt. Skriv inn passordet ditt for \u00e5 koble kontoen din til p\u00e5 nytt.", "title": "Godkjenne integrering p\u00e5 nytt" }, "user": { diff --git a/homeassistant/components/somfy_mylink/translations/hu.json b/homeassistant/components/somfy_mylink/translations/hu.json index 093a35b78fe..3610a930022 100644 --- a/homeassistant/components/somfy_mylink/translations/hu.json +++ b/homeassistant/components/somfy_mylink/translations/hu.json @@ -34,6 +34,7 @@ }, "init": { "data": { + "default_reverse": "A konfigur\u00e1latlan bor\u00edt\u00f3k alap\u00e9rtelmezett megford\u00edt\u00e1si \u00e1llapota", "entity_id": "Konfigur\u00e1ljon egy adott entit\u00e1st.", "target_id": "Az \u00e1rny\u00e9kol\u00f3 be\u00e1ll\u00edt\u00e1sainak konfigur\u00e1l\u00e1sa." }, diff --git a/homeassistant/components/sonos/translations/no.json b/homeassistant/components/sonos/translations/no.json index 2da0b5a1b0b..2e9b464f5f2 100644 --- a/homeassistant/components/sonos/translations/no.json +++ b/homeassistant/components/sonos/translations/no.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "not_sonos_device": "Oppdaget enhet er ikke en Sonos -enhet", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "step": { diff --git a/homeassistant/components/switcher_kis/translations/no.json b/homeassistant/components/switcher_kis/translations/no.json new file mode 100644 index 00000000000..b3d6b5d782e --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/no.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "step": { + "confirm": { + "description": "Vil du starte oppsettet?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/hu.json b/homeassistant/components/syncthru/translations/hu.json index b82b2587bc6..a5b645200db 100644 --- a/homeassistant/components/syncthru/translations/hu.json +++ b/homeassistant/components/syncthru/translations/hu.json @@ -5,6 +5,7 @@ }, "error": { "invalid_url": "\u00c9rv\u00e9nytelen URL", + "syncthru_not_supported": "Az eszk\u00f6z nem t\u00e1mogatja a SyncThru-t", "unknown_state": "A nyomtat\u00f3 \u00e1llapota ismeretlen, ellen\u0151rizze az URL-t \u00e9s a h\u00e1l\u00f3zati kapcsolatot" }, "flow_title": "{name}", diff --git a/homeassistant/components/synology_dsm/translations/no.json b/homeassistant/components/synology_dsm/translations/no.json index c8bb60bcb3e..d1e2d084f0d 100644 --- a/homeassistant/components/synology_dsm/translations/no.json +++ b/homeassistant/components/synology_dsm/translations/no.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Enheten er allerede konfigurert" + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -29,6 +30,14 @@ "description": "Vil du konfigurere {name} ({host})?", "title": "" }, + "reauth": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "\u00c5rsak: {details}", + "title": "Synology DSM Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/tesla/translations/no.json b/homeassistant/components/tesla/translations/no.json index ce706640636..11e49486107 100644 --- a/homeassistant/components/tesla/translations/no.json +++ b/homeassistant/components/tesla/translations/no.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA -kode (valgfritt)", "password": "Passord", "username": "E-post" }, diff --git a/homeassistant/components/toon/translations/hu.json b/homeassistant/components/toon/translations/hu.json index 28a987a4512..18f333dccdf 100644 --- a/homeassistant/components/toon/translations/hu.json +++ b/homeassistant/components/toon/translations/hu.json @@ -15,6 +15,9 @@ }, "description": "V\u00e1lassza ki a hozz\u00e1adni k\u00edv\u00e1nt szerz\u0151d\u00e9sc\u00edmet.", "title": "V\u00e1lassza ki a meg\u00e1llapod\u00e1st" + }, + "pick_implementation": { + "title": "V\u00e1lassza ki a hiteles\u00edt\u00e9shez" } } } diff --git a/homeassistant/components/tractive/translations/de.json b/homeassistant/components/tractive/translations/de.json new file mode 100644 index 00000000000..522649fe393 --- /dev/null +++ b/homeassistant/components/tractive/translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/no.json b/homeassistant/components/tractive/translations/no.json new file mode 100644 index 00000000000..3ae73c02103 --- /dev/null +++ b/homeassistant/components/tractive/translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "email": "E-post", + "password": "Passord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/de.json b/homeassistant/components/uptimerobot/translations/de.json index 81a9960b69c..7a50a5ba28e 100644 --- a/homeassistant/components/uptimerobot/translations/de.json +++ b/homeassistant/components/uptimerobot/translations/de.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel", "unknown": "Unerwarteter Fehler" }, "step": { diff --git a/homeassistant/components/uptimerobot/translations/no.json b/homeassistant/components/uptimerobot/translations/no.json new file mode 100644 index 00000000000..ee44ef0fdbc --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "unknown": "Uventet feil" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_api_key": "Ugyldig API-n\u00f8kkel", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.hu.json b/homeassistant/components/wolflink/translations/sensor.hu.json index 34f54e80ae8..0a257e570cf 100644 --- a/homeassistant/components/wolflink/translations/sensor.hu.json +++ b/homeassistant/components/wolflink/translations/sensor.hu.json @@ -3,6 +3,8 @@ "wolflink__state": { "1_x_warmwasser": "1 x DHW", "abgasklappe": "F\u00fcstg\u00e1zcsillap\u00edt\u00f3", + "absenkbetrieb": "Visszaes\u00e9s m\u00f3d", + "absenkstop": "Visszaes\u00e9s meg\u00e1ll\u00edt\u00e1sa", "aktiviert": "Aktiv\u00e1lt", "antilegionellenfunktion": "Anti-legionella funkci\u00f3", "at_abschaltung": "OT le\u00e1ll\u00edt\u00e1s", @@ -20,6 +22,7 @@ "dhw_prior": "DHW Priorit\u00e1s", "eco": "Takar\u00e9kos", "ein": "Enged\u00e9lyezve", + "estrichtrocknung": "Padl\u00f3sz\u00e1r\u00edt\u00e1si", "externe_deaktivierung": "K\u00fcls\u0151 deaktiv\u00e1l\u00e1s", "fernschalter_ein": "T\u00e1vir\u00e1ny\u00edt\u00f3 enged\u00e9lyezve", "frost_heizkreis": "F\u0171t\u0151k\u00f6r fagy\u00e1s", @@ -72,6 +75,7 @@ "tpw": "TPW", "urlaubsmodus": "Nyaral\u00e1s \u00fczemm\u00f3d", "ventilprufung": "Szelep teszt", + "vorspulen": "Bel\u00e9p\u00e9si sz\u00e1r\u00edt\u00e1s", "warmwasser": "DHW", "warmwasser_schnellstart": "DHW gyorsind\u00edt\u00e1s", "warmwasserbetrieb": "DHW m\u00f3d", diff --git a/homeassistant/components/xiaomi_miio/translations/select.no.json b/homeassistant/components/xiaomi_miio/translations/select.no.json new file mode 100644 index 00000000000..8205447ac2c --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.no.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Lys", + "dim": "Dim", + "off": "Av" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/no.json b/homeassistant/components/yale_smart_alarm/translations/no.json index bbeedb7dc89..eba8861fa46 100644 --- a/homeassistant/components/yale_smart_alarm/translations/no.json +++ b/homeassistant/components/yale_smart_alarm/translations/no.json @@ -1,8 +1,15 @@ { "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "invalid_auth": "Ugyldig godkjenning" + }, "step": { "reauth_confirm": { "data": { + "area_id": "Omr\u00e5de -ID", "name": "Navn", "password": "Passord", "username": "Brukernavn" @@ -10,6 +17,7 @@ }, "user": { "data": { + "area_id": "Omr\u00e5de -ID", "name": "Navn", "password": "Passord", "username": "Brukernavn" diff --git a/homeassistant/components/youless/translations/no.json b/homeassistant/components/youless/translations/no.json index 01ea5b65fb1..460c07cb535 100644 --- a/homeassistant/components/youless/translations/no.json +++ b/homeassistant/components/youless/translations/no.json @@ -1,8 +1,12 @@ { "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, "step": { "user": { "data": { + "host": "Vert", "name": "Navn" } } diff --git a/homeassistant/components/zha/translations/nn.json b/homeassistant/components/zha/translations/nn.json index 2e607435b7e..9e9b677ddc1 100644 --- a/homeassistant/components/zha/translations/nn.json +++ b/homeassistant/components/zha/translations/nn.json @@ -1,6 +1,9 @@ { "config": { "step": { + "port_config": { + "title": "Innstillinger" + }, "user": { "title": "ZHA" } diff --git a/homeassistant/components/zwave_js/translations/no.json b/homeassistant/components/zwave_js/translations/no.json index 8eb4c176356..34ddeb753b1 100644 --- a/homeassistant/components/zwave_js/translations/no.json +++ b/homeassistant/components/zwave_js/translations/no.json @@ -51,6 +51,21 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Konfigurer parameter {subtype} verdi", + "node_status": "Nodestatus", + "value": "Gjeldende verdi for en Z-Wave-verdi" + }, + "trigger_type": { + "event.notification.entry_control": "Sendte et varsel om oppf\u00f8ringskontroll", + "event.notification.notification": "Sendte et varsel", + "event.value_notification.basic": "Grunnleggende CC -hendelse p\u00e5 {subtype}", + "event.value_notification.central_scene": "Sentral scenehandling p\u00e5 {subtype}", + "event.value_notification.scene_activation": "Sceneaktivering p\u00e5 {subtype}", + "state.node_status": "Nodestatus endret" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Kunne ikke hente oppdagelsesinformasjon om Z-Wave JS-tillegg", From 8b8f3b55b687e54c7cf9fca5ab9ccc9df968decb Mon Sep 17 00:00:00 2001 From: Gian Klug <51193103+gianklug@users.noreply.github.com> Date: Sat, 7 Aug 2021 06:25:35 +0200 Subject: [PATCH 197/903] Add state class and last reset in kostal_plenticore (#54084) * Add state class and implement in kostal_plenticore * Add support for more entity variants * Add the state_class to the total values too * Reformat kostal const.py * Add `last_reset` to kostal_plenticore entities when `state_class` is set Also reformat sensor.py * Fix import * Remove the constants from the homeassistant constants file * Use sensor constants for the state_class * Reformat * Reformat * Move last_reset from sensor.py into const.py * Remove last_reset on PERCENTAGE entities * Address lint issues * Update homeassistant/components/kostal_plenticore/sensor.py Co-authored-by: Martin Hjelmare * Import datetime * Apply suggestions from code review * Fix isort * Fix more isort Co-authored-by: Martin Hjelmare --- .../components/kostal_plenticore/const.py | 95 ++++++++++++++++--- .../components/kostal_plenticore/sensor.py | 18 +++- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index 5c223f4f5d6..ede8e10cb25 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -1,5 +1,10 @@ """Constants for the Kostal Plenticore Solar Inverter integration.""" +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, @@ -11,11 +16,14 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, ) +from homeassistant.util.dt import utc_from_timestamp DOMAIN = "kostal_plenticore" ATTR_ENABLED_DEFAULT = "entity_registry_enabled_default" +LAST_RESET_NEVER = utc_from_timestamp(0) + # Defines all entities for process data. # # Each entry is defined with a tuple of these values: @@ -40,6 +48,7 @@ SENSOR_PROCESS_DATA = [ ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, ATTR_ENABLED_DEFAULT: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, "format_round", ), @@ -51,6 +60,7 @@ SENSOR_PROCESS_DATA = [ ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, ATTR_ENABLED_DEFAULT: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, "format_round", ), @@ -65,28 +75,44 @@ SENSOR_PROCESS_DATA = [ "devices:local", "HomeGrid_P", "Home Power from Grid", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local", "HomeOwn_P", "Home Power from Own", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local", "HomePv_P", "Home Power from PV", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local", "Home_P", "Home Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( @@ -97,6 +123,7 @@ SENSOR_PROCESS_DATA = [ ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, ATTR_ENABLED_DEFAULT: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, "format_round", ), @@ -104,28 +131,44 @@ SENSOR_PROCESS_DATA = [ "devices:local:pv1", "P", "DC1 Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local:pv2", "P", "DC2 Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local:pv3", "P", "DC3 Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local", "PV2Bat_P", "PV to Battery Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( @@ -139,14 +182,18 @@ SENSOR_PROCESS_DATA = [ "devices:local:battery", "Cycles", "Battery Cycles", - {ATTR_ICON: "mdi:recycle"}, + {ATTR_ICON: "mdi:recycle", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT}, "format_round", ), ( "devices:local:battery", "P", "Battery Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( @@ -174,7 +221,11 @@ SENSOR_PROCESS_DATA = [ "scb:statistic:EnergyFlow", "Statistic:Autarky:Total", "Autarky Total", - {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_ICON: "mdi:chart-donut"}, + { + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_ICON: "mdi:chart-donut", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( @@ -202,7 +253,11 @@ SENSOR_PROCESS_DATA = [ "scb:statistic:EnergyFlow", "Statistic:OwnConsumptionRate:Total", "Own Consumption Rate Total", - {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_ICON: "mdi:chart-donut"}, + { + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_ICON: "mdi:chart-donut", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( @@ -249,6 +304,8 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_LAST_RESET: LAST_RESET_NEVER, }, "format_energy", ), @@ -289,6 +346,8 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_LAST_RESET: LAST_RESET_NEVER, }, "format_energy", ), @@ -329,6 +388,8 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_LAST_RESET: LAST_RESET_NEVER, }, "format_energy", ), @@ -369,6 +430,8 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_LAST_RESET: LAST_RESET_NEVER, }, "format_energy", ), @@ -409,6 +472,8 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_LAST_RESET: LAST_RESET_NEVER, }, "format_energy", ), @@ -449,6 +514,8 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_LAST_RESET: LAST_RESET_NEVER, }, "format_energy", ), @@ -489,6 +556,8 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_LAST_RESET: LAST_RESET_NEVER, }, "format_energy", ), @@ -530,6 +599,8 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_LAST_RESET: LAST_RESET_NEVER, }, "format_energy", ), diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 717dfacbfdf..099d359e619 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -1,11 +1,15 @@ """Platform for Kostal Plenticore sensors.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import Any, Callable -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -179,11 +183,21 @@ class PlenticoreDataSensor(CoordinatorEntity, SensorEntity): """Return the class of this device, from component DEVICE_CLASSES.""" return self._sensor_data.get(ATTR_DEVICE_CLASS) + @property + def state_class(self) -> str | None: + """Return the class of the state of this device, from component STATE_CLASSES.""" + return self._sensor_data.get(ATTR_STATE_CLASS) + @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" return self._sensor_data.get(ATTR_ENABLED_DEFAULT, False) + @property + def last_reset(self) -> datetime | None: + """Return the last_reset time.""" + return self._sensor_data.get(ATTR_LAST_RESET) + @property def state(self) -> Any | None: """Return the state of the sensor.""" From 0b52e13eb8178dba45ebd3d8eb75a5e47d9d2d96 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sat, 7 Aug 2021 01:18:08 -0400 Subject: [PATCH 198/903] Fix androidtv media_image_hash (#54188) --- homeassistant/components/androidtv/media_player.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 08ae2999e37..8bc53bd86b7 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -449,6 +449,11 @@ class ADBDevice(MediaPlayerEntity): ATTR_HDMI_INPUT: None, } + @property + def media_image_hash(self): + """Hash value for media image.""" + return f"{datetime.now().timestamp()}" if self._screencap else None + @adb_decorator() async def _adb_screencap(self): """Take a screen capture from the device.""" @@ -458,9 +463,6 @@ class ADBDevice(MediaPlayerEntity): """Fetch current playing image.""" if not self._screencap or self.state in (STATE_OFF, None) or not self.available: return None, None - self._attr_media_image_hash = ( - f"{datetime.now().timestamp()}" if self._screencap else None - ) media_data = await self._adb_screencap() if media_data: From 6830eec549c372946b19035000c10afecd2f2da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20R=C3=B8rvik?= <60797691+jorgror@users.noreply.github.com> Date: Sat, 7 Aug 2021 09:45:53 +0200 Subject: [PATCH 199/903] Flexit component fix for updated modbus (#53583) * pyflexit first argument should be a ModbusSerialClient This component broke with 2021.6 I have tested this patch on my setup and it restores functionality * Implemented async reading of modbus values Stopped using pyflexit as this is outdated and not needed Instead using async_pymodbus_call from ModbusHub class * Bugfix: Reading fan mode from wrong register * Implemented async writing Set target temperature and fan mode using modbus call Added some error handling * No longer require pyflexit * Review comments. Co-authored-by: jan Iversen --- homeassistant/components/flexit/climate.py | 149 ++++++++++++++---- homeassistant/components/flexit/manifest.json | 1 - requirements_all.txt | 3 - 3 files changed, 119 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index cf4662b9866..5e7ac137982 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from pyflexit.pyflexit import pyflexit import voluptuous as vol from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity @@ -12,7 +11,15 @@ from homeassistant.components.climate.const import ( SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.components.modbus.const import CONF_HUB, DEFAULT_HUB, MODBUS_DOMAIN +from homeassistant.components.modbus.const import ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + CALL_TYPE_WRITE_REGISTER, + CONF_HUB, + DEFAULT_HUB, + MODBUS_DOMAIN, +) +from homeassistant.components.modbus.modbus import ModbusHub from homeassistant.const import ( ATTR_TEMPERATURE, CONF_NAME, @@ -20,7 +27,9 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -35,18 +44,25 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities, + discovery_info: DiscoveryInfoType = None, +): """Set up the Flexit Platform.""" modbus_slave = config.get(CONF_SLAVE) name = config.get(CONF_NAME) hub = hass.data[MODBUS_DOMAIN][config.get(CONF_HUB)] - add_entities([Flexit(hub, modbus_slave, name)], True) + async_add_entities([Flexit(hub, modbus_slave, name)], True) class Flexit(ClimateEntity): """Representation of a Flexit AC unit.""" - def __init__(self, hub, modbus_slave, name): + def __init__( + self, hub: ModbusHub, modbus_slave: int | None, name: str | None + ) -> None: """Initialize the unit.""" self._hub = hub self._name = name @@ -64,34 +80,65 @@ class Flexit(ClimateEntity): self._heating = None self._cooling = None self._alarm = False - self.unit = pyflexit(hub, modbus_slave) + self._outdoor_air_temp = None @property def supported_features(self): """Return the list of supported features.""" return SUPPORT_FLAGS - def update(self): + async def async_update(self): """Update unit attributes.""" - if not self.unit.update(): - _LOGGER.warning("Modbus read failed") + self._target_temperature = await self._async_read_temp_from_register( + CALL_TYPE_REGISTER_HOLDING, 8 + ) + self._current_temperature = await self._async_read_temp_from_register( + CALL_TYPE_REGISTER_INPUT, 9 + ) + res = await self._async_read_int16_from_register(CALL_TYPE_REGISTER_HOLDING, 17) + if res < len(self._fan_modes): + self._current_fan_mode = res + self._filter_hours = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 8 + ) + # # Mechanical heat recovery, 0-100% + self._heat_recovery = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 14 + ) + # # Heater active 0-100% + self._heating = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 15 + ) + # # Cooling active 0-100% + self._cooling = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 13 + ) + # # Filter alarm 0/1 + self._filter_alarm = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 27 + ) + # # Heater enabled or not. Does not mean it's necessarily heating + self._heater_enabled = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 28 + ) + self._outdoor_air_temp = await self._async_read_temp_from_register( + CALL_TYPE_REGISTER_INPUT, 11 + ) - self._target_temperature = self.unit.get_target_temp - self._current_temperature = self.unit.get_temp - self._current_fan_mode = self._fan_modes[self.unit.get_fan_speed] - self._filter_hours = self.unit.get_filter_hours - # Mechanical heat recovery, 0-100% - self._heat_recovery = self.unit.get_heat_recovery - # Heater active 0-100% - self._heating = self.unit.get_heating - # Cooling active 0-100% - self._cooling = self.unit.get_cooling - # Filter alarm 0/1 - self._filter_alarm = self.unit.get_filter_alarm - # Heater enabled or not. Does not mean it's necessarily heating - self._heater_enabled = self.unit.get_heater_enabled - # Current operation mode - self._current_operation = self.unit.get_operation + actual_air_speed = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 48 + ) + + if self._heating: + self._current_operation = "Heating" + elif self._cooling: + self._current_operation = "Cooling" + elif self._heat_recovery: + self._current_operation = "Recovering" + elif actual_air_speed: + self._current_operation = "Fan Only" + else: + self._current_operation = "Off" @property def extra_state_attributes(self): @@ -103,6 +150,7 @@ class Flexit(ClimateEntity): "heating": self._heating, "heater_enabled": self._heater_enabled, "cooling": self._cooling, + "outdoor_air_temp": self._outdoor_air_temp, } @property @@ -153,12 +201,53 @@ class Flexit(ClimateEntity): """Return the list of available fan modes.""" return self._fan_modes - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if kwargs.get(ATTR_TEMPERATURE) is not None: - self._target_temperature = kwargs.get(ATTR_TEMPERATURE) - self.unit.set_temp(self._target_temperature) + target_temperature = kwargs.get(ATTR_TEMPERATURE) + else: + _LOGGER.error("Received invalid temperature") + return - def set_fan_mode(self, fan_mode): + if await self._async_write_int16_to_register(8, target_temperature * 10): + self._target_temperature = target_temperature + else: + _LOGGER.error("Modbus error setting target temperature to Flexit") + + async def async_set_fan_mode(self, fan_mode): """Set new fan mode.""" - self.unit.set_fan_speed(self._fan_modes.index(fan_mode)) + if await self._async_write_int16_to_register( + 17, self.fan_modes.index(fan_mode) + ): + self._current_fan_mode = self.fan_modes.index(fan_mode) + else: + _LOGGER.error("Modbus error setting fan mode to Flexit") + + # Based on _async_read_register in ModbusThermostat class + async def _async_read_int16_from_register(self, register_type, register) -> int: + """Read register using the Modbus hub slave.""" + result = await self._hub.async_pymodbus_call( + self._slave, register, 1, register_type + ) + if result is None: + _LOGGER.error("Error reading value from Flexit modbus adapter") + return -1 + + return int(result.registers[0]) + + async def _async_read_temp_from_register(self, register_type, register) -> float: + result = float( + await self._async_read_int16_from_register(register_type, register) + ) + if result == -1: + return -1 + return result / 10.0 + + async def _async_write_int16_to_register(self, register, value) -> bool: + value = int(value) + result = await self._hub.async_pymodbus_call( + self._slave, register, value, CALL_TYPE_WRITE_REGISTER + ) + if result == -1: + return False + return True diff --git a/homeassistant/components/flexit/manifest.json b/homeassistant/components/flexit/manifest.json index 96ed5b55904..d9f84d5ab81 100644 --- a/homeassistant/components/flexit/manifest.json +++ b/homeassistant/components/flexit/manifest.json @@ -2,7 +2,6 @@ "domain": "flexit", "name": "Flexit", "documentation": "https://www.home-assistant.io/integrations/flexit", - "requirements": ["pyflexit==0.3"], "dependencies": ["modbus"], "codeowners": [], "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 5dc4d86d603..a1cf52d2071 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1437,9 +1437,6 @@ pyfido==2.1.1 # homeassistant.components.fireservicerota pyfireservicerota==0.0.43 -# homeassistant.components.flexit -pyflexit==0.3 - # homeassistant.components.flic pyflic==2.0.3 From 422fe48c3a438ba94ba1da83ed4038d3581c70cb Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 7 Aug 2021 12:15:31 +0200 Subject: [PATCH 200/903] Correct device class typo in rfxtrx (#54200) --- homeassistant/components/rfxtrx/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index d697c56f7e8..f6751d760b2 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -111,7 +111,7 @@ async def async_setup_entry( if description is None: description = BinarySensorEntityDescription(key=type_string) if device_class: - description = replace(description, device_class=device) + description = replace(description, device_class=device_class) return description for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): From 6dd875bc4a9b53f3ff7858d1a7486d89d6d6e56d Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 7 Aug 2021 12:17:33 +0200 Subject: [PATCH 201/903] Bump ha-philipsjs to 2.7.5 (#54176) --- homeassistant/components/philips_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 4f3ee5a9ab3..3bea3ff7337 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -2,7 +2,7 @@ "domain": "philips_js", "name": "Philips TV", "documentation": "https://www.home-assistant.io/integrations/philips_js", - "requirements": ["ha-philipsjs==2.7.4"], + "requirements": ["ha-philipsjs==2.7.5"], "codeowners": ["@elupus"], "config_flow": true, "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index a1cf52d2071..d4d958a529b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ guppy3==3.1.0 ha-ffmpeg==3.0.2 # homeassistant.components.philips_js -ha-philipsjs==2.7.4 +ha-philipsjs==2.7.5 # homeassistant.components.habitica habitipy==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 83dfc9695de..f8e66439b42 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -422,7 +422,7 @@ guppy3==3.1.0 ha-ffmpeg==3.0.2 # homeassistant.components.philips_js -ha-philipsjs==2.7.4 +ha-philipsjs==2.7.5 # homeassistant.components.habitica habitipy==0.2.0 From e0bc911e2487766388b2d0c1f20be48660478747 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 7 Aug 2021 12:22:08 +0200 Subject: [PATCH 202/903] Fix Neato reauth flow when token expired (#52843) * Fix Neato reauth flow when token expired * Change and simplify approach * Missing file * Cleanup * Update unique_id * Added missing lamda * Unique_id reworked * Guard for id future ID changes * Bump pybotvac: provide unique_id * Address review comment * Fix update check * Remove token check * Trigger reauth only for 401 and 403 code response * Review comments --- .coveragerc | 1 + homeassistant/components/neato/__init__.py | 47 ++++++------------- homeassistant/components/neato/config_flow.py | 6 +-- homeassistant/components/neato/hub.py | 47 +++++++++++++++++++ homeassistant/components/neato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 68 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/neato/hub.py diff --git a/.coveragerc b/.coveragerc index 1a9221b3abb..839bd34f6e5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -677,6 +677,7 @@ omit = homeassistant/components/neato/__init__.py homeassistant/components/neato/api.py homeassistant/components/neato/camera.py + homeassistant/components/neato/hub.py homeassistant/components/neato/sensor.py homeassistant/components/neato/switch.py homeassistant/components/neato/vacuum.py diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index 28569e0f1d7..2c277a2ac8d 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -1,7 +1,7 @@ """Support for Neato botvac connected vacuum cleaners.""" -from datetime import timedelta import logging +import aiohttp from pybotvac import Account, Neato from pybotvac.exceptions import NeatoException import voluptuous as vol @@ -12,17 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle from . import api, config_flow -from .const import ( - NEATO_CONFIG, - NEATO_DOMAIN, - NEATO_LOGIN, - NEATO_MAP_DATA, - NEATO_PERSISTENT_MAPS, - NEATO_ROBOTS, -) +from .const import NEATO_CONFIG, NEATO_DOMAIN, NEATO_LOGIN +from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) @@ -77,10 +70,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as ex: + _LOGGER.debug("API error: %s (%s)", ex.code, ex.message) + if ex.code in (401, 403): + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + neato_session = api.ConfigEntryAuth(hass, entry, implementation) hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session hub = NeatoHub(hass, Account(neato_session)) + await hub.async_update_entry_unique_id(entry) + try: await hass.async_add_executor_job(hub.update_robots) except NeatoException as ex: @@ -94,32 +97,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigType) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[NEATO_DOMAIN].pop(entry.entry_id) return unload_ok - - -class NeatoHub: - """A My Neato hub wrapper class.""" - - def __init__(self, hass: HomeAssistant, neato: Account) -> None: - """Initialize the Neato hub.""" - self._hass = hass - self.my_neato: Account = neato - - @Throttle(timedelta(minutes=1)) - def update_robots(self): - """Update the robot states.""" - _LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS)) - self._hass.data[NEATO_ROBOTS] = self.my_neato.robots - self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps - self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps - - def download_map(self, url): - """Download a new map image.""" - map_image_data = self.my_neato.get_map_image(url) - return map_image_data diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index 580faffe8ff..c4ca9e45a89 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -5,7 +5,7 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_TOKEN +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.helpers import config_entry_oauth2_flow from .const import NEATO_DOMAIN @@ -26,7 +26,7 @@ class OAuth2FlowHandler( async def async_step_user(self, user_input: dict | None = None) -> dict: """Create an entry for the flow.""" current_entries = self._async_current_entries() - if current_entries and CONF_TOKEN in current_entries[0].data: + if self.source != SOURCE_REAUTH and current_entries: # Already configured return self.async_abort(reason="already_configured") @@ -47,7 +47,7 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict) -> dict: """Create an entry for the flow. Update an entry if one already exist.""" current_entries = self._async_current_entries() - if current_entries and CONF_TOKEN not in current_entries[0].data: + if self.source == SOURCE_REAUTH and current_entries: # Update entry self.hass.config_entries.async_update_entry( current_entries[0], title=self.flow_impl.name, data=data diff --git a/homeassistant/components/neato/hub.py b/homeassistant/components/neato/hub.py new file mode 100644 index 00000000000..b394507f408 --- /dev/null +++ b/homeassistant/components/neato/hub.py @@ -0,0 +1,47 @@ +"""Support for Neato botvac connected vacuum cleaners.""" +from datetime import timedelta +import logging + +from pybotvac import Account + +from homeassistant.core import HomeAssistant +from homeassistant.util import Throttle + +from .const import NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS + +_LOGGER = logging.getLogger(__name__) + + +class NeatoHub: + """A My Neato hub wrapper class.""" + + def __init__(self, hass: HomeAssistant, neato: Account) -> None: + """Initialize the Neato hub.""" + self._hass = hass + self.my_neato: Account = neato + + @Throttle(timedelta(minutes=1)) + def update_robots(self): + """Update the robot states.""" + _LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS)) + self._hass.data[NEATO_ROBOTS] = self.my_neato.robots + self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps + self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps + + def download_map(self, url): + """Download a new map image.""" + map_image_data = self.my_neato.get_map_image(url) + return map_image_data + + async def async_update_entry_unique_id(self, entry) -> str: + """Update entry for unique_id.""" + + await self._hass.async_add_executor_job(self.my_neato.refresh_userdata) + unique_id = self.my_neato.unique_id + + if entry.unique_id == unique_id: + return unique_id + + _LOGGER.debug("Updating user unique_id for previous config entry") + self._hass.config_entries.async_update_entry(entry, unique_id=unique_id) + return unique_id diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index 014e366db46..fc751df45de 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -3,7 +3,7 @@ "name": "Neato Botvac", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/neato", - "requirements": ["pybotvac==0.0.21"], + "requirements": ["pybotvac==0.0.22"], "codeowners": ["@dshokouhi", "@Santobert"], "dependencies": ["http"], "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index d4d958a529b..7a4f1153d70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1342,7 +1342,7 @@ pyblackbird==0.5 # pybluez==0.22 # homeassistant.components.neato -pybotvac==0.0.21 +pybotvac==0.0.22 # homeassistant.components.nissan_leaf pycarwings2==2.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8e66439b42..bbd9cbbc7ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -760,7 +760,7 @@ pyatv==0.8.2 pyblackbird==0.5 # homeassistant.components.neato -pybotvac==0.0.21 +pybotvac==0.0.22 # homeassistant.components.cloudflare pycfdns==1.2.1 From ca2bdfab6b7701e4fc250387a82ef1dfde93dd6f Mon Sep 17 00:00:00 2001 From: Mk4242 <76903406+Mk4242@users.noreply.github.com> Date: Sat, 7 Aug 2021 18:24:19 +0200 Subject: [PATCH 203/903] Update const.py (#54195) Remove extra attribute for FlowTemperature sensor, which prevents the ebusd integration from initialising --- homeassistant/components/ebusd/const.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py index 7052a9950fd..3d4ab508ca2 100644 --- a/homeassistant/components/ebusd/const.py +++ b/homeassistant/components/ebusd/const.py @@ -223,7 +223,6 @@ SENSOR_TYPES = { None, 4, DEVICE_CLASS_TEMPERATURE, - None, ], "Flame": ["Flame", None, "mdi:toggle-switch", 2, None], "PowerEnergyConsumptionHeatingCircuit": [ From 819131ad21ef2516028f73a26d2c0c49660d8c66 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 7 Aug 2021 19:15:25 +0200 Subject: [PATCH 204/903] Raise ConfigEntryNotReady for Neato API error (#54227) --- homeassistant/components/neato/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index 2c277a2ac8d..6310e81cdd0 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -77,6 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("API error: %s (%s)", ex.code, ex.message) if ex.code in (401, 403): raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + raise ConfigEntryNotReady from ex neato_session = api.ConfigEntryAuth(hass, entry, implementation) hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session From 3b1d44478a0ecefdef82e8d5e6b5f39e8b85eaa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 7 Aug 2021 20:22:02 +0200 Subject: [PATCH 205/903] Change update interval from 60s to 10s for Uptime Robot (#54230) --- homeassistant/components/uptimerobot/const.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/uptimerobot/const.py b/homeassistant/components/uptimerobot/const.py index ee9832a040a..7f3655b75cf 100644 --- a/homeassistant/components/uptimerobot/const.py +++ b/homeassistant/components/uptimerobot/const.py @@ -7,7 +7,8 @@ from typing import Final LOGGER: Logger = getLogger(__package__) -COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=60) +# The free plan is limited to 10 requests/minute +COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=10) DOMAIN: Final = "uptimerobot" PLATFORMS: Final = ["binary_sensor"] From a485b14293588bf5d82bf64d391f447ff5743936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 7 Aug 2021 20:22:19 +0200 Subject: [PATCH 206/903] Set entities as unavailable if last update was not successful (#54229) --- homeassistant/components/uptimerobot/entity.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index 4b4847dfc7c..b265af77535 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -75,4 +75,6 @@ class UptimeRobotEntity(CoordinatorEntity): @property def available(self) -> bool: """Returtn if entity is available.""" + if not self.coordinator.last_update_success: + return False return self.monitor is not None From af565ea6bd8bc88cd10b5a6f1d979a9e121c74cc Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 8 Aug 2021 00:11:15 +0000 Subject: [PATCH 207/903] [ci skip] Translation update --- .../adguard/translations/zh-Hans.json | 13 ++++- .../agent_dvr/translations/zh-Hans.json | 13 +++++ .../asuswrt/translations/zh-Hans.json | 44 ++++++++++++++++ .../brother/translations/zh-Hans.json | 15 ++++++ .../cert_expiry/translations/zh-Hans.json | 13 +++-- .../co2signal/translations/zh-Hans.json | 11 ++++ .../coronavirus/translations/zh-Hans.json | 3 +- .../components/energy/translations/es.json | 3 ++ .../ezviz/translations/zh-Hans.json | 52 +++++++++++++++++++ .../components/flipr/translations/es.json | 15 ++++++ .../glances/translations/zh-Hans.json | 26 ++++++++-- .../components/gree/translations/zh-Hans.json | 13 +++++ .../homeassistant/translations/es.json | 1 + .../homeassistant/translations/zh-Hans.json | 1 + .../translations/zh-Hans.json | 2 + .../huawei_lte/translations/zh-Hans.json | 3 +- .../components/ipp/translations/zh-Hans.json | 19 ++++++- .../components/kodi/translations/zh-Hans.json | 41 ++++++++++++++- .../mikrotik/translations/zh-Hans.json | 23 +++++++- .../translations/zh-Hans.json | 22 ++++++++ .../onvif/translations/zh-Hans.json | 52 ++++++++++++++++++- .../components/plex/translations/zh-Hans.json | 38 +++++++++++++- .../components/prosegur/translations/es.json | 29 +++++++++++ .../components/prosegur/translations/pt.json | 19 +++++++ .../components/renault/translations/es.json | 19 +++++++ .../renault/translations/zh-Hans.json | 26 ++++++++++ .../samsungtv/translations/zh-Hans.json | 34 ++++++++++++ .../solaredge/translations/zh-Hans.json | 16 +++++- .../spotify/translations/zh-Hans.json | 19 +++++++ .../syncthing/translations/zh-Hans.json | 22 ++++++++ .../syncthru/translations/zh-Hans.json | 7 +++ .../synology_dsm/translations/zh-Hans.json | 12 ++++- .../components/tractive/translations/es.json | 19 +++++++ .../components/tractive/translations/he.json | 19 +++++++ .../components/tractive/translations/pt.json | 16 ++++++ .../tractive/translations/zh-Hans.json | 19 +++++++ .../transmission/translations/zh-Hans.json | 28 +++++++++- .../uptimerobot/translations/es.json | 20 +++++++ .../uptimerobot/translations/he.json | 4 +- .../uptimerobot/translations/pt.json | 10 ++++ .../uptimerobot/translations/zh-Hans.json | 20 +++++++ .../vizio/translations/zh-Hans.json | 12 +++++ .../xiaomi_miio/translations/select.es.json | 9 ++++ .../translations/select.zh-Hans.json | 9 ++++ .../yale_smart_alarm/translations/es.json | 28 ++++++++++ .../components/youless/translations/es.json | 15 ++++++ 46 files changed, 830 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/asuswrt/translations/zh-Hans.json create mode 100644 homeassistant/components/co2signal/translations/zh-Hans.json create mode 100644 homeassistant/components/energy/translations/es.json create mode 100644 homeassistant/components/ezviz/translations/zh-Hans.json create mode 100644 homeassistant/components/flipr/translations/es.json create mode 100644 homeassistant/components/gree/translations/zh-Hans.json create mode 100644 homeassistant/components/minecraft_server/translations/zh-Hans.json create mode 100644 homeassistant/components/prosegur/translations/es.json create mode 100644 homeassistant/components/prosegur/translations/pt.json create mode 100644 homeassistant/components/renault/translations/es.json create mode 100644 homeassistant/components/renault/translations/zh-Hans.json create mode 100644 homeassistant/components/samsungtv/translations/zh-Hans.json create mode 100644 homeassistant/components/syncthing/translations/zh-Hans.json create mode 100644 homeassistant/components/syncthru/translations/zh-Hans.json create mode 100644 homeassistant/components/tractive/translations/es.json create mode 100644 homeassistant/components/tractive/translations/he.json create mode 100644 homeassistant/components/tractive/translations/pt.json create mode 100644 homeassistant/components/tractive/translations/zh-Hans.json create mode 100644 homeassistant/components/uptimerobot/translations/es.json create mode 100644 homeassistant/components/uptimerobot/translations/pt.json create mode 100644 homeassistant/components/uptimerobot/translations/zh-Hans.json create mode 100644 homeassistant/components/vizio/translations/zh-Hans.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.es.json create mode 100644 homeassistant/components/xiaomi_miio/translations/select.zh-Hans.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/es.json create mode 100644 homeassistant/components/youless/translations/es.json diff --git a/homeassistant/components/adguard/translations/zh-Hans.json b/homeassistant/components/adguard/translations/zh-Hans.json index 4204beb5268..ee68ce83e91 100644 --- a/homeassistant/components/adguard/translations/zh-Hans.json +++ b/homeassistant/components/adguard/translations/zh-Hans.json @@ -1,14 +1,23 @@ { "config": { "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", "existing_instance_updated": "\u66f4\u65b0\u4e86\u73b0\u6709\u914d\u7f6e\u3002" }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, "step": { "user": { "data": { + "host": "\u4e3b\u673a\u5730\u5740", "password": "\u5bc6\u7801", - "username": "\u7528\u6237\u540d" - } + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u8bc1\u4e66\u51ed\u8bc1", + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66\u51ed\u8bc1" + }, + "description": "\u8bbe\u7f6e\u60a8\u7684 AdGuard Home \u5b9e\u4f8b\u4ee5\u5141\u8bb8\u76d1\u89c6\u548c\u63a7\u5236" } } } diff --git a/homeassistant/components/agent_dvr/translations/zh-Hans.json b/homeassistant/components/agent_dvr/translations/zh-Hans.json index 2941dfd9383..68393fce470 100644 --- a/homeassistant/components/agent_dvr/translations/zh-Hans.json +++ b/homeassistant/components/agent_dvr/translations/zh-Hans.json @@ -1,7 +1,20 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, "error": { + "already_in_progress": "\u914d\u7f6e\u6d41\u5df2\u8fdb\u884c\u4e2d", "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3" + }, + "title": "\u914d\u7f6e Agent DVR" + } } } } \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/zh-Hans.json b/homeassistant/components/asuswrt/translations/zh-Hans.json new file mode 100644 index 00000000000..69f7bf98df3 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/zh-Hans.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86\uff0c\u4e14\u53ea\u80fd\u914d\u7f6e\u4e00\u6b21\u3002" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_host": "\u65e0\u6548\u7684\u4e3b\u673a\u5730\u5740\u6216 IP \u5730\u5740", + "pwd_and_ssh": "\u53ea\u63d0\u4f9b\u5bc6\u7801\u6216 SSH \u5bc6\u94a5\u6587\u4ef6", + "pwd_or_ssh": "\u8bf7\u63d0\u4f9b\u5bc6\u7801\u6216 SSH \u5bc6\u94a5\u6587\u4ef6", + "ssh_not_file": "\u672a\u627e\u5230 SSH \u5bc6\u94a5\u6587\u4ef6", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "mode": "\u4f7f\u7528\u6a21\u5f0f", + "name": "\u540d\u79f0", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "protocol": "\u901a\u4fe1\u534f\u8bae", + "ssh_key": "SSH \u5bc6\u94a5\u6587\u4ef6\u8def\u5f84 (\u4e0d\u662f\u5bc6\u7801)", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8bbe\u7f6e\u8fde\u63a5\u5230\u8def\u7531\u5668\u6240\u9700\u7684\u53c2\u6570", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u7b49\u5f85\u591a\u5c11\u79d2\u540e\u5219\u5224\u5b9a\u8bbe\u5907\u79bb\u5f00", + "dnsmasq": "\u8def\u7531\u5668\u4e2d\u7684 dnsmasq.leases \u6587\u4ef6\u4f4d\u7f6e", + "interface": "\u60f3\u8981\u76d1\u6d4b\u7684\u7aef\u53e3(\u4f8b\u5982: eth0,eth1 \u7b49)", + "require_ip": "\u8bbe\u5907\u5fc5\u987b\u5177\u6709 IP (\u7528\u4e8e\u63a5\u5165\u70b9\u6a21\u5f0f)" + }, + "title": "AsusWRT \u9009\u9879" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/zh-Hans.json b/homeassistant/components/brother/translations/zh-Hans.json index 8f9e85e54a9..91e0c310dd1 100644 --- a/homeassistant/components/brother/translations/zh-Hans.json +++ b/homeassistant/components/brother/translations/zh-Hans.json @@ -1,8 +1,23 @@ { "config": { + "abort": { + "unsupported_model": "\u4e0d\u652f\u6301\u6b64\u6253\u5370\u673a\u578b\u53f7\u3002" + }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", "snmp_error": "SNMP\u670d\u52a1\u5668\u5df2\u5173\u95ed\u6216\u4e0d\u652f\u6301\u6253\u5370\u3002" + }, + "step": { + "user": { + "description": "\u8bbe\u7f6e Brother \u6253\u5370\u673a\u96c6\u6210\u3002\u5982\u679c\u60a8\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/brother" + }, + "zeroconf_confirm": { + "data": { + "type": "\u6253\u5370\u673a\u7c7b\u578b" + }, + "description": "\u60a8\u662f\u5426\u8981\u5c06 Brother \u6253\u5370\u673a {model} (\u5e8f\u5217\u53f7:`{serial_number}`) \u6dfb\u52a0\u5230 Home Assistant ?", + "title": "\u5df2\u53d1\u73b0\u7684 Brother \u6253\u5370\u673a" + } } } } \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/zh-Hans.json b/homeassistant/components/cert_expiry/translations/zh-Hans.json index 07affc990a8..201749ae796 100644 --- a/homeassistant/components/cert_expiry/translations/zh-Hans.json +++ b/homeassistant/components/cert_expiry/translations/zh-Hans.json @@ -1,15 +1,22 @@ { "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", + "import_failed": "\u914d\u7f6e\u5bfc\u5165\u5931\u8d25" + }, "error": { - "connection_timeout": "\u8fde\u63a5\u5230\u6b64\u4e3b\u673a\u65f6\u7684\u8d85\u65f6" + "connection_refused": "\u8fde\u63a5\u5230\u4e3b\u673a\u65f6\u88ab\u62d2\u7edd\u8fde\u63a5", + "connection_timeout": "\u8fde\u63a5\u5230\u6b64\u4e3b\u673a\u65f6\u7684\u8d85\u65f6", + "resolve_failed": "\u65e0\u6cd5\u89e3\u6790\u4e3b\u673a" }, "step": { "user": { "data": { - "host": "\u8bc1\u4e66\u7684\u4e3b\u673a\u540d", + "host": "\u4e3b\u673a\u5730\u5740", "name": "\u8bc1\u4e66\u7684\u540d\u79f0", "port": "\u8bc1\u4e66\u7684\u7aef\u53e3" - } + }, + "title": "\u5b9a\u4e49\u8981\u6d4b\u8bd5\u7684\u8bc1\u4e66" } } } diff --git a/homeassistant/components/co2signal/translations/zh-Hans.json b/homeassistant/components/co2signal/translations/zh-Hans.json new file mode 100644 index 00000000000..af750541de5 --- /dev/null +++ b/homeassistant/components/co2signal/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "\u8bbf\u95ee\u4ee4\u724c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/zh-Hans.json b/homeassistant/components/coronavirus/translations/zh-Hans.json index 5bb92ac1172..6348ac40896 100644 --- a/homeassistant/components/coronavirus/translations/zh-Hans.json +++ b/homeassistant/components/coronavirus/translations/zh-Hans.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u6b64\u56fd\u5bb6/\u5730\u533a\u5df2\u914d\u7f6e\u5b8c\u6210\u3002" + "already_configured": "\u6b64\u56fd\u5bb6/\u5730\u533a\u5df2\u914d\u7f6e\u5b8c\u6210\u3002", + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" }, "step": { "user": { diff --git a/homeassistant/components/energy/translations/es.json b/homeassistant/components/energy/translations/es.json new file mode 100644 index 00000000000..64c2f5bffa1 --- /dev/null +++ b/homeassistant/components/energy/translations/es.json @@ -0,0 +1,3 @@ +{ + "title": "Energ\u00eda" +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/zh-Hans.json b/homeassistant/components/ezviz/translations/zh-Hans.json new file mode 100644 index 00000000000..3d8daedec73 --- /dev/null +++ b/homeassistant/components/ezviz/translations/zh-Hans.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured_account": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", + "ezviz_cloud_account_missing": "\u8424\u77f3\u4e91\u8d26\u53f7\u4e22\u5931\u3002\u8bf7\u91cd\u65b0\u914d\u7f6e\u8424\u77f3\u4e91\u8d26\u53f7\u3002", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u51ed\u8bc1\u65e0\u6548", + "invalid_host": "\u65e0\u6548\u7684\u4e3b\u673a\u5730\u5740\u6216 IP \u5730\u5740" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8f93\u5165\u5e26\u6709 RTSP \u51ed\u8bc1\u7684\u8424\u77f3\u6444\u50cf\u5934{serial} IP {ip_address} ", + "title": "\u5df2\u53d1\u73b0\u7684\u8424\u77f3\u6444\u50cf\u5934" + }, + "user": { + "data": { + "password": "\u5bc6\u7801", + "url": "URL", + "username": "\u7528\u6237\u540d" + }, + "title": "\u8fde\u63a5\u5230\u8424\u77f3\u4e91" + }, + "user_custom_url": { + "data": { + "password": "\u5bc6\u7801", + "url": "URL", + "username": "\u7528\u6237\u540d" + }, + "description": "\u624b\u52a8\u6307\u5b9a\u4f60\u7684\u533a\u57df\u7f51\u5740", + "title": "\u8fde\u63a5\u5230\u81ea\u5b9a\u4e49\u8424\u77f3\u4e91\u5730\u5740" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "FFmpeg \u53c2\u6570\u4f20\u9012\u81f3\u6444\u50cf\u673a", + "timeout": "\u8bf7\u6c42\u8d85\u65f6\uff08\u79d2\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/es.json b/homeassistant/components/flipr/translations/es.json new file mode 100644 index 00000000000..478510ba5f1 --- /dev/null +++ b/homeassistant/components/flipr/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "unknown": "Error desconocido" + }, + "step": { + "user": { + "data": { + "email": "Correo-e", + "password": "Clave" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/zh-Hans.json b/homeassistant/components/glances/translations/zh-Hans.json index 22cb2995672..a62b5f8b32e 100644 --- a/homeassistant/components/glances/translations/zh-Hans.json +++ b/homeassistant/components/glances/translations/zh-Hans.json @@ -1,15 +1,35 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u8fde\u63a5" + }, "error": { - "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "wrong_version": "\u4e0d\u652f\u6301\u7684\u7248\u672c (\u4ec5\u96502\u62163)" }, "step": { "user": { "data": { + "host": "\u4e3b\u673a\u5730\u5740", "name": "\u540d\u79f0", "password": "\u5bc6\u7801", - "username": "\u7528\u6237\u540d" - } + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u51ed\u8bc1", + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66", + "version": "Glances API \u7248\u672c (2 \u6216 3)" + }, + "title": "\u8bbe\u7f6e Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9891\u7387" + }, + "description": "\u914d\u7f6e Glances \u9009\u9879" } } } diff --git a/homeassistant/components/gree/translations/zh-Hans.json b/homeassistant/components/gree/translations/zh-Hans.json new file mode 100644 index 00000000000..808f01b57a8 --- /dev/null +++ b/homeassistant/components/gree/translations/zh-Hans.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u6b64\u7f51\u7edc\u672a\u53d1\u73b0\u76f8\u5173\u8bbe\u5907", + "single_instance_allowed": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e\u3002\u53ea\u5141\u8bb8\u5b58\u5728\u4e00\u4e2a\u914d\u7f6e\u6587\u6863" + }, + "step": { + "confirm": { + "description": "\u4f60\u60f3\u8981\u5f00\u59cb\u914d\u7f6e\u5417\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/es.json b/homeassistant/components/homeassistant/translations/es.json index 562a7335617..0a9342afa69 100644 --- a/homeassistant/components/homeassistant/translations/es.json +++ b/homeassistant/components/homeassistant/translations/es.json @@ -10,6 +10,7 @@ "os_version": "Versi\u00f3n del Sistema Operativo", "python_version": "Versi\u00f3n de Python", "timezone": "Zona horaria", + "user": "Usuario", "version": "Versi\u00f3n", "virtualenv": "Entorno virtual" } diff --git a/homeassistant/components/homeassistant/translations/zh-Hans.json b/homeassistant/components/homeassistant/translations/zh-Hans.json index 617866926b8..e640d502e0c 100644 --- a/homeassistant/components/homeassistant/translations/zh-Hans.json +++ b/homeassistant/components/homeassistant/translations/zh-Hans.json @@ -10,6 +10,7 @@ "os_version": "\u64cd\u4f5c\u7cfb\u7edf\u7248\u672c", "python_version": "Python \u7248\u672c", "timezone": "\u65f6\u533a", + "user": "\u7528\u6237", "version": "\u7248\u672c", "virtualenv": "\u865a\u62df\u73af\u5883" } diff --git a/homeassistant/components/homekit_controller/translations/zh-Hans.json b/homeassistant/components/homekit_controller/translations/zh-Hans.json index 624050e7146..7da392179f6 100644 --- a/homeassistant/components/homekit_controller/translations/zh-Hans.json +++ b/homeassistant/components/homekit_controller/translations/zh-Hans.json @@ -12,6 +12,7 @@ }, "error": { "authentication_error": "HomeKit \u4ee3\u7801\u4e0d\u6b63\u786e\u3002\u8bf7\u68c0\u67e5\u540e\u91cd\u8bd5\u3002", + "insecure_setup_code": "\u8bf7\u6c42\u7684\u8bbe\u7f6e\u4ee3\u7801\u7531\u4e8e\u8fc7\u4e8e\u7b80\u5355\u800c\u4e0d\u5b89\u5168\u3002\u6b64\u914d\u4ef6\u4e0d\u7b26\u5408\u57fa\u672c\u5b89\u5168\u8981\u6c42\u3002", "max_peers_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u6ca1\u6709\u7a7a\u95f2\u7684\u914d\u5bf9\u5b58\u50a8\u7a7a\u95f4\u3002", "pairing_failed": "\u5c1d\u8bd5\u4e0e\u6b64\u8bbe\u5907\u914d\u5bf9\u65f6\u53d1\u751f\u672a\u5904\u7406\u7684\u9519\u8bef\u3002\u8fd9\u53ef\u80fd\u662f\u6682\u65f6\u6027\u6545\u969c\uff0c\u4e5f\u53ef\u80fd\u662f\u60a8\u7684\u8bbe\u5907\u76ee\u524d\u4e0d\u88ab\u652f\u6301\u3002", "unable_to_pair": "\u65e0\u6cd5\u914d\u5bf9\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "\u5141\u8bb8\u4f7f\u7528\u4e0d\u5b89\u5168\u7684\u8bbe\u7f6e\u4ee3\u7801\u914d\u5bf9\u3002", "pairing_code": "\u914d\u5bf9\u4ee3\u7801" }, "description": "\u8f93\u5165\u60a8\u7684 HomeKit \u914d\u5bf9\u4ee3\u7801\uff08\u683c\u5f0f\u4e3a XXX-XX-XXX\uff09\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", diff --git a/homeassistant/components/huawei_lte/translations/zh-Hans.json b/homeassistant/components/huawei_lte/translations/zh-Hans.json index 987c53e4d5c..4fb447403d6 100644 --- a/homeassistant/components/huawei_lte/translations/zh-Hans.json +++ b/homeassistant/components/huawei_lte/translations/zh-Hans.json @@ -1,7 +1,8 @@ { "config": { "error": { - "incorrect_username": "\u7528\u6237\u540d\u9519\u8bef" + "incorrect_username": "\u7528\u6237\u540d\u9519\u8bef", + "login_attempts_exceeded": "\u5df2\u8d85\u8fc7\u6700\u5927\u767b\u5f55\u6b21\u6570\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5" } } } \ No newline at end of file diff --git a/homeassistant/components/ipp/translations/zh-Hans.json b/homeassistant/components/ipp/translations/zh-Hans.json index 254f6df9327..38242cae563 100644 --- a/homeassistant/components/ipp/translations/zh-Hans.json +++ b/homeassistant/components/ipp/translations/zh-Hans.json @@ -1,10 +1,25 @@ { "config": { "abort": { - "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "ipp_version_error": "\u6253\u5370\u673a\u4e0d\u652f\u6301\u8be5 IPP \u7248\u672c", + "parse_error": "\u65e0\u6cd5\u89e3\u6790\u6253\u5370\u673a\u54cd\u5e94\u3002" }, "error": { - "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "connection_upgrade": "\u65e0\u6cd5\u8fde\u63a5\u5230\u6253\u5370\u673a\u3002\u8bf7\u9009\u4e2d SSL/TLS \u9009\u9879\u540e\u91cd\u8bd5\u3002" + }, + "step": { + "user": { + "data": { + "base_path": "\u6253\u5370\u673a\u7684\u76f8\u5bf9\u8def\u5f84" + }, + "description": "\u901a\u8fc7 Internet \u6253\u5370\u534f\u8bae (IPP) \u8bbe\u7f6e\u60a8\u7684\u6253\u5370\u673a\uff0c\u4e0e Home Assistant \u8fde\u63a5\u3002", + "title": "\u8fde\u63a5\u60a8\u7684\u6253\u5370\u673a" + }, + "zeroconf_confirm": { + "title": "\u5df2\u53d1\u73b0\u7684\u6253\u5370\u673a" + } } } } \ No newline at end of file diff --git a/homeassistant/components/kodi/translations/zh-Hans.json b/homeassistant/components/kodi/translations/zh-Hans.json index 6fe91b6e995..12915ccdb9b 100644 --- a/homeassistant/components/kodi/translations/zh-Hans.json +++ b/homeassistant/components/kodi/translations/zh-Hans.json @@ -1,11 +1,50 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u65e0\u6548\u9a8c\u8bc1", + "no_uuid": "Kodi \u5b9e\u4f8b\u6ca1\u6709\u552f\u4e00\u7684 ID\u3002\u8fd9\u5f88\u53ef\u80fd\u662f\u7531\u4e8e\u65e7\u7684 Kodi \u7248\u672c\uff0817.x \u6216\u66f4\u4f4e\u7248\u672c\uff09\u9020\u6210\u3002\u60a8\u53ef\u4ee5\u624b\u52a8\u914d\u7f6e\u96c6\u6210\u6216\u5347\u7ea7\u5230\u66f4\u65b0\u7684 Kodi \u7248\u672c\u3002", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "flow_title": "{name}", "step": { "credentials": { "data": { + "password": "\u5bc6\u7801", "username": "\u7528\u6237\u540d" - } + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u7684 Kodi \u7528\u6237\u540d\u548c\u5bc6\u7801\u3002\u8fd9\u4e9b\u53ef\u4ee5\u5728\u201c\u7cfb\u7edf/\u8bbe\u7f6e/\u7f51\u7edc/\u670d\u52a1\u201d\u4e2d\u627e\u5230\u3002" + }, + "discovery_confirm": { + "description": "\u60a8\u662f\u5426\u60f3\u8981\u5c06 Kodi (`{name}`) \u6dfb\u52a0\u5230 Home Assistant?", + "title": "\u5df2\u53d1\u73b0 Kodi" + }, + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u9a8c\u8bc1" + }, + "description": "Kodi \u8fde\u63a5\u4fe1\u606f\u3002\n\u8bf7\u786e\u4fdd\u5728\u8bbe\u7f6e\uff1a\u201c\u7cfb\u7edf/\u8bbe\u7f6e/\u7f51\u7edc/\u670d\u52a1\u201d\u4e2d\u542f\u7528\u201c\u5141\u8bb8\u901a\u8fc7 HTTP \u63a7\u5236 Kodi\u201d\u3002" + }, + "ws_port": { + "data": { + "ws_port": "\u7aef\u53e3" + }, + "description": "WebSocket \u7aef\u53e3(\u5728 Kodi \u4e2d\u6709\u65f6\u79f0\u4e3a TCP \u7aef\u53e3)\u3002\u4e3a\u901a\u8fc7 WebSocket \u8fdb\u884c\u8fde\u63a5\uff0c\u60a8\u9700\u8981\u5728\"\u7cfb\u7edf/\u8bbe\u7f6e/\u7f51\u7edc/\u670d\u52a1\"\u4e2d\u542f\u7528\u201c\u5141\u8bb8\u7a0b\u5e8f...\u63a7\u5236 Kodi\u201d\u3002\u5982\u679c\u672a\u542f\u7528 WebSocket\uff0c\u8bf7\u79fb\u9664\u7aef\u53e3\u5e76\u7559\u7a7a\u3002" } } + }, + "device_automation": { + "trigger_type": { + "turn_off": "[entity_name} \u88ab\u8981\u6c42\u5173\u95ed", + "turn_on": "[entity_name} \u88ab\u8981\u6c42\u6253\u5f00" + } } } \ No newline at end of file diff --git a/homeassistant/components/mikrotik/translations/zh-Hans.json b/homeassistant/components/mikrotik/translations/zh-Hans.json index 9604af53495..14916be1264 100644 --- a/homeassistant/components/mikrotik/translations/zh-Hans.json +++ b/homeassistant/components/mikrotik/translations/zh-Hans.json @@ -1,14 +1,33 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548", + "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728" + }, "step": { "user": { "data": { "host": "\u4e3b\u673a", - "name": "\u540d\u5b57", + "name": "\u540d\u79f0", "password": "\u5bc6\u7801", "port": "\u7aef\u53e3", "username": "\u7528\u6237\u540d", - "verify_ssl": "\u4f7f\u7528 ssl" + "verify_ssl": "\u4f7f\u7528 SSL" + }, + "title": "\u8bbe\u7f6e Mikrotik \u8def\u7531\u5668" + } + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "\u542f\u7528 ARP Ping", + "force_dhcp": "\u4f7f\u7528 DHCP \u5f3a\u5236\u626b\u63cf" } } } diff --git a/homeassistant/components/minecraft_server/translations/zh-Hans.json b/homeassistant/components/minecraft_server/translations/zh-Hans.json new file mode 100644 index 00000000000..ef3c08c8434 --- /dev/null +++ b/homeassistant/components/minecraft_server/translations/zh-Hans.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230\u670d\u52a1\u5668\u3002\u8bf7\u68c0\u67e5\u4e3b\u673a\u5730\u5740\u548c\u7aef\u53e3\u5e76\u91cd\u8bd5\uff0c\u4e14\u786e\u4fdd\u60a8\u5728\u670d\u52a1\u5668\u4e0a\u8fd0\u884c\u7684 Minecraft \u7248\u672c\u81f3\u5c11\u5728 1.7 \u4ee5\u4e0a\u3002", + "invalid_ip": "IP \u5730\u5740\u65e0\u6548 (\u65e0\u6cd5\u786e\u5b9a MAC \u5730\u5740)\u3002\u8bf7\u66f4\u6b63\u5e76\u91cd\u8bd5\u3002", + "invalid_port": "\u7aef\u53e3\u7684\u8303\u56f4\u5728 1024 \u5230 65535 \u4e4b\u95f4\u3002\u8bf7\u66f4\u6b63\u5e76\u91cd\u8bd5\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0" + }, + "description": "\u8bbe\u7f6e\u60a8\u7684 Minecraft \u670d\u52a1\u5668\u5b9e\u4f8b\u4ee5\u5141\u8bb8\u76d1\u63a7\u3002", + "title": "\u8fde\u63a5\u60a8\u7684 Minecraft \u670d\u52a1\u5668" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/onvif/translations/zh-Hans.json b/homeassistant/components/onvif/translations/zh-Hans.json index 0a0b6db3d38..13dd993228e 100644 --- a/homeassistant/components/onvif/translations/zh-Hans.json +++ b/homeassistant/components/onvif/translations/zh-Hans.json @@ -1,19 +1,69 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u5df2\u5728\u8fdb\u884c\u4e2d", + "no_h264": "\u65e0\u53ef\u7528\u7684 H264 \u76f4\u64ad\u6d41\u3002\u8bf7\u68c0\u67e5\u8be5\u8bbe\u5907\u4e0a\u7684\u914d\u7f6e\u6587\u4ef6\u3002", + "no_mac": "\u65e0\u6cd5\u4e3a ONVIF \u914d\u7f6e\u8bbe\u5907\u552f\u4e00 ID", + "onvif_error": "\u914d\u7f6e ONVIF \u8bbe\u5907\u65f6\u51fa\u9519\u3002\u68c0\u67e5\u65e5\u5fd7\u4ee5\u83b7\u53d6\u66f4\u591a\u4fe1\u606f\u3002" + }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25" }, "step": { "auth": { "data": { + "password": "\u5bc6\u7801", "username": "\u7528\u6237\u540d" - } + }, + "title": "\u914d\u7f6e\u8ba4\u8bc1\u4fe1\u606f" + }, + "configure": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d" + }, + "title": "\u914d\u7f6e ONVIF \u8bbe\u5907" + }, + "configure_profile": { + "data": { + "include": "\u521b\u5efa\u6444\u50cf\u673a\u5b9e\u4f53" + }, + "description": "\u4ee5 {resolution} \u5206\u8fa8\u7387\u521b\u5efa {profile} \u6444\u50cf\u673a\u5b9e\u4f53\uff1f", + "title": "\u914d\u7f6e \u914d\u7f6e\u6587\u4ef6" + }, + "device": { + "data": { + "host": "\u9009\u62e9\u5df2\u88ab\u53d1\u73b0\u7684 ONVIF \u8bbe\u5907" + }, + "title": "\u9009\u62e9 ONVIF \u8bbe\u5907" + }, + "manual_input": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0", + "port": "\u7aef\u53e3" + }, + "title": "\u914d\u7f6e ONVIF \u8bbe\u5907" + }, + "user": { + "data": { + "auto": "\u81ea\u52a8\u641c\u7d22" + }, + "description": "\u901a\u8fc7\u70b9\u51fb\u63d0\u4ea4\u6309\u94ae\uff0cHome Assistant \u5c06\u4f1a\u5c1d\u8bd5\u641c\u7d22\u60a8\u7684\u7f51\u7edc\u4e2d\u652f\u6301 Profile S \u7684 ONVIF \u8bbe\u5907\u3002\n\n\u9700\u8981\u6ce8\u610f\u7684\u662f\uff0c\u6709\u4e9b\u751f\u4ea7\u5546\u51fa\u5382\u65f6\u9ed8\u8ba4\u4f1a\u5c06 ONVIF \u529f\u80fd\u5173\u95ed\u3002\u8bf7\u786e\u8ba4\u60a8\u7684\u6444\u50cf\u5934\u5df2\u6253\u5f00\u8be5\u529f\u80fd\u3002", + "title": "\u914d\u7f6e ONVIF \u8bbe\u5907" } } }, "options": { "step": { "onvif_devices": { + "data": { + "extra_arguments": "\u9644\u52a0 FFmpeg \u53c2\u6570", + "rtsp_transport": "RTSP \u4f20\u8f93" + }, "title": "ONVIF \u8bbe\u5907\u9009\u9879" } } diff --git a/homeassistant/components/plex/translations/zh-Hans.json b/homeassistant/components/plex/translations/zh-Hans.json index 9cc02584789..02f548f2286 100644 --- a/homeassistant/components/plex/translations/zh-Hans.json +++ b/homeassistant/components/plex/translations/zh-Hans.json @@ -1,11 +1,45 @@ { "config": { + "abort": { + "already_configured": "\u6b64 Plex \u670d\u52a1\u5668\u5df2\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u5df2\u5728\u8fdb\u884c\u4e2d", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f", + "token_request_timeout": "\u83b7\u53d6\u4ee4\u724c\u8d85\u65f6", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "faulty_credentials": "\u6388\u6743\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u4ee4\u724c\u4fe1\u606f", + "host_or_token": "\u5fc5\u987b\u81f3\u5c11\u63d0\u4f9b\u4e00\u4e2a\u4e3b\u673a\u5730\u5740\u6216\u4ee4\u724c", + "not_found": "\u627e\u4e0d\u5230 Plex \u670d\u52a1\u5668", + "ssl_error": "SSL \u8bc1\u4e66\u9519\u8bef" + }, + "flow_title": "{name} ({host})", "step": { + "manual_setup": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u8bc1\u4e66", + "token": "\u4ee4\u724c (\u53ef\u9009)", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66" + }, + "title": "\u624b\u52a8\u914d\u7f6e" + }, "select_server": { "data": { "server": "\u670d\u52a1\u5668" }, + "description": "\u6709\u591a\u4e2a\u53ef\u7528\u670d\u52a1\u5668\uff0c\u8bf7\u9009\u62e9\uff1a", "title": "\u9009\u62e9 Plex \u670d\u52a1\u5668" + }, + "user": { + "title": "Plex \u5a92\u4f53\u670d\u52a1\u5668" + }, + "user_advanced": { + "data": { + "setup_method": "\u8bbe\u7f6e\u65b9\u6cd5" + }, + "title": "Plex \u5a92\u4f53\u670d\u52a1\u5668" } } }, @@ -14,8 +48,10 @@ "plex_mp_settings": { "data": { "ignore_new_shared_users": "\u5ffd\u7565\u65b0\u589e\u7ba1\u7406/\u5171\u4eab\u4f7f\u7528\u8005", + "ignore_plex_web_clients": "\u5ffd\u7565 Plex Web \u5ba2\u6237\u7aef", "monitored_users": "\u53d7\u76d1\u89c6\u7684\u7528\u6237" - } + }, + "description": "Plex \u5a92\u4f53\u64ad\u653e\u5668\u9009\u9879" } } } diff --git a/homeassistant/components/prosegur/translations/es.json b/homeassistant/components/prosegur/translations/es.json new file mode 100644 index 00000000000..fbccb2f6391 --- /dev/null +++ b/homeassistant/components/prosegur/translations/es.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "El sistema ya est\u00e1 configurado", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n err\u00f3nea", + "unknown": "Error desconocido" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Vuelva a autenticarse con su cuenta Prosegur.", + "password": "Clave", + "username": "Nombre de Usuario" + } + }, + "user": { + "data": { + "country": "Pa\u00eds", + "password": "Clave", + "username": "Nombre de Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/pt.json b/homeassistant/components/prosegur/translations/pt.json new file mode 100644 index 00000000000..d479d880d7f --- /dev/null +++ b/homeassistant/components/prosegur/translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "reauth_successful": "Reautentica\u00e7\u00e3o bem sucedida" + }, + "error": { + "cannot_connect": "Falha na liga\u00e7\u00e3o" + }, + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/es.json b/homeassistant/components/renault/translations/es.json new file mode 100644 index 00000000000..894226d361e --- /dev/null +++ b/homeassistant/components/renault/translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada" + }, + "error": { + "invalid_credentials": "Autenticaci\u00f3n err\u00f3nea" + }, + "step": { + "user": { + "data": { + "locale": "Configuraci\u00f3n regional", + "password": "Clave", + "username": "Correo-e" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/zh-Hans.json b/homeassistant/components/renault/translations/zh-Hans.json new file mode 100644 index 00000000000..b081f64a961 --- /dev/null +++ b/homeassistant/components/renault/translations/zh-Hans.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u8d26\u53f7\u5df2\u88ab\u914d\u7f6e", + "kamereon_no_account": "\u65e0\u6cd5\u627e\u5230 Kamereon \u5e10\u6237" + }, + "error": { + "invalid_credentials": "\u65e0\u6548\u8ba4\u8bc1" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon \u8d26\u53f7 ID" + }, + "title": "\u9009\u62e9 Kamereon \u8d26\u53f7 ID" + }, + "user": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7535\u5b50\u90ae\u7bb1" + }, + "title": "\u8bbe\u7f6e Renault \u51ed\u8bc1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/translations/zh-Hans.json b/homeassistant/components/samsungtv/translations/zh-Hans.json new file mode 100644 index 00000000000..da6a5c3c9ba --- /dev/null +++ b/homeassistant/components/samsungtv/translations/zh-Hans.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u5df2\u5728\u8fdb\u884c\u4e2d", + "auth_missing": "Home Assistant \u672a\u88ab\u5141\u8bb8\u8fde\u63a5\u6b64\u4e09\u661f\u7535\u89c6\u3002\u8bf7\u68c0\u67e5\u60a8\u7684\u7535\u89c6\u8bbe\u7f6e\u3002", + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "id_missing": "\u6b64\u4e09\u661f\u8bbe\u5907\u6ca1\u6709\u5e8f\u5217\u53f7\u3002", + "not_supported": "\u6b64\u4e09\u661f\u8bbe\u5907\u76ee\u524d\u6682\u4e0d\u652f\u6301\u3002", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "auth_missing": "Home Assistant \u672a\u88ab\u5141\u8bb8\u8fde\u63a5\u6b64\u4e09\u661f\u7535\u89c6\u3002\u8bf7\u68c0\u67e5\u60a8\u7684\u7535\u89c6\u8bbe\u7f6e\u3002" + }, + "flow_title": "{device}", + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u914d\u7f6e {device} ?\n\u5982\u679c\u60a8\u4e4b\u524d\u4ece\u672a\u8fde\u63a5\u8fc7 Home Assistant \uff0c\u60a8\u5c06\u4f1a\u5728\u8be5\u7535\u89c6\u4e0a\u770b\u5230\u8bf7\u6c42\u6388\u6743\u7684\u5f39\u7a97\u3002", + "title": "\u4e09\u661f\u7535\u89c6" + }, + "reauth_confirm": { + "description": "\u63d0\u4ea4\u4fe1\u606f\u540e\uff0c\u8bf7\u5728 30 \u79d2\u5185\u5728 {device} \u540c\u610f\u83b7\u53d6\u76f8\u5173\u6388\u6743\u3002" + }, + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u7528\u6237\u540d" + }, + "description": "\u8f93\u5165\u60a8\u7684\u4e09\u661f\u7535\u89c6\u4fe1\u606f\u3002\u5982\u679c\u60a8\u4e4b\u524d\u4ece\u672a\u8fde\u63a5\u8fc7 Home Assistant \uff0c\u60a8\u5c06\u4f1a\u5728\u8be5\u7535\u89c6\u4e0a\u770b\u5230\u8bf7\u6c42\u6388\u6743\u7684\u5f39\u7a97\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/translations/zh-Hans.json b/homeassistant/components/solaredge/translations/zh-Hans.json index baf8c980cb7..7f5039e9f93 100644 --- a/homeassistant/components/solaredge/translations/zh-Hans.json +++ b/homeassistant/components/solaredge/translations/zh-Hans.json @@ -1,10 +1,22 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "could_not_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 SolarEdge API", + "invalid_api_key": "\u65e0\u6548\u7684 API \u5bc6\u94a5", + "site_not_active": "\u672a\u6fc0\u6d3b" + }, "step": { "user": { "data": { - "api_key": "API \u5bc6\u7801" - } + "api_key": "API \u5bc6\u7801", + "name": "\u5b89\u88c5\u540d\u79f0", + "site_id": "SolarEdge \u7ad9\u70b9 ID" + }, + "title": "\u5b9a\u4e49\u672c\u6b21\u5b89\u88c5\u7684 API \u53c2\u6570" } } } diff --git a/homeassistant/components/spotify/translations/zh-Hans.json b/homeassistant/components/spotify/translations/zh-Hans.json index 19a6909de48..fdda1685cf1 100644 --- a/homeassistant/components/spotify/translations/zh-Hans.json +++ b/homeassistant/components/spotify/translations/zh-Hans.json @@ -1,4 +1,23 @@ { + "config": { + "abort": { + "authorize_url_timeout": "\u751f\u6210\u6388\u6743\u7f51\u5740\u8d85\u65f6\u3002", + "missing_configuration": "Spotify \u96c6\u6210\u672a\u914d\u7f6e \u3002\u8bf7\u9075\u5faa\u6587\u6863\u914d\u7f6e\u3002", + "no_url_available": "\u65e0 URL \u53ef\u7528\uff0c\u66f4\u591a\u4fe1\u606f\u8bf7[check the help section]({docs_url})", + "reauth_account_mismatch": "\u5df2\u9a8c\u8bc1\u7684 Spotify \u5e10\u6237\u4e0e\u9700\u8981\u91cd\u65b0\u9a8c\u8bc1\u7684\u5e10\u6237\u4e0d\u5339\u914d\u3002" + }, + "create_entry": { + "default": "\u5df2\u6210\u529f\u901a\u8fc7 Spotify \u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1\u3002" + }, + "step": { + "pick_implementation": { + "title": "\u9009\u62e9\u9a8c\u8bc1\u65b9\u5f0f" + }, + "reauth_confirm": { + "description": "Spotify \u96c6\u6210\u9700\u8981\u91cd\u65b0\u9a8c\u8bc1\u5e10\u6237\uff1a {account}" + } + } + }, "system_health": { "info": { "api_endpoint_reachable": "\u53ef\u8bbf\u95ee Spotify API" diff --git a/homeassistant/components/syncthing/translations/zh-Hans.json b/homeassistant/components/syncthing/translations/zh-Hans.json new file mode 100644 index 00000000000..87d3db5c83f --- /dev/null +++ b/homeassistant/components/syncthing/translations/zh-Hans.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u8ba4\u8bc1\u65e0\u6548" + }, + "step": { + "user": { + "data": { + "title": "\u8bbe\u7f6e Syncthing \u96c6\u6210", + "token": "\u4ee4\u724c", + "url": "\u8fde\u63a5\u5730\u5740", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66" + } + } + } + }, + "title": "Syncthing" +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/zh-Hans.json b/homeassistant/components/syncthru/translations/zh-Hans.json new file mode 100644 index 00000000000..c50e250aee9 --- /dev/null +++ b/homeassistant/components/syncthru/translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown_state": "\u6253\u5370\u673a\u72b6\u6001\u672a\u77e5\uff0c\u8bf7\u9a8c\u8bc1 URL \u548c\u7f51\u7edc\u662f\u5426\u8fde\u63a5\u6b63\u5e38" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/zh-Hans.json b/homeassistant/components/synology_dsm/translations/zh-Hans.json index b4edf8039a6..862f526c38d 100644 --- a/homeassistant/components/synology_dsm/translations/zh-Hans.json +++ b/homeassistant/components/synology_dsm/translations/zh-Hans.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86" + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", + "reauth_successful": "\u91cd\u9a8c\u8bc1" }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", @@ -28,13 +29,20 @@ "description": "\u60a8\u60f3\u8981\u914d\u7f6e {name} ({host}) \u5417\uff1f", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + } + }, "user": { "data": { "host": "\u4e3b\u673a", "password": "\u5bc6\u7801", "port": "\u7aef\u53e3", "ssl": "\u4f7f\u7528 SSL \u8bc1\u4e66", - "username": "\u7528\u6237\u540d" + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66" }, "title": "Synology DSM" } diff --git a/homeassistant/components/tractive/translations/es.json b/homeassistant/components/tractive/translations/es.json new file mode 100644 index 00000000000..11aa4f1aa9c --- /dev/null +++ b/homeassistant/components/tractive/translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El sistema ya est\u00e1 configurado" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n err\u00f3nea", + "unknown": "Error desconocido" + }, + "step": { + "user": { + "data": { + "email": "Correo-e", + "password": "Clave" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/he.json b/homeassistant/components/tractive/translations/he.json new file mode 100644 index 00000000000..1cccac175a0 --- /dev/null +++ b/homeassistant/components/tractive/translations/he.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "email": "\u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/pt.json b/homeassistant/components/tractive/translations/pt.json new file mode 100644 index 00000000000..7430480cc09 --- /dev/null +++ b/homeassistant/components/tractive/translations/pt.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Palavra-passe" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/zh-Hans.json b/homeassistant/components/tractive/translations/zh-Hans.json new file mode 100644 index 00000000000..5d8e6c66984 --- /dev/null +++ b/homeassistant/components/tractive/translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u5b58\u5728\u914d\u7f6e\u6587\u6863" + }, + "error": { + "invalid_auth": "\u8ba4\u8bc1\u65e0\u6548", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "email": "\u7535\u5b50\u90ae\u7bb1", + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/transmission/translations/zh-Hans.json b/homeassistant/components/transmission/translations/zh-Hans.json index d217ccdc842..a056b99a4bb 100644 --- a/homeassistant/components/transmission/translations/zh-Hans.json +++ b/homeassistant/components/transmission/translations/zh-Hans.json @@ -1,10 +1,34 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548", + "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728" + }, "step": { "user": { "data": { - "password": "\u5bc6\u7801" - } + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d" + }, + "title": "\u914d\u7f6e Transmission \u5ba2\u6237\u7aef" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "limit": "\u9650\u5236", + "scan_interval": "\u66f4\u65b0\u9891\u7387" + }, + "title": "Transmission \u914d\u7f6e\u9009\u9879" } } } diff --git a/homeassistant/components/uptimerobot/translations/es.json b/homeassistant/components/uptimerobot/translations/es.json new file mode 100644 index 00000000000..1f88050745d --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", + "unknown": "Error desconocido" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_api_key": "Clave de la API err\u00f3nea", + "unknown": "Error desconocido" + }, + "step": { + "user": { + "data": { + "api_key": "Clave de la API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/he.json b/homeassistant/components/uptimerobot/translations/he.json index 5b6fc485e04..1a45e5c78cd 100644 --- a/homeassistant/components/uptimerobot/translations/he.json +++ b/homeassistant/components/uptimerobot/translations/he.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { diff --git a/homeassistant/components/uptimerobot/translations/pt.json b/homeassistant/components/uptimerobot/translations/pt.json new file mode 100644 index 00000000000..10c16aafa0f --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/pt.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Erro inesperado" + }, + "error": { + "invalid_api_key": "Chave de API inv\u00e1lida" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/zh-Hans.json b/homeassistant/components/uptimerobot/translations/zh-Hans.json new file mode 100644 index 00000000000..92106b06ce2 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/zh-Hans.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u8d26\u53f7\u5df2\u88ab\u914d\u7f6e", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_api_key": "\u65e0\u6548\u7684 API \u5bc6\u94a5", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u94a5" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/translations/zh-Hans.json b/homeassistant/components/vizio/translations/zh-Hans.json new file mode 100644 index 00000000000..1fa1ebc751d --- /dev/null +++ b/homeassistant/components/vizio/translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "pair_tv": { + "title": "\u5b8c\u6210\u914d\u5bf9\u8fc7\u7a0b" + }, + "pairing_complete": { + "title": "\u914d\u5bf9\u5b8c\u6210" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.es.json b/homeassistant/components/xiaomi_miio/translations/select.es.json new file mode 100644 index 00000000000..3906ef91342 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.es.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Brillo", + "dim": "Atenuar", + "off": "Apagado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.zh-Hans.json b/homeassistant/components/xiaomi_miio/translations/select.zh-Hans.json new file mode 100644 index 00000000000..bad6ba91597 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.zh-Hans.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "\u4eae", + "dim": "\u6697", + "off": "\u5173" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/es.json b/homeassistant/components/yale_smart_alarm/translations/es.json new file mode 100644 index 00000000000..b970badb079 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/es.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n err\u00f3nea" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID de \u00c1rea", + "name": "Nombre", + "password": "Clave", + "username": "Nombre de usuario" + } + }, + "user": { + "data": { + "area_id": "ID de \u00e1rea", + "name": "Nombre", + "password": "Clave", + "username": "Nombre de usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/es.json b/homeassistant/components/youless/translations/es.json new file mode 100644 index 00000000000..72a56cc5608 --- /dev/null +++ b/homeassistant/components/youless/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar" + }, + "step": { + "user": { + "data": { + "host": "Anfitri\u00f3n", + "name": "Nombre" + } + } + } + } +} \ No newline at end of file From b9e0de2eed63848d1c6370efeb8bab0765e62515 Mon Sep 17 00:00:00 2001 From: Trinnik Date: Sat, 7 Aug 2021 21:51:05 -0600 Subject: [PATCH 208/903] Fix update entity prior to adding (#54015) --- homeassistant/components/aladdin_connect/cover.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 85f89f3043b..14e2b2f0ce2 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -49,7 +49,10 @@ def setup_platform( try: if not acc.login(): raise ValueError("Username or Password is incorrect") - add_entities(AladdinDevice(acc, door) for door in acc.get_doors()) + add_entities( + (AladdinDevice(acc, door) for door in acc.get_doors()), + update_before_add = True + ) except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) hass.components.persistent_notification.create( From 22acaa8e63aa09dd5403d6f6d2bc17ba1b17d453 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 7 Aug 2021 21:00:37 -0700 Subject: [PATCH 209/903] Pin google-cloud-pubsub to an older version (#54239) Pin google-cloud-pubsub to an older version, since newer versions have a pin that is incompatible with the existing grpcio pin already in package_constraints.txt --- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 617a057743b..6d22aa51b24 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -51,6 +51,11 @@ httplib2>=0.19.0 # https://github.com/home-assistant/core/issues/40148 grpcio==1.31.0 +# Newer versions of cloud pubsub pin a higher version of grpcio. This can +# be reverted when the grpcio pin is reverted, see: +# https://github.com/home-assistant/core/issues/53427 +google-cloud-pubsub==2.1.0 + # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7dcc4f71fe8..934ea9be90c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -73,6 +73,11 @@ httplib2>=0.19.0 # https://github.com/home-assistant/core/issues/40148 grpcio==1.31.0 +# Newer versions of cloud pubsub pin a higher version of grpcio. This can +# be reverted when the grpcio pin is reverted, see: +# https://github.com/home-assistant/core/issues/53427 +google-cloud-pubsub==2.1.0 + # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 From d3007c26b31fab0387452371b700d49db640ec89 Mon Sep 17 00:00:00 2001 From: carstenschroeder Date: Sun, 8 Aug 2021 06:03:20 +0200 Subject: [PATCH 210/903] Bugfix: Bring back unique IDs for ADS covers after #52488 (#54212) --- homeassistant/components/ads/cover.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index 0cd0264cb50..976bfd58fed 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -91,13 +91,13 @@ class AdsCover(AdsEntity, CoverEntity): ): """Initialize AdsCover entity.""" super().__init__(ads_hub, name, ads_var_is_closed) - if self._ads_var is None: + if self._attr_unique_id is None: if ads_var_position is not None: - self._unique_id = ads_var_position + self._attr_unique_id = ads_var_position elif ads_var_pos_set is not None: - self._unique_id = ads_var_pos_set + self._attr_unique_id = ads_var_pos_set elif ads_var_open is not None: - self._unique_id = ads_var_open + self._attr_unique_id = ads_var_open self._state_dict[STATE_KEY_POSITION] = None self._ads_var_position = ads_var_position From 2232915ea830071f9039b221f286ded7fc60096a Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 8 Aug 2021 06:10:08 +0200 Subject: [PATCH 211/903] Add parameter to delay sending of requests in modbus (#54203) --- homeassistant/components/modbus/__init__.py | 2 ++ homeassistant/components/modbus/const.py | 1 + homeassistant/components/modbus/modbus.py | 11 +++++++++-- tests/components/modbus/test_init.py | 2 ++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 16be39230db..43aa49e6da7 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -66,6 +66,7 @@ from .const import ( CONF_INPUT_TYPE, CONF_MAX_TEMP, CONF_MIN_TEMP, + CONF_MSG_WAIT, CONF_PARITY, CONF_PRECISION, CONF_RETRIES, @@ -283,6 +284,7 @@ MODBUS_SCHEMA = vol.Schema( vol.Optional(CONF_DELAY, default=0): cv.positive_int, vol.Optional(CONF_RETRIES, default=3): cv.positive_int, vol.Optional(CONF_RETRY_ON_EMPTY, default=False): cv.boolean, + vol.Optional(CONF_MSG_WAIT): cv.positive_int, vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [BINARY_SENSOR_SCHEMA] ), diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 10eb07f801e..ef6d7c3fc32 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -30,6 +30,7 @@ CONF_INPUTS = "inputs" CONF_INPUT_TYPE = "input_type" CONF_MAX_TEMP = "max_temp" CONF_MIN_TEMP = "min_temp" +CONF_MSG_WAIT = "message_wait_milliseconds" CONF_PARITY = "parity" CONF_REGISTER = "register" CONF_REGISTER_TYPE = "register_type" diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 77d8b669c24..dad91f26a12 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -39,6 +39,7 @@ from .const import ( CONF_BAUDRATE, CONF_BYTESIZE, CONF_CLOSE_COMM_ON_ERROR, + CONF_MSG_WAIT, CONF_PARITY, CONF_RETRIES, CONF_RETRY_ON_EMPTY, @@ -227,6 +228,12 @@ class ModbusHub: self._pb_params["framer"] = ModbusRtuFramer Defaults.Timeout = client_config[CONF_TIMEOUT] + if CONF_MSG_WAIT in client_config: + self._msg_wait = client_config[CONF_MSG_WAIT] / 1000 + elif self._config_type == CONF_SERIAL: + self._msg_wait = 30 / 1000 + else: + self._msg_wait = 0 def _log_error(self, text: str, error_state=True): log_text = f"Pymodbus: {text}" @@ -322,7 +329,7 @@ class ModbusHub: result = await self.hass.async_add_executor_job( self._pymodbus_call, unit, address, value, use_call ) - if self._config_type == "serial": + if self._msg_wait: # small delay until next request/response - await asyncio.sleep(30 / 1000) + await asyncio.sleep(self._msg_wait) return result diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 8b8d063bf02..b9f6420604f 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,6 +40,7 @@ from homeassistant.components.modbus.const import ( CONF_BYTESIZE, CONF_DATA_TYPE, CONF_INPUT_TYPE, + CONF_MSG_WAIT, CONF_PARITY, CONF_STOPBITS, CONF_SWAP, @@ -245,6 +246,7 @@ async def test_exception_struct_validator(do_config): CONF_PORT: "usb01", CONF_PARITY: "E", CONF_STOPBITS: 1, + CONF_MSG_WAIT: 100, }, { CONF_TYPE: "serial", From a001fd5000e59f6fda8885600d034c0fe3c3c3cf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 7 Aug 2021 21:10:21 -0700 Subject: [PATCH 212/903] Fix formatting (#54247) --- homeassistant/components/aladdin_connect/cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 14e2b2f0ce2..5cebe3622dc 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -51,7 +51,7 @@ def setup_platform( raise ValueError("Username or Password is incorrect") add_entities( (AladdinDevice(acc, door) for door in acc.get_doors()), - update_before_add = True + update_before_add=True, ) except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) From 8a4674c086d3434833db55bf9a9907ff778efd68 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 8 Aug 2021 06:11:56 +0200 Subject: [PATCH 213/903] Solve missing automatic update of struct configuration in modbus (#54193) --- homeassistant/components/modbus/validators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 3efb61f8027..b59557e58d2 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -86,6 +86,7 @@ def struct_validator(config): _LOGGER.warning(error) try: data_type = OLD_DATA_TYPES[data_type][config.get(CONF_COUNT, 1)] + config[CONF_DATA_TYPE] = data_type except KeyError as exp: error = f"{name} cannot convert automatically {data_type}" raise vol.Invalid(error) from exp From 53c64e5148a47b14cfb4cde4d852a950118037b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 8 Aug 2021 06:12:55 +0200 Subject: [PATCH 214/903] Handle added and removed monitors (#54228) --- .../components/uptimerobot/__init__.py | 82 +++++++++++++++---- 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 17bc8f9a629..07782fda533 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceRegistry, async_get_registry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER, PLATFORMS @@ -18,25 +19,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: uptime_robot_api = UptimeRobot( entry.data[CONF_API_KEY], async_get_clientsession(hass) ) + dev_reg = await async_get_registry(hass) - async def async_update_data() -> list[UptimeRobotMonitor]: - """Fetch data from API UptimeRobot API.""" - try: - response = await uptime_robot_api.async_get_monitors() - except UptimeRobotException as exception: - raise UpdateFailed(exception) from exception - else: - if response.status == API_ATTR_OK: - monitors: list[UptimeRobotMonitor] = response.data - return monitors - raise UpdateFailed(response.error.message) - - hass.data[DOMAIN][entry.entry_id] = coordinator = DataUpdateCoordinator( + hass.data[DOMAIN][entry.entry_id] = coordinator = UptimeRobotDataUpdateCoordinator( hass, - LOGGER, - name=DOMAIN, - update_method=async_update_data, - update_interval=COORDINATOR_UPDATE_INTERVAL, + config_entry_id=entry.entry_id, + dev_reg=dev_reg, + api=uptime_robot_api, ) await coordinator.async_config_entry_first_refresh() @@ -53,3 +42,62 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for Uptime Robot.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry_id: str, + dev_reg: DeviceRegistry, + api: UptimeRobot, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_method=self._async_update_data, + update_interval=COORDINATOR_UPDATE_INTERVAL, + ) + self._config_entry_id = config_entry_id + self._device_registry = dev_reg + self._api = api + + async def _async_update_data(self) -> list[UptimeRobotMonitor] | None: + """Update data.""" + try: + response = await self._api.async_get_monitors() + except UptimeRobotException as exception: + raise UpdateFailed(exception) from exception + else: + if response.status != API_ATTR_OK: + raise UpdateFailed(response.error.message) + + monitors: list[UptimeRobotMonitor] = response.data + + current_monitors = { + list(device.identifiers)[0][1] + for device in self._device_registry.devices.values() + if self._config_entry_id in device.config_entries + and list(device.identifiers)[0][0] == DOMAIN + } + new_monitors = {str(monitor.id) for monitor in monitors} + if stale_monitors := current_monitors - new_monitors: + for monitor_id in stale_monitors: + if device := self._device_registry.async_get_device( + {(DOMAIN, monitor_id)} + ): + self._device_registry.async_remove_device(device.id) + + # If there are new monitors, we should reload the config entry so we can + # 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) + ) + return None + + return monitors From 11f15f66afbf8968c3020a53f11926773c374968 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 8 Aug 2021 05:20:55 +0100 Subject: [PATCH 215/903] OVO Energy Long-term Statistics (#54157) Co-authored-by: Paulus Schoutsen --- homeassistant/components/ovo_energy/sensor.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 7615a7011d3..f678caf02b0 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -1,4 +1,6 @@ """Support for OVO Energy sensors.""" +from __future__ import annotations + from datetime import timedelta from ovoenergy import OVODailyUsage @@ -6,8 +8,10 @@ from ovoenergy.ovoenergy import OVOEnergy from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_CLASS_ENERGY, DEVICE_CLASS_MONETARY from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.dt import utc_from_timestamp from . import OVOEnergyDeviceEntity from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN @@ -57,6 +61,9 @@ async def async_setup_entry( class OVOEnergySensor(OVOEnergyDeviceEntity, SensorEntity): """Defines a OVO Energy sensor.""" + _attr_last_reset = utc_from_timestamp(0) + _attr_state_class = "measurement" + def __init__( self, coordinator: DataUpdateCoordinator, @@ -64,15 +71,17 @@ class OVOEnergySensor(OVOEnergyDeviceEntity, SensorEntity): key: str, name: str, icon: str, - unit_of_measurement: str = "", + device_class: str | None, + unit_of_measurement: str | None, ) -> None: """Initialize OVO Energy sensor.""" + self._attr_device_class = device_class self._unit_of_measurement = unit_of_measurement super().__init__(coordinator, client, key, name, icon) @property - def unit_of_measurement(self) -> str: + def unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return self._unit_of_measurement @@ -89,6 +98,7 @@ class OVOEnergyLastElectricityReading(OVOEnergySensor): f"{client.account_id}_last_electricity_reading", "OVO Last Electricity Reading", "mdi:flash", + DEVICE_CLASS_ENERGY, "kWh", ) @@ -124,6 +134,7 @@ class OVOEnergyLastGasReading(OVOEnergySensor): f"{DOMAIN}_{client.account_id}_last_gas_reading", "OVO Last Gas Reading", "mdi:gas-cylinder", + DEVICE_CLASS_ENERGY, "kWh", ) @@ -160,6 +171,7 @@ class OVOEnergyLastElectricityCost(OVOEnergySensor): f"{DOMAIN}_{client.account_id}_last_electricity_cost", "OVO Last Electricity Cost", "mdi:cash-multiple", + DEVICE_CLASS_MONETARY, currency, ) @@ -196,6 +208,7 @@ class OVOEnergyLastGasCost(OVOEnergySensor): f"{DOMAIN}_{client.account_id}_last_gas_cost", "OVO Last Gas Cost", "mdi:cash-multiple", + DEVICE_CLASS_MONETARY, currency, ) From 75726a26954cf2527c9fa28b9de2200232d255d5 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 7 Aug 2021 21:29:52 -0700 Subject: [PATCH 216/903] Don't block motionEye setup on NoURLAvailableError (#54225) Co-authored-by: Paulus Schoutsen --- .../components/motioneye/__init__.py | 68 +++++++++++-------- tests/components/motioneye/test_web_hooks.py | 31 +++++++++ 2 files changed, 71 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 2ade7c48e1b..acafdceeb05 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -53,7 +53,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo, EntityDescription -from homeassistant.helpers.network import get_url +from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -145,12 +145,21 @@ def listen_for_new_cameras( @callback -def async_generate_motioneye_webhook(hass: HomeAssistant, webhook_id: str) -> str: +def async_generate_motioneye_webhook( + hass: HomeAssistant, webhook_id: str +) -> str | None: """Generate the full local URL for a webhook_id.""" - return "{}{}".format( - get_url(hass, allow_cloud=False), - async_generate_path(webhook_id), - ) + try: + return "{}{}".format( + get_url(hass, allow_cloud=False), + async_generate_path(webhook_id), + ) + except NoURLAvailableError: + _LOGGER.warning( + "Unable to get Home Assistant URL. Have you set the internal and/or " + "external URLs in Configuration -> General?" + ) + return None @callback @@ -228,28 +237,31 @@ def _add_camera( if entry.options.get(CONF_WEBHOOK_SET, DEFAULT_WEBHOOK_SET): url = async_generate_motioneye_webhook(hass, entry.data[CONF_WEBHOOK_ID]) - if _set_webhook( - _build_url( - device, - url, - EVENT_MOTION_DETECTED, - EVENT_MOTION_DETECTED_KEYS, - ), - KEY_WEB_HOOK_NOTIFICATIONS_URL, - KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD, - KEY_WEB_HOOK_NOTIFICATIONS_ENABLED, - camera, - ) | _set_webhook( - _build_url( - device, - url, - EVENT_FILE_STORED, - EVENT_FILE_STORED_KEYS, - ), - KEY_WEB_HOOK_STORAGE_URL, - KEY_WEB_HOOK_STORAGE_HTTP_METHOD, - KEY_WEB_HOOK_STORAGE_ENABLED, - camera, + if url and ( + _set_webhook( + _build_url( + device, + url, + EVENT_MOTION_DETECTED, + EVENT_MOTION_DETECTED_KEYS, + ), + KEY_WEB_HOOK_NOTIFICATIONS_URL, + KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD, + KEY_WEB_HOOK_NOTIFICATIONS_ENABLED, + camera, + ) + | _set_webhook( + _build_url( + device, + url, + EVENT_FILE_STORED, + EVENT_FILE_STORED_KEYS, + ), + KEY_WEB_HOOK_STORAGE_URL, + KEY_WEB_HOOK_STORAGE_HTTP_METHOD, + KEY_WEB_HOOK_STORAGE_ENABLED, + camera, + ) ): hass.async_create_task(client.async_set_camera(camera_id, camera)) diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index 03b4e8bc46a..f20ef5101e9 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -32,11 +32,13 @@ from homeassistant.const import ( ) 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 from . import ( TEST_CAMERA, TEST_CAMERA_DEVICE_IDENTIFIER, + TEST_CAMERA_ENTITY_ID, TEST_CAMERA_ID, TEST_CAMERA_NAME, TEST_CAMERAS, @@ -251,6 +253,35 @@ async def test_setup_camera_with_correct_webhook( assert not client.async_set_camera.called +async def test_setup_camera_with_no_home_assistant_urls( + hass: HomeAssistant, + caplog: Any, +) -> None: + """Verify setup works without Home Assistant internal/external URLs.""" + + client = create_mock_motioneye_client() + config_entry = create_mock_motioneye_config_entry(hass, data={CONF_URL: TEST_URL}) + + with patch( + "homeassistant.components.motioneye.get_url", side_effect=NoURLAvailableError + ): + await setup_mock_motioneye_config_entry( + hass, + config_entry=config_entry, + client=client, + ) + + # Should log a warning ... + assert "Unable to get Home Assistant URL" in caplog.text + + # ... should not set callbacks in the camera ... + assert not client.async_set_camera.called + + # ... but camera should still be present. + entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID) + assert entity_state + + async def test_good_query(hass: HomeAssistant, aiohttp_client: Any) -> None: """Test good callbacks.""" await async_setup_component(hass, "http", {"http": {}}) From 7d29eb282bf1670692d6bc4d38d76dec22029279 Mon Sep 17 00:00:00 2001 From: rjulius23 Date: Sun, 8 Aug 2021 07:02:20 +0200 Subject: [PATCH 217/903] Add enumerate to builtins in python_script component (#54244) --- homeassistant/components/python_script/__init__.py | 1 + tests/components/python_script/test_init.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 89a7ab4ba04..922f5b71a3c 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -195,6 +195,7 @@ def execute(hass, filename, source, data=None): "sum": sum, "any": any, "all": all, + "enumerate": enumerate, } builtins = safe_builtins.copy() builtins.update(utility_builtins) diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 142d833698d..1e1f24b6eee 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -179,6 +179,20 @@ for i in [1, 2]: assert hass.states.is_state("hello.2", "world") +async def test_using_enumerate(hass): + """Test that enumerate is accepted and executed.""" + source = """ +for index, value in enumerate(["earth", "mars"]): + hass.states.set('hello.{}'.format(index), value) + """ + + hass.async_add_job(execute, hass, "test.py", source, {}) + await hass.async_block_till_done() + + assert hass.states.is_state("hello.0", "earth") + assert hass.states.is_state("hello.1", "mars") + + async def test_unpacking_sequence(hass, caplog): """Test compile error logs error.""" caplog.set_level(logging.ERROR) From fc40735295740f5b43e2e686cd2664e4ee17dc80 Mon Sep 17 00:00:00 2001 From: Schmidsfeld <68500293+Schmidsfeld@users.noreply.github.com> Date: Sun, 8 Aug 2021 11:23:28 +0200 Subject: [PATCH 218/903] Add more Fritz sensors for DSL connections (#53198) * Update sensor.py Added information about the upstream line accorrding to fritzconnection library (available since V1.5.0) . New information available are line sync speed,, noise margin and power attenuation. Tested with ADSL and VDSL lines on fritzbox 7590, 7490 and 7390. Not tested on cable internet / fiber. According to upstrem library should also work / fail gracefully. * Update sensor.py Fixed errors from automated tests Sorry it took so long * Update homeassistant/components/fritz/sensor.py Thank you this sounds even better Co-authored-by: Simone Chemelli * Update homeassistant/components/fritz/sensor.py Co-authored-by: Simone Chemelli * Update homeassistant/components/fritz/sensor.py Co-authored-by: Simone Chemelli * Update homeassistant/components/fritz/sensor.py Co-authored-by: Simone Chemelli * Update homeassistant/components/fritz/sensor.py Co-authored-by: Simone Chemelli * Update homeassistant/components/fritz/sensor.py Co-authored-by: Simone Chemelli * Update homeassistant/components/fritz/sensor.py Co-authored-by: Simone Chemelli * Update homeassistant/components/fritz/sensor.py Co-authored-by: Simone Chemelli * Update homeassistant/components/fritz/sensor.py Co-authored-by: Simone Chemelli * black & mypy fixes * Rebase, fix multiplier, add conditional create Co-authored-by: Simone Chemelli --- homeassistant/components/fritz/const.py | 2 + homeassistant/components/fritz/sensor.py | 103 +++++++++++++++++++++-- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 8b3f9106602..4ae8314113f 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -6,6 +6,8 @@ PLATFORMS = ["binary_sensor", "device_tracker", "sensor", "switch"] DATA_FRITZ = "fritz_data" +DSL_CONNECTION = "dsl" + DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.178.1" DEFAULT_PORT = 49000 diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index d7a34564b43..c7d3fc243a5 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -15,13 +15,14 @@ from homeassistant.const import ( DATA_RATE_KILOBITS_PER_SECOND, DATA_RATE_KILOBYTES_PER_SECOND, DEVICE_CLASS_TIMESTAMP, + SIGNAL_STRENGTH_DECIBELS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow from .common import FritzBoxBaseEntity, FritzBoxTools -from .const import DOMAIN, UPTIME_DEVIATION +from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION _LOGGER = logging.getLogger(__name__) @@ -89,6 +90,44 @@ def _retrieve_gb_received_state(status: FritzStatus, last_value: str) -> float: return round(status.bytes_received / 1000 / 1000 / 1000, 1) # type: ignore[no-any-return] +def _retrieve_link_kb_s_sent_state(status: FritzStatus, last_value: str) -> float: + """Return upload link rate.""" + return round(status.max_linked_bit_rate[0] / 1000, 1) # type: ignore[no-any-return] + + +def _retrieve_link_kb_s_received_state(status: FritzStatus, last_value: str) -> float: + """Return download link rate.""" + return round(status.max_linked_bit_rate[1] / 1000, 1) # type: ignore[no-any-return] + + +def _retrieve_link_noise_margin_sent_state( + status: FritzStatus, last_value: str +) -> float: + """Return upload noise margin.""" + return status.noise_margin[0] # type: ignore[no-any-return] + + +def _retrieve_link_noise_margin_received_state( + status: FritzStatus, last_value: str +) -> float: + """Return download noise margin.""" + return status.noise_margin[1] # type: ignore[no-any-return] + + +def _retrieve_link_attenuation_sent_state( + status: FritzStatus, last_value: str +) -> float: + """Return upload line attenuation.""" + return status.attenuation[0] # type: ignore[no-any-return] + + +def _retrieve_link_attenuation_received_state( + status: FritzStatus, last_value: str +) -> float: + """Return download line attenuation.""" + return status.attenuation[1] # type: ignore[no-any-return] + + class SensorData(TypedDict, total=False): """Sensor data class.""" @@ -99,6 +138,7 @@ class SensorData(TypedDict, total=False): unit_of_measurement: str | None icon: str | None state_provider: Callable + connection_type: str | None SENSOR_DATA = { @@ -118,27 +158,27 @@ SENSOR_DATA = { state_provider=_retrieve_connection_uptime_state, ), "kb_s_sent": SensorData( - name="kB/s sent", + name="Upload Throughput", state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, icon="mdi:upload", state_provider=_retrieve_kb_s_sent_state, ), "kb_s_received": SensorData( - name="kB/s received", + name="Download Throughput", state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, icon="mdi:download", state_provider=_retrieve_kb_s_received_state, ), "max_kb_s_sent": SensorData( - name="Max kbit/s sent", + name="Max Connection Upload Throughput", unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:upload", state_provider=_retrieve_max_kb_s_sent_state, ), "max_kb_s_received": SensorData( - name="Max kbit/s received", + name="Max Connection Download Throughput", unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:download", state_provider=_retrieve_max_kb_s_received_state, @@ -159,6 +199,48 @@ SENSOR_DATA = { icon="mdi:download", state_provider=_retrieve_gb_received_state, ), + "link_kb_s_sent": SensorData( + name="Link Upload Throughput", + unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + icon="mdi:upload", + state_provider=_retrieve_link_kb_s_sent_state, + connection_type=DSL_CONNECTION, + ), + "link_kb_s_received": SensorData( + name="Link Download Throughput", + unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + icon="mdi:download", + state_provider=_retrieve_link_kb_s_received_state, + connection_type=DSL_CONNECTION, + ), + "link_noise_margin_sent": SensorData( + name="Link Upload Noise Margin", + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:upload", + state_provider=_retrieve_link_noise_margin_sent_state, + connection_type=DSL_CONNECTION, + ), + "link_noise_margin_received": SensorData( + name="Link Download Noise Margin", + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:download", + state_provider=_retrieve_link_noise_margin_received_state, + connection_type=DSL_CONNECTION, + ), + "link_attenuation_sent": SensorData( + name="Link Upload Power Attenuation", + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:upload", + state_provider=_retrieve_link_attenuation_sent_state, + connection_type=DSL_CONNECTION, + ), + "link_attenuation_received": SensorData( + name="Link Download Power Attenuation", + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:download", + state_provider=_retrieve_link_attenuation_received_state, + connection_type=DSL_CONNECTION, + ), } @@ -177,7 +259,16 @@ async def async_setup_entry( return entities = [] - for sensor_type in SENSOR_DATA: + dslinterface = await hass.async_add_executor_job( + fritzbox_tools.connection.call_action, + "WANDSLInterfaceConfig:1", + "GetInfo", + ) + dsl: bool = dslinterface["NewEnable"] + + for sensor_type, sensor_data in SENSOR_DATA.items(): + if not dsl and sensor_data.get("connection_type") == DSL_CONNECTION: + continue entities.append(FritzBoxSensor(fritzbox_tools, entry.title, sensor_type)) if entities: From a4fd718e41e53a3ad8be7eba4b98e6330eb579c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 8 Aug 2021 11:29:32 +0200 Subject: [PATCH 219/903] Fix device registry lookup in uptimerobot (#54256) --- homeassistant/components/uptimerobot/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 07782fda533..4e6ff7908ee 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -7,7 +7,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceRegistry, async_get_registry +from homeassistant.helpers.device_registry import ( + DeviceRegistry, + async_entries_for_config_entry, + async_get_registry, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER, PLATFORMS @@ -80,9 +84,9 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator): current_monitors = { list(device.identifiers)[0][1] - for device in self._device_registry.devices.values() - if self._config_entry_id in device.config_entries - and list(device.identifiers)[0][0] == DOMAIN + for device in async_entries_for_config_entry( + self._device_registry, self._config_entry_id + ) } new_monitors = {str(monitor.id) for monitor in monitors} if stale_monitors := current_monitors - new_monitors: From 3da61b77a90d3ee9042d1ed240768cde9f3e1ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 8 Aug 2021 12:26:14 +0200 Subject: [PATCH 220/903] Remove monitor checks in Uptime Robot entities (#54259) --- .../components/uptimerobot/entity.py | 54 ++++++------------- 1 file changed, 16 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index b265af77535..b9783c88b9c 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from pyuptimerobot import UptimeRobotMonitor from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -25,56 +25,34 @@ class UptimeRobotEntity(CoordinatorEntity): """Initialize Uptime Robot entities.""" super().__init__(coordinator) self.entity_description = description - self._target = target + self._attr_device_info = { + "identifiers": {(DOMAIN, str(self.monitor.id))}, + "name": "Uptime Robot", + "manufacturer": "Uptime Robot Team", + "entry_type": "service", + "model": self.monitor.type.name, + } self._attr_extra_state_attributes = { ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_TARGET: self._target, + ATTR_TARGET: target, } + self._attr_unique_id = str(self.monitor.id) @property - def unique_id(self) -> str | None: - """Return the unique_id of the entity.""" - return str(self.monitor.id) if self.monitor else None - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this AdGuard Home instance.""" - if self.monitor: - return { - "identifiers": {(DOMAIN, str(self.monitor.id))}, - "name": "Uptime Robot", - "manufacturer": "Uptime Robot Team", - "entry_type": "service", - "model": self.monitor.type.name, - } - return {} - - @property - def monitors(self) -> list[UptimeRobotMonitor]: + def _monitors(self) -> list[UptimeRobotMonitor]: """Return all monitors.""" return self.coordinator.data or [] @property - def monitor(self) -> UptimeRobotMonitor | None: + def monitor(self) -> UptimeRobotMonitor: """Return the monitor for this entity.""" return next( - ( - monitor - for monitor in self.monitors - if str(monitor.id) == self.entity_description.key - ), - None, + monitor + for monitor in self._monitors + if str(monitor.id) == self.entity_description.key ) @property def monitor_available(self) -> bool: """Returtn if the monitor is available.""" - status: bool = self.monitor.status == 2 if self.monitor else False - return status - - @property - def available(self) -> bool: - """Returtn if entity is available.""" - if not self.coordinator.last_update_success: - return False - return self.monitor is not None + return bool(self.monitor.status == 2) From 18a0fcf9311948c082d66fe17eb52e872303e954 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 8 Aug 2021 15:02:37 +0200 Subject: [PATCH 221/903] Strict typing for Neato (#53633) * Strict typing * Rebase * Tweak import * Cleanup * Rebase + typing hub * Flake8 * Update homeassistant/components/neato/config_flow.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/neato/vacuum.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/neato/camera.py Co-authored-by: Martin Hjelmare * Address review comments * Black * Update homeassistant/components/neato/config_flow.py Co-authored-by: Martin Hjelmare * Specific dict definition * Annotations Co-authored-by: Martin Hjelmare --- .strict-typing | 1 + homeassistant/components/neato/api.py | 9 +- homeassistant/components/neato/camera.py | 59 +++++--- homeassistant/components/neato/config_flow.py | 15 +- homeassistant/components/neato/hub.py | 10 +- homeassistant/components/neato/sensor.py | 45 ++++-- homeassistant/components/neato/switch.py | 50 +++--- homeassistant/components/neato/vacuum.py | 142 +++++++++++------- mypy.ini | 14 +- script/hassfest/mypy_config.py | 1 - 10 files changed, 213 insertions(+), 133 deletions(-) diff --git a/.strict-typing b/.strict-typing index 915ac50d6a1..e8c4f83fa80 100644 --- a/.strict-typing +++ b/.strict-typing @@ -63,6 +63,7 @@ homeassistant.components.mailbox.* homeassistant.components.media_player.* homeassistant.components.mysensors.* homeassistant.components.nam.* +homeassistant.components.neato.* homeassistant.components.nest.* homeassistant.components.netatmo.* homeassistant.components.network.* diff --git a/homeassistant/components/neato/api.py b/homeassistant/components/neato/api.py index a22b1b48e74..cd26b009040 100644 --- a/homeassistant/components/neato/api.py +++ b/homeassistant/components/neato/api.py @@ -1,5 +1,8 @@ """API for Neato Botvac bound to Home Assistant OAuth.""" +from __future__ import annotations + from asyncio import run_coroutine_threadsafe +from typing import Any import pybotvac @@ -7,7 +10,7 @@ from homeassistant import config_entries, core from homeassistant.helpers import config_entry_oauth2_flow -class ConfigEntryAuth(pybotvac.OAuthSession): +class ConfigEntryAuth(pybotvac.OAuthSession): # type: ignore[misc] """Provide Neato Botvac authentication tied to an OAuth2 based config entry.""" def __init__( @@ -29,7 +32,7 @@ class ConfigEntryAuth(pybotvac.OAuthSession): self.session.async_ensure_token_valid(), self.hass.loop ).result() - return self.session.token["access_token"] + return self.session.token["access_token"] # type: ignore[no-any-return] class NeatoImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation): @@ -39,7 +42,7 @@ class NeatoImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation): """ @property - def extra_authorize_data(self) -> dict: + def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" return {"client_secret": self.client_secret} diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index 9a2f47bcfa3..b6def2cfe38 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -1,10 +1,20 @@ """Support for loading picture from Neato.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from pybotvac.exceptions import NeatoRobotException +from pybotvac.robot import Robot +from urllib3.response import HTTPResponse from homeassistant.components.camera import Camera +from homeassistant.components.neato import NeatoHub +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( NEATO_DOMAIN, @@ -20,11 +30,13 @@ SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES) ATTR_GENERATED_AT = "generated_at" -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Neato camera with config entry.""" dev = [] - neato = hass.data.get(NEATO_LOGIN) - mapdata = hass.data.get(NEATO_MAP_DATA) + neato: NeatoHub = hass.data[NEATO_LOGIN] + mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA) for robot in hass.data[NEATO_ROBOTS]: if "maps" in robot.traits: dev.append(NeatoCleaningMap(neato, robot, mapdata)) @@ -39,7 +51,9 @@ async def async_setup_entry(hass, entry, async_add_entities): class NeatoCleaningMap(Camera): """Neato cleaning map for last clean.""" - def __init__(self, neato, robot, mapdata): + def __init__( + self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None + ) -> None: """Initialize Neato cleaning map.""" super().__init__() self.robot = robot @@ -47,24 +61,18 @@ class NeatoCleaningMap(Camera): self._mapdata = mapdata self._available = neato is not None self._robot_name = f"{self.robot.name} Cleaning Map" - self._robot_serial = self.robot.serial - self._generated_at = None - self._image_url = None - self._image = None + self._robot_serial: str = self.robot.serial + self._generated_at: str | None = None + self._image_url: str | None = None + self._image: bytes | None = None - def camera_image(self): + def camera_image(self) -> bytes | None: """Return image response.""" self.update() return self._image - def update(self): + def update(self) -> None: """Check the contents of the map list.""" - if self.neato is None: - _LOGGER.error("Error while updating '%s'", self.entity_id) - self._image = None - self._image_url = None - self._available = False - return _LOGGER.debug("Running camera update for '%s'", self.entity_id) try: @@ -80,7 +88,8 @@ class NeatoCleaningMap(Camera): return image_url = None - map_data = self._mapdata[self._robot_serial]["maps"][0] + if self._mapdata: + map_data: dict[str, Any] = self._mapdata[self._robot_serial]["maps"][0] image_url = map_data["url"] if image_url == self._image_url: _LOGGER.debug( @@ -89,7 +98,7 @@ class NeatoCleaningMap(Camera): return try: - image = self.neato.download_map(image_url) + image: HTTPResponse = self.neato.download_map(image_url) except NeatoRobotException as ex: if self._available: # Print only once when available _LOGGER.error( @@ -102,33 +111,33 @@ class NeatoCleaningMap(Camera): self._image = image.read() self._image_url = image_url - self._generated_at = map_data["generated_at"] + self._generated_at = map_data.get("generated_at") self._available = True @property - def name(self): + def name(self) -> str: """Return the name of this camera.""" return self._robot_name @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID.""" return self._robot_serial @property - def available(self): + def available(self) -> bool: """Return if the robot is available.""" return self._available @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for neato robot.""" return {"identifiers": {(NEATO_DOMAIN, self._robot_serial)}} @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" - data = {} + data: dict[str, Any] = {} if self._generated_at is not None: data[ATTR_GENERATED_AT] = self._generated_at diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index c4ca9e45a89..07aea0a7e9c 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -2,10 +2,13 @@ from __future__ import annotations import logging +from types import MappingProxyType +from typing import Any import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import NEATO_DOMAIN @@ -23,7 +26,9 @@ class OAuth2FlowHandler( """Return logger.""" return logging.getLogger(__name__) - async def async_step_user(self, user_input: dict | None = None) -> dict: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Create an entry for the flow.""" current_entries = self._async_current_entries() if self.source != SOURCE_REAUTH and current_entries: @@ -32,11 +37,13 @@ class OAuth2FlowHandler( return await super().async_step_user(user_input=user_input) - async def async_step_reauth(self, data) -> dict: + async def async_step_reauth(self, data: MappingProxyType[str, Any]) -> FlowResult: """Perform reauth upon migration of old entries.""" return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input: dict | None = None) -> dict: + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm reauth upon migration of old entries.""" if user_input is None: return self.async_show_form( @@ -44,7 +51,7 @@ class OAuth2FlowHandler( ) return await self.async_step_user() - async def async_oauth_create_entry(self, data: dict) -> dict: + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow. Update an entry if one already exist.""" current_entries = self._async_current_entries() if self.source == SOURCE_REAUTH and current_entries: diff --git a/homeassistant/components/neato/hub.py b/homeassistant/components/neato/hub.py index b394507f408..cb639de4acb 100644 --- a/homeassistant/components/neato/hub.py +++ b/homeassistant/components/neato/hub.py @@ -3,7 +3,9 @@ from datetime import timedelta import logging from pybotvac import Account +from urllib3.response import HTTPResponse +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.util import Throttle @@ -21,23 +23,23 @@ class NeatoHub: self.my_neato: Account = neato @Throttle(timedelta(minutes=1)) - def update_robots(self): + def update_robots(self) -> None: """Update the robot states.""" _LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS)) self._hass.data[NEATO_ROBOTS] = self.my_neato.robots self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps - def download_map(self, url): + def download_map(self, url: str) -> HTTPResponse: """Download a new map image.""" map_image_data = self.my_neato.get_map_image(url) return map_image_data - async def async_update_entry_unique_id(self, entry) -> str: + async def async_update_entry_unique_id(self, entry: ConfigEntry) -> str: """Update entry for unique_id.""" await self._hass.async_add_executor_job(self.my_neato.refresh_userdata) - unique_id = self.my_neato.unique_id + unique_id: str = self.my_neato.unique_id if entry.unique_id == unique_id: return unique_id diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 98208698037..1cf10112b92 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -1,11 +1,20 @@ """Support for Neato sensors.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from pybotvac.exceptions import NeatoRobotException +from pybotvac.robot import Robot +from homeassistant.components.neato import NeatoHub from homeassistant.components.sensor import DEVICE_CLASS_BATTERY, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES @@ -16,10 +25,12 @@ SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES) BATTERY = "Battery" -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Neato sensor using config entry.""" dev = [] - neato = hass.data.get(NEATO_LOGIN) + neato: NeatoHub = hass.data[NEATO_LOGIN] for robot in hass.data[NEATO_ROBOTS]: dev.append(NeatoSensor(neato, robot)) @@ -33,15 +44,15 @@ async def async_setup_entry(hass, entry, async_add_entities): class NeatoSensor(SensorEntity): """Neato sensor.""" - def __init__(self, neato, robot): + def __init__(self, neato: NeatoHub, robot: Robot) -> None: """Initialize Neato sensor.""" self.robot = robot - self._available = False - self._robot_name = f"{self.robot.name} {BATTERY}" - self._robot_serial = self.robot.serial - self._state = None + self._available: bool = False + self._robot_name: str = f"{self.robot.name} {BATTERY}" + self._robot_serial: str = self.robot.serial + self._state: dict[str, Any] | None = None - def update(self): + def update(self) -> None: """Update Neato Sensor.""" try: self._state = self.robot.state @@ -58,36 +69,38 @@ class NeatoSensor(SensorEntity): _LOGGER.debug("self._state=%s", self._state) @property - def name(self): + def name(self) -> str: """Return the name of this sensor.""" return self._robot_name @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID.""" return self._robot_serial @property - def device_class(self): + def device_class(self) -> str: """Return the device class.""" return DEVICE_CLASS_BATTERY @property - def available(self): + def available(self) -> bool: """Return availability.""" return self._available @property - def state(self): + def state(self) -> str | None: """Return the state.""" - return self._state["details"]["charge"] if self._state else None + if self._state is not None: + return str(self._state["details"]["charge"]) + return None @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return unit of measurement.""" return PERCENTAGE @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for neato robot.""" return {"identifiers": {(NEATO_DOMAIN, self._robot_serial)}} diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index a3cc51b82c6..0e0d49f2b28 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -1,11 +1,19 @@ """Support for Neato Connected Vacuums switches.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from pybotvac.exceptions import NeatoRobotException +from pybotvac.robot import Robot +from homeassistant.components.neato import NeatoHub +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, ToggleEntity +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES @@ -18,10 +26,13 @@ SWITCH_TYPE_SCHEDULE = "schedule" SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]} -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Neato switch with config entry.""" dev = [] - neato = hass.data.get(NEATO_LOGIN) + neato: NeatoHub = hass.data[NEATO_LOGIN] + for robot in hass.data[NEATO_ROBOTS]: for type_name in SWITCH_TYPES: dev.append(NeatoConnectedSwitch(neato, robot, type_name)) @@ -36,18 +47,18 @@ async def async_setup_entry(hass, entry, async_add_entities): class NeatoConnectedSwitch(ToggleEntity): """Neato Connected Switches.""" - def __init__(self, neato, robot, switch_type): + def __init__(self, neato: NeatoHub, robot: Robot, switch_type: str) -> None: """Initialize the Neato Connected switches.""" self.type = switch_type self.robot = robot self._available = False self._robot_name = f"{self.robot.name} {SWITCH_TYPES[self.type][0]}" - self._state = None - self._schedule_state = None + self._state: dict[str, Any] | None = None + self._schedule_state: str | None = None self._clean_state = None - self._robot_serial = self.robot.serial + self._robot_serial: str = self.robot.serial - def update(self): + def update(self) -> None: """Update the states of Neato switches.""" _LOGGER.debug("Running Neato switch update for '%s'", self.entity_id) try: @@ -65,7 +76,7 @@ class NeatoConnectedSwitch(ToggleEntity): _LOGGER.debug("self._state=%s", self._state) if self.type == SWITCH_TYPE_SCHEDULE: _LOGGER.debug("State: %s", self._state) - if self._state["details"]["isScheduleEnabled"]: + if self._state is not None and self._state["details"]["isScheduleEnabled"]: self._schedule_state = STATE_ON else: self._schedule_state = STATE_OFF @@ -74,34 +85,33 @@ class NeatoConnectedSwitch(ToggleEntity): ) @property - def name(self): + def name(self) -> str: """Return the name of the switch.""" return self._robot_name @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" return self._robot_serial @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" - if self.type == SWITCH_TYPE_SCHEDULE: - if self._schedule_state == STATE_ON: - return True - return False + return bool( + self.type == SWITCH_TYPE_SCHEDULE and self._schedule_state == STATE_ON + ) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for neato robot.""" return {"identifiers": {(NEATO_DOMAIN, self._robot_serial)}} - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if self.type == SWITCH_TYPE_SCHEDULE: try: @@ -111,7 +121,7 @@ class NeatoConnectedSwitch(ToggleEntity): "Neato switch connection error '%s': %s", self.entity_id, ex ) - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" if self.type == SWITCH_TYPE_SCHEDULE: try: diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index b6cf43a6a3e..527cd4dce23 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -1,7 +1,11 @@ """Support for Neato Connected Vacuums.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any +from pybotvac import Robot from pybotvac.exceptions import NeatoRobotException import voluptuous as vol @@ -24,9 +28,14 @@ from homeassistant.components.vacuum import ( SUPPORT_STOP, StateVacuumEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import NeatoHub from .const import ( ACTION, ALERTS, @@ -72,12 +81,14 @@ ATTR_CATEGORY = "category" ATTR_ZONE = "zone" -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Neato vacuum with config entry.""" dev = [] - neato = hass.data.get(NEATO_LOGIN) - mapdata = hass.data.get(NEATO_MAP_DATA) - persistent_maps = hass.data.get(NEATO_PERSISTENT_MAPS) + neato: NeatoHub = hass.data[NEATO_LOGIN] + mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA) + persistent_maps: dict[str, Any] | None = hass.data.get(NEATO_PERSISTENT_MAPS) for robot in hass.data[NEATO_ROBOTS]: dev.append(NeatoConnectedVacuum(neato, robot, mapdata, persistent_maps)) @@ -105,33 +116,39 @@ async def async_setup_entry(hass, entry, async_add_entities): class NeatoConnectedVacuum(StateVacuumEntity): """Representation of a Neato Connected Vacuum.""" - def __init__(self, neato, robot, mapdata, persistent_maps): + def __init__( + self, + neato: NeatoHub, + robot: Robot, + mapdata: dict[str, Any] | None, + persistent_maps: dict[str, Any] | None, + ) -> None: """Initialize the Neato Connected Vacuum.""" self.robot = robot - self._available = neato is not None + self._available: bool = neato is not None self._mapdata = mapdata - self._name = f"{self.robot.name}" - self._robot_has_map = self.robot.has_persistent_maps + self._name: str = f"{self.robot.name}" + self._robot_has_map: bool = self.robot.has_persistent_maps self._robot_maps = persistent_maps - self._robot_serial = self.robot.serial - self._status_state = None - self._clean_state = None - self._state = None - self._clean_time_start = None - self._clean_time_stop = None - self._clean_area = None - self._clean_battery_start = None - self._clean_battery_end = None - self._clean_susp_charge_count = None - self._clean_susp_time = None - self._clean_pause_time = None - self._clean_error_time = None - self._launched_from = None - self._battery_level = None - self._robot_boundaries = [] - self._robot_stats = None + self._robot_serial: str = self.robot.serial + self._status_state: str | None = None + self._clean_state: str | None = None + self._state: dict[str, Any] | None = None + self._clean_time_start: str | None = None + self._clean_time_stop: str | None = None + self._clean_area: float | None = None + self._clean_battery_start: int | None = None + self._clean_battery_end: int | None = None + self._clean_susp_charge_count: int | None = None + self._clean_susp_time: int | None = None + self._clean_pause_time: int | None = None + self._clean_error_time: int | None = None + self._launched_from: str | None = None + self._battery_level: int | None = None + self._robot_boundaries: list = [] + self._robot_stats: dict[str, Any] | None = None - def update(self): + def update(self) -> None: """Update the states of Neato Vacuums.""" _LOGGER.debug("Running Neato Vacuums update for '%s'", self.entity_id) try: @@ -151,6 +168,8 @@ class NeatoConnectedVacuum(StateVacuumEntity): self._available = False return + if self._state is None: + return self._available = True _LOGGER.debug("self._state=%s", self._state) if "alert" in self._state: @@ -198,10 +217,12 @@ class NeatoConnectedVacuum(StateVacuumEntity): self._battery_level = self._state["details"]["charge"] - if not self._mapdata.get(self._robot_serial, {}).get("maps", []): + if self._mapdata is None or not self._mapdata.get(self._robot_serial, {}).get( + "maps", [] + ): return - mapdata = self._mapdata[self._robot_serial]["maps"][0] + mapdata: dict[str, Any] = self._mapdata[self._robot_serial]["maps"][0] self._clean_time_start = mapdata["start_at"] self._clean_time_stop = mapdata["end_at"] self._clean_area = mapdata["cleaned_area"] @@ -215,10 +236,11 @@ class NeatoConnectedVacuum(StateVacuumEntity): if ( self._robot_has_map + and self._state and self._state["availableServices"]["maps"] != "basic-1" - and self._robot_maps[self._robot_serial] + and self._robot_maps ): - allmaps = self._robot_maps[self._robot_serial] + allmaps: dict = self._robot_maps[self._robot_serial] _LOGGER.debug( "Found the following maps for '%s': %s", self.entity_id, allmaps ) @@ -249,44 +271,44 @@ class NeatoConnectedVacuum(StateVacuumEntity): ) @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def supported_features(self): + def supported_features(self) -> int: """Flag vacuum cleaner robot features that are supported.""" return SUPPORT_NEATO @property - def battery_level(self): + def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" return self._battery_level @property - def available(self): + def available(self) -> bool: """Return if the robot is available.""" return self._available @property - def icon(self): + def icon(self) -> str: """Return neato specific icon.""" return "mdi:robot-vacuum-variant" @property - def state(self): + def state(self) -> str | None: """Return the status of the vacuum cleaner.""" return self._clean_state @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" return self._robot_serial @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" - data = {} + data: dict[str, Any] = {} if self._status_state is not None: data[ATTR_STATUS] = self._status_state @@ -314,28 +336,32 @@ class NeatoConnectedVacuum(StateVacuumEntity): return data @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for neato robot.""" - info = {"identifiers": {(NEATO_DOMAIN, self._robot_serial)}, "name": self._name} + info: DeviceInfo = { + "identifiers": {(NEATO_DOMAIN, self._robot_serial)}, + "name": self._name, + } if self._robot_stats: info["manufacturer"] = self._robot_stats["battery"]["vendor"] info["model"] = self._robot_stats["model"] info["sw_version"] = self._robot_stats["firmware"] return info - def start(self): + def start(self) -> None: """Start cleaning or resume cleaning.""" - try: - if self._state["state"] == 1: - self.robot.start_cleaning() - elif self._state["state"] == 3: - self.robot.resume_cleaning() - except NeatoRobotException as ex: - _LOGGER.error( - "Neato vacuum connection error for '%s': %s", self.entity_id, ex - ) + if self._state: + try: + if self._state["state"] == 1: + self.robot.start_cleaning() + elif self._state["state"] == 3: + self.robot.resume_cleaning() + except NeatoRobotException as ex: + _LOGGER.error( + "Neato vacuum connection error for '%s': %s", self.entity_id, ex + ) - def pause(self): + def pause(self) -> None: """Pause the vacuum.""" try: self.robot.pause_cleaning() @@ -344,7 +370,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): "Neato vacuum connection error for '%s': %s", self.entity_id, ex ) - def return_to_base(self, **kwargs): + def return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" try: if self._clean_state == STATE_CLEANING: @@ -356,7 +382,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): "Neato vacuum connection error for '%s': %s", self.entity_id, ex ) - def stop(self, **kwargs): + def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" try: self.robot.stop_cleaning() @@ -365,7 +391,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): "Neato vacuum connection error for '%s': %s", self.entity_id, ex ) - def locate(self, **kwargs): + def locate(self, **kwargs: Any) -> None: """Locate the robot by making it emit a sound.""" try: self.robot.locate() @@ -374,7 +400,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): "Neato vacuum connection error for '%s': %s", self.entity_id, ex ) - def clean_spot(self, **kwargs): + def clean_spot(self, **kwargs: Any) -> None: """Run a spot cleaning starting from the base.""" try: self.robot.start_spot_cleaning() @@ -383,7 +409,9 @@ class NeatoConnectedVacuum(StateVacuumEntity): "Neato vacuum connection error for '%s': %s", self.entity_id, ex ) - def neato_custom_cleaning(self, mode, navigation, category, zone=None): + def neato_custom_cleaning( + self, mode: str, navigation: str, category: str, zone: str | None = None + ) -> None: """Zone cleaning service call.""" boundary_id = None if zone is not None: diff --git a/mypy.ini b/mypy.ini index 7f0a932f0af..9c54f7a043f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -704,6 +704,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.neato.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.nest.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1515,9 +1526,6 @@ ignore_errors = true [mypy-homeassistant.components.mullvad.*] ignore_errors = true -[mypy-homeassistant.components.neato.*] -ignore_errors = true - [mypy-homeassistant.components.ness_alarm.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 23967721053..e3b76747be2 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -101,7 +101,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.mobile_app.*", "homeassistant.components.motion_blinds.*", "homeassistant.components.mullvad.*", - "homeassistant.components.neato.*", "homeassistant.components.ness_alarm.*", "homeassistant.components.nest.legacy.*", "homeassistant.components.netio.*", From aaddeb0bcdefa735a6b5c4be3f3f905537467518 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 8 Aug 2021 15:21:55 +0200 Subject: [PATCH 222/903] Add missing `motor_speed` sensor for Xiaomi Miio humidifier CA1 and CB1 (#54264) * Add motor_speed sensor for CA1 and CB1 * Remove value limits --- .../components/xiaomi_miio/sensor.py | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 852adfcc071..c180bb75a77 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -49,6 +49,8 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRHUMIDIFIER_CA1, + MODEL_AIRHUMIDIFIER_CB1, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, @@ -69,13 +71,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) -ATTR_ACTUAL_MOTOR_SPEED = "actual_speed" +ATTR_ACTUAL_SPEED = "actual_speed" ATTR_AIR_QUALITY = "air_quality" ATTR_CHARGING = "charging" ATTR_DISPLAY_CLOCK = "display_clock" ATTR_HUMIDITY = "humidity" ATTR_ILLUMINANCE = "illuminance" ATTR_LOAD_POWER = "load_power" +ATTR_MOTOR_SPEED = "motor_speed" ATTR_NIGHT_MODE = "night_mode" ATTR_NIGHT_TIME_BEGIN = "night_time_begin" ATTR_NIGHT_TIME_END = "night_time_end" @@ -130,14 +133,19 @@ SENSOR_TYPES = { valid_min_value=0.0, valid_max_value=100.0, ), - ATTR_ACTUAL_MOTOR_SPEED: XiaomiMiioSensorDescription( - key=ATTR_ACTUAL_MOTOR_SPEED, + ATTR_ACTUAL_SPEED: XiaomiMiioSensorDescription( + key=ATTR_ACTUAL_SPEED, name="Actual Speed", unit_of_measurement="rpm", icon="mdi:fast-forward", state_class=STATE_CLASS_MEASUREMENT, - valid_min_value=200.0, - valid_max_value=2000.0, + ), + ATTR_MOTOR_SPEED: XiaomiMiioSensorDescription( + key=ATTR_MOTOR_SPEED, + name="Motor Speed", + unit_of_measurement="rpm", + icon="mdi:fast-forward", + state_class=STATE_CLASS_MEASUREMENT, ), ATTR_ILLUMINANCE: XiaomiMiioSensorDescription( key=ATTR_ILLUMINANCE, @@ -154,22 +162,15 @@ SENSOR_TYPES = { ), } -HUMIDIFIER_MIIO_SENSORS = { - ATTR_HUMIDITY: "humidity", - ATTR_TEMPERATURE: "temperature", -} - -HUMIDIFIER_MIOT_SENSORS = { - ATTR_HUMIDITY: "humidity", - ATTR_TEMPERATURE: "temperature", - ATTR_WATER_LEVEL: "water_level", - ATTR_ACTUAL_MOTOR_SPEED: "actual_speed", -} - -HUMIDIFIER_MJJSQ_SENSORS = { - ATTR_HUMIDITY: "humidity", - ATTR_TEMPERATURE: "temperature", -} +HUMIDIFIER_MIIO_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE) +HUMIDIFIER_CA1_CB1_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE, ATTR_MOTOR_SPEED) +HUMIDIFIER_MIOT_SENSORS = ( + ATTR_HUMIDITY, + ATTR_TEMPERATURE, + ATTR_WATER_LEVEL, + ATTR_ACTUAL_SPEED, +) +HUMIDIFIER_MJJSQ_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -225,7 +226,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = config_entry.data[CONF_MODEL] device = None sensors = [] - if model in MODELS_HUMIDIFIER_MIOT: + if model in (MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1): + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + sensors = HUMIDIFIER_CA1_CB1_SENSORS + elif model in MODELS_HUMIDIFIER_MIOT: device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] sensors = HUMIDIFIER_MIOT_SENSORS elif model in MODELS_HUMIDIFIER_MJJSQ: From 89bb95b0bee72c275d03bcc08df3f5f863bd10ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 8 Aug 2021 15:41:05 +0200 Subject: [PATCH 223/903] Add re-authentication to Uptime Robot (#54226) * Add reauthentication to Uptime Robot * Fix en strings * format * Fix docstring * Remove unused patch * Handle no existing entry * Handle account mismatch during reauthentication * Add test to validate reauth is triggered properly * Test reauth after setup * Adjust tests * Add full context for reauth init --- .../components/uptimerobot/__init__.py | 10 +- .../components/uptimerobot/config_flow.py | 42 +++- .../components/uptimerobot/strings.json | 45 ++-- .../uptimerobot/translations/en.json | 11 +- .../uptimerobot/test_config_flow.py | 197 +++++++++++++++++- tests/components/uptimerobot/test_init.py | 107 ++++++++++ 6 files changed, 383 insertions(+), 29 deletions(-) create mode 100644 tests/components/uptimerobot/test_init.py diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 4e6ff7908ee..4eaef45c4d2 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -1,11 +1,17 @@ """The Uptime Robot integration.""" from __future__ import annotations -from pyuptimerobot import UptimeRobot, UptimeRobotException, UptimeRobotMonitor +from pyuptimerobot import ( + UptimeRobot, + UptimeRobotAuthenticationException, + UptimeRobotException, + UptimeRobotMonitor, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import ( DeviceRegistry, @@ -74,6 +80,8 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator): """Update data.""" try: response = await self._api.async_get_monitors() + except UptimeRobotAuthenticationException as exception: + raise ConfigEntryAuthFailed(exception) from exception except UptimeRobotException as exception: raise UpdateFailed(exception) from exception else: diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index 7bab74fa03e..1e8bec992ad 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -58,15 +58,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if response and response.data and response.data.email else None ) - if account: - await self.async_set_unique_id(str(account.user_id)) - self._abort_if_unique_id_configured() return errors, account async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: """Handle the initial step.""" - errors: dict[str, str] = {} if user_input is None: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA @@ -74,12 +70,48 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors, account = await self._validate_input(user_input) if account: + await self.async_set_unique_id(str(account.user_id)) + self._abort_if_unique_id_configured() return self.async_create_entry(title=account.email, data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth( + self, user_input: ConfigType | None = None + ) -> FlowResult: + """Return the reauth confirm step.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: ConfigType | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA + ) + errors, account = await self._validate_input(user_input) + if account: + if self.context.get("unique_id") and self.context["unique_id"] != str( + account.user_id + ): + errors["base"] = "reauth_failed_matching_account" + else: + existing_entry = await self.async_set_unique_id(str(account.user_id)) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=user_input + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reauth_failed_existing") + + return self.async_show_form( + step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + async def async_step_import(self, import_config: ConfigType) -> FlowResult: """Import a config entry from configuration.yaml.""" for entry in self._async_current_entries(): @@ -93,5 +125,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _, account = await self._validate_input(imported_config) if account: + await self.async_set_unique_id(str(account.user_id)) + self._abort_if_unique_id_configured() return self.async_create_entry(title=account.email, data=imported_config) return self.async_abort(reason="unknown") diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index f51061eec33..094130b470d 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -1,20 +1,31 @@ { - "config": { - "step": { - "user": { - "data": { - "api_key": "[%key:common::config_flow::data::api_key%]" + "config": { + "step": { + "user": { + "description": "You need to supply a read-only API key from Uptime Robot", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "You need to supply a new read-only API key from Uptime Robot", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "reauth_failed_matching_account": "The API key you provided does not match the account ID for existing configuration." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again.", + "unknown": "[%key:common::config_flow::error::unknown%]" } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "unknown": "[%key:common::config_flow::error::unknown%]" } - } -} +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/en.json b/homeassistant/components/uptimerobot/translations/en.json index d23431fa888..8140c84897f 100644 --- a/homeassistant/components/uptimerobot/translations/en.json +++ b/homeassistant/components/uptimerobot/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Account is already configured", - "unknown": "Unexpected error" + "unknown": "Unexpected error", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -11,6 +12,14 @@ }, "step": { "user": { + "description": "You need to supply a read-only API key from Uptime Robot", + "data": { + "api_key": "API Key" + } + }, + "reauth_confirm": { + "title": "Reauthenticate Integration", + "description": "You need to supply a read-only API key from Uptime Robot", "data": { "api_key": "API Key" } diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 41f0b6b639e..967e1b499f5 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -70,7 +70,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) assert result2["type"] == RESULT_TYPE_FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"]["base"] == "cannot_connect" async def test_form_unexpected_error(hass: HomeAssistant) -> None: @@ -88,7 +88,7 @@ async def test_form_unexpected_error(hass: HomeAssistant) -> None: {"api_key": "1234"}, ) - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"]["base"] == "unknown" async def test_form_api_key_error(hass: HomeAssistant) -> None: @@ -106,7 +106,7 @@ async def test_form_api_key_error(hass: HomeAssistant) -> None: {"api_key": "1234"}, ) - assert result2["errors"] == {"base": "invalid_api_key"} + assert result2["errors"]["base"] == "invalid_api_key" async def test_form_api_error(hass: HomeAssistant, caplog: LogCaptureFixture) -> None: @@ -129,7 +129,7 @@ async def test_form_api_error(hass: HomeAssistant, caplog: LogCaptureFixture) -> {"api_key": "1234"}, ) - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"]["base"] == "unknown" assert "test error from API." in caplog.text @@ -211,7 +211,7 @@ async def test_user_unique_id_already_exists(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] is None with patch( @@ -233,5 +233,190 @@ async def test_user_unique_id_already_exists(hass): await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 - assert result2["type"] == "abort" + assert result2["type"] == RESULT_TYPE_ABORT assert result2["reason"] == "already_configured" + + +async def test_reauthentication(hass): + """Test Uptime Robot reauthentication.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test", "user_id": 1234567890}, + } + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauthentication_failure(hass): + """Test Uptime Robot reauthentication failure.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "fail", + "error": {"message": "test error from API."}, + } + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"]["base"] == "unknown" + + +async def test_reauthentication_failure_no_existing_entry(hass): + """Test Uptime Robot reauthentication with no existing entry.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + data={"platform": DOMAIN, "api_key": "1234"}, + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test", "user_id": 1234567890}, + } + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_failed_existing" + + +async def test_reauthentication_failure_account_not_matching(hass): + """Test Uptime Robot reauthentication failure when using another account.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test", "user_id": 1234567891}, + } + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"]["base"] == "reauth_failed_matching_account" diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py new file mode 100644 index 00000000000..b4534af763a --- /dev/null +++ b/tests/components/uptimerobot/test_init.py @@ -0,0 +1,107 @@ +"""Test the Uptime Robot init.""" +import datetime +from unittest.mock import patch + +from pytest import LogCaptureFixture +from pyuptimerobot import UptimeRobotApiResponse +from pyuptimerobot.exceptions import UptimeRobotAuthenticationException + +from homeassistant import config_entries +from homeassistant.components.uptimerobot.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_reauthentication_trigger_in_setup( + hass: HomeAssistant, caplog: LogCaptureFixture +): + """Test reauthentication trigger.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="test@test.test", + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + source=config_entries.SOURCE_USER, + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotAuthenticationException, + ): + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR + assert mock_config_entry.reason == "could not authenticate" + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + assert ( + "Config entry 'test@test.test' for uptimerobot integration could not authenticate" + in caplog.text + ) + + +async def test_reauthentication_trigger_after_setup( + hass: HomeAssistant, caplog: LogCaptureFixture +): + """Test reauthentication trigger.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="test@test.test", + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + source=config_entries.SOURCE_USER, + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "monitors": [ + {"id": 1234, "friendly_name": "Test monitor", "status": 2} + ], + } + ), + ): + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + binary_sensor = hass.states.get("binary_sensor.test_monitor") + assert mock_config_entry.state == config_entries.ConfigEntryState.LOADED + assert binary_sensor.state == "on" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotAuthenticationException, + ): + + async_fire_time_changed(hass, dt.utcnow() + datetime.timedelta(seconds=10)) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + binary_sensor = hass.states.get("binary_sensor.test_monitor") + + assert binary_sensor.state == "unavailable" + assert "Authentication failed while fetching uptimerobot data" in caplog.text + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id From 7590cb2861f55d72c5f7b459e78d4f9f01b7a50c Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 8 Aug 2021 09:43:08 -0400 Subject: [PATCH 224/903] Fix camera state and attributes for agent_dvr (#54049) * Fix camera state and attributes for agent_dvr * tweak * tweak --- homeassistant/components/agent_dvr/camera.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py index 30c27eb047a..8a29428a833 100644 --- a/homeassistant/components/agent_dvr/camera.py +++ b/homeassistant/components/agent_dvr/camera.py @@ -67,8 +67,6 @@ async def async_setup_entry( class AgentCamera(MjpegCamera): """Representation of an Agent Device Stream.""" - _attr_supported_features = SUPPORT_ON_OFF - def __init__(self, device): """Initialize as a subclass of MjpegCamera.""" device_info = { @@ -80,7 +78,6 @@ class AgentCamera(MjpegCamera): self._removed = False self._attr_name = f"{device.client.name} {device.name}" self._attr_unique_id = f"{device._client.unique}_{device.typeID}_{device.id}" - self._attr_should_poll = True super().__init__(device_info) self._attr_device_info = { "identifiers": {(AGENT_DOMAIN, self.unique_id)}, @@ -102,10 +99,10 @@ class AgentCamera(MjpegCamera): if self.device.client.is_available and not self._removed: _LOGGER.error("%s lost", self.name) self._removed = True - self._attr_available = self.device.client.is_available self._attr_icon = "mdi:camcorder-off" if self.is_on: self._attr_icon = "mdi:camcorder" + self._attr_available = self.device.client.is_available self._attr_extra_state_attributes = { ATTR_ATTRIBUTION: ATTRIBUTION, "editable": False, @@ -117,6 +114,11 @@ class AgentCamera(MjpegCamera): "alerts_enabled": self.device.alerts_active, } + @property + def should_poll(self) -> bool: + """Update the state periodically.""" + return True + @property def is_recording(self) -> bool: """Return whether the monitor is recording.""" @@ -137,6 +139,11 @@ class AgentCamera(MjpegCamera): """Return True if entity is connected.""" return self.device.connected + @property + def supported_features(self) -> int: + """Return supported features.""" + return SUPPORT_ON_OFF + @property def is_on(self) -> bool: """Return true if on.""" From e8aa280d7f427e8d2f3536210456213dd4a0466f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 8 Aug 2021 22:48:33 +0200 Subject: [PATCH 225/903] Add modbus get_hub (#54277) * Add dict with hubs. * Update flexit to use get_hub. * Remove executor_task for close. --- homeassistant/components/flexit/climate.py | 4 +-- homeassistant/components/modbus/__init__.py | 8 +++++- .../components/modbus/binary_sensor.py | 4 +-- homeassistant/components/modbus/climate.py | 4 +-- homeassistant/components/modbus/cover.py | 4 +-- homeassistant/components/modbus/fan.py | 5 ++-- homeassistant/components/modbus/light.py | 4 +-- homeassistant/components/modbus/modbus.py | 25 +++++++++---------- homeassistant/components/modbus/sensor.py | 4 +-- homeassistant/components/modbus/switch.py | 4 +-- 10 files changed, 36 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index 5e7ac137982..ce3e5a68e1e 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -11,13 +11,13 @@ from homeassistant.components.climate.const import ( SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.components.modbus import get_hub from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, CALL_TYPE_WRITE_REGISTER, CONF_HUB, DEFAULT_HUB, - MODBUS_DOMAIN, ) from homeassistant.components.modbus.modbus import ModbusHub from homeassistant.const import ( @@ -53,7 +53,7 @@ async def async_setup_platform( """Set up the Flexit Platform.""" modbus_slave = config.get(CONF_SLAVE) name = config.get(CONF_NAME) - hub = hass.data[MODBUS_DOMAIN][config.get(CONF_HUB)] + hub = get_hub(hass, config[CONF_HUB]) async_add_entities([Flexit(hub, modbus_slave, name)], True) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 43aa49e6da7..8e7d1e48e1a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -42,6 +42,7 @@ from homeassistant.const import ( CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from .const import ( @@ -114,7 +115,7 @@ from .const import ( DEFAULT_TEMP_UNIT, MODBUS_DOMAIN as DOMAIN, ) -from .modbus import async_modbus_setup +from .modbus import ModbusHub, async_modbus_setup from .validators import number_validator, scan_interval_validator, struct_validator _LOGGER = logging.getLogger(__name__) @@ -357,6 +358,11 @@ SERVICE_WRITE_COIL_SCHEMA = vol.Schema( ) +def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: + """Return modbus hub with name.""" + return hass.data[DOMAIN][name] + + async def async_setup(hass, config): """Set up Modbus component.""" return await async_modbus_setup( diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index ac635c76275..a54630379b8 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import get_hub from .base_platform import BasePlatform -from .const import MODBUS_DOMAIN PARALLEL_UPDATES = 1 _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ async def async_setup_platform( return for entry in discovery_info[CONF_BINARY_SENSORS]: - hub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub = get_hub(hass, discovery_info[CONF_NAME]) sensors.append(ModbusBinarySensor(hub, entry)) async_add_entities(sensors) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 16334d883a9..692d3c9a058 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -23,6 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import get_hub from .base_platform import BaseStructPlatform from .const import ( ATTR_TEMPERATURE, @@ -39,7 +40,6 @@ from .const import ( DATA_TYPE_UINT16, DATA_TYPE_UINT32, DATA_TYPE_UINT64, - MODBUS_DOMAIN, ) from .modbus import ModbusHub @@ -59,7 +59,7 @@ async def async_setup_platform( entities = [] for entity in discovery_info[CONF_CLIMATES]: - hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) entities.append(ModbusThermostat(hub, entity)) async_add_entities(entities) diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 98a352f218a..e55fe6d92eb 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import get_hub from .base_platform import BasePlatform from .const import ( CALL_TYPE_COIL, @@ -30,7 +31,6 @@ from .const import ( CONF_STATE_OPENING, CONF_STATUS_REGISTER, CONF_STATUS_REGISTER_TYPE, - MODBUS_DOMAIN, ) from .modbus import ModbusHub @@ -50,7 +50,7 @@ async def async_setup_platform( covers = [] for cover in discovery_info[CONF_COVERS]: - hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) covers.append(ModbusCover(hub, cover)) async_add_entities(covers) diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index a4d4265846d..435d331bbc4 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -8,8 +8,9 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from . import get_hub from .base_platform import BaseSwitch -from .const import CONF_FANS, MODBUS_DOMAIN +from .const import CONF_FANS from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -25,7 +26,7 @@ async def async_setup_platform( fans = [] for entry in discovery_info[CONF_FANS]: - hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) fans.append(ModbusFan(hub, entry)) async_add_entities(fans) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 3eae5ed3db3..dd9a8ad754d 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -8,8 +8,8 @@ from homeassistant.const import CONF_LIGHTS, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from . import get_hub from .base_platform import BaseSwitch -from .const import MODBUS_DOMAIN from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -25,7 +25,7 @@ async def async_setup_platform( lights = [] for entry in discovery_info[CONF_LIGHTS]: - hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) lights.append(ModbusLight(hub, entry)) async_add_entities(lights) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index dad91f26a12..e2f1295220f 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -1,4 +1,6 @@ """Support for Modbus.""" +from __future__ import annotations + import asyncio from collections import namedtuple import logging @@ -57,6 +59,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) + ConfEntry = namedtuple("ConfEntry", "call_type attr func_name") RunEntry = namedtuple("RunEntry", "attr func") PYMODBUS_CALL = [ @@ -184,6 +187,8 @@ async def async_modbus_setup( class ModbusHub: """Thread safe wrapper class for pymodbus.""" + name: str + def __init__(self, hass, client_config): """Initialize the Modbus hub.""" @@ -193,7 +198,7 @@ class ModbusHub: self._in_error = False self._lock = asyncio.Lock() self.hass = hass - self._config_name = client_config[CONF_NAME] + self.name = client_config[CONF_NAME] self._config_type = client_config[CONF_TYPE] self._config_delay = client_config[CONF_DELAY] self._pb_call = {} @@ -262,7 +267,7 @@ class ModbusHub: """Try to connect, and retry if needed.""" async with self._lock: if not await self.hass.async_add_executor_job(self._pymodbus_connect): - err = f"{self._config_name} connect failed, retry in pymodbus" + err = f"{self.name} connect failed, retry in pymodbus" self._log_error(err, error_state=False) return @@ -278,8 +283,11 @@ class ModbusHub: self._async_cancel_listener = None self._config_delay = 0 - def _pymodbus_close(self): - """Close sync. pymodbus.""" + async def async_close(self): + """Disconnect client.""" + if self._async_cancel_listener: + self._async_cancel_listener() + self._async_cancel_listener = None if self._client: try: self._client.close() @@ -287,15 +295,6 @@ class ModbusHub: self._log_error(str(exception_error)) self._client = None - async def async_close(self): - """Disconnect client.""" - if self._async_cancel_listener: - self._async_cancel_listener() - self._async_cancel_listener = None - - async with self._lock: - return await self.hass.async_add_executor_job(self._pymodbus_close) - def _pymodbus_connect(self): """Connect client.""" try: diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index e969fa23a65..7bb7e1cd049 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import get_hub from .base_platform import BaseStructPlatform -from .const import MODBUS_DOMAIN from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -31,7 +31,7 @@ async def async_setup_platform( return for entry in discovery_info[CONF_SENSORS]: - hub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub = get_hub(hass, discovery_info[CONF_NAME]) sensors.append(ModbusRegisterSensor(hub, entry)) async_add_entities(sensors) diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 820e43419a0..55dc014420f 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -8,8 +8,8 @@ from homeassistant.const import CONF_NAME, CONF_SWITCHES from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from . import get_hub from .base_platform import BaseSwitch -from .const import MODBUS_DOMAIN from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -26,7 +26,7 @@ async def async_setup_platform( return for entry in discovery_info[CONF_SWITCHES]: - hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) switches.append(ModbusSwitch(hub, entry)) async_add_entities(switches) From 02459e68132b255b0b8a069e88ff022d1eae3ee2 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 8 Aug 2021 23:23:21 +0200 Subject: [PATCH 226/903] Convert last properties in modbus to _attr_variable (#53919) --- .../components/modbus/base_platform.py | 71 +++++++++---------- .../components/modbus/binary_sensor.py | 11 +-- homeassistant/components/modbus/climate.py | 3 +- homeassistant/components/modbus/cover.py | 25 +++---- homeassistant/components/modbus/fan.py | 8 +++ homeassistant/components/modbus/sensor.py | 9 +-- 6 files changed, 53 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index f767201496c..c580e6167ca 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_STRUCTURE, STATE_ON, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity @@ -124,48 +124,46 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): registers = self._swap_registers(registers) byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) if self._data_type == DATA_TYPE_STRING: - self._value = byte_string.decode() - else: - val = struct.unpack(self._structure, byte_string) + return byte_string.decode() - # Issue: https://github.com/home-assistant/core/issues/41944 - # If unpack() returns a tuple greater than 1, don't try to process the value. - # Instead, return the values of unpack(...) separated by commas. - if len(val) > 1: - # Apply scale and precision to floats and ints - v_result = [] - for entry in val: - v_temp = self._scale * entry + self._offset - - # We could convert int to float, and the code would still work; however - # we lose some precision, and unit tests will fail. Therefore, we do - # the conversion only when it's absolutely necessary. - if isinstance(v_temp, int) and self._precision == 0: - v_result.append(str(v_temp)) - else: - v_result.append(f"{float(v_temp):.{self._precision}f}") - self._value = ",".join(map(str, v_result)) - else: - # Apply scale and precision to floats and ints - val = self._scale * val[0] + self._offset + val = struct.unpack(self._structure, byte_string) + # Issue: https://github.com/home-assistant/core/issues/41944 + # If unpack() returns a tuple greater than 1, don't try to process the value. + # Instead, return the values of unpack(...) separated by commas. + if len(val) > 1: + # Apply scale and precision to floats and ints + v_result = [] + for entry in val: + v_temp = self._scale * entry + self._offset # We could convert int to float, and the code would still work; however # we lose some precision, and unit tests will fail. Therefore, we do # the conversion only when it's absolutely necessary. - if isinstance(val, int) and self._precision == 0: - self._value = str(val) + if isinstance(v_temp, int) and self._precision == 0: + v_result.append(str(v_temp)) else: - self._value = f"{float(val):.{self._precision}f}" + v_result.append(f"{float(v_temp):.{self._precision}f}") + return ",".join(map(str, v_result)) + + # Apply scale and precision to floats and ints + val = self._scale * val[0] + self._offset + + # We could convert int to float, and the code would still work; however + # we lose some precision, and unit tests will fail. Therefore, we do + # the conversion only when it's absolutely necessary. + if isinstance(val, int) and self._precision == 0: + return str(val) + return f"{float(val):.{self._precision}f}" -class BaseSwitch(BasePlatform, RestoreEntity): +class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): """Base class representing a Modbus switch.""" def __init__(self, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" config[CONF_INPUT_TYPE] = "" super().__init__(hub, config) - self._is_on = None + self._attr_is_on = False convert = { CALL_TYPE_REGISTER_HOLDING: ( CALL_TYPE_REGISTER_HOLDING, @@ -202,12 +200,7 @@ class BaseSwitch(BasePlatform, RestoreEntity): await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: - self._is_on = state.state == STATE_ON - - @property - def is_on(self): - """Return true if switch is on.""" - return self._is_on + self._attr_is_on = state.state == STATE_ON async def async_turn(self, command): """Evaluate switch result.""" @@ -221,7 +214,7 @@ class BaseSwitch(BasePlatform, RestoreEntity): self._attr_available = True if not self._verify_active: - self._is_on = command == self.command_on + self._attr_is_on = command == self.command_on self.async_write_ha_state() return @@ -258,13 +251,13 @@ class BaseSwitch(BasePlatform, RestoreEntity): self._attr_available = True if self._verify_type == CALL_TYPE_COIL: - self._is_on = bool(result.bits[0] & 1) + self._attr_is_on = bool(result.bits[0] & 1) else: value = int(result.registers[0]) if value == self._state_on: - self._is_on = True + self._attr_is_on = True elif value == self._state_off: - self._is_on = False + self._attr_is_on = False elif value is not None: _LOGGER.error( "Unexpected response from modbus device slave %s register %s, got 0x%2x", diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index a54630379b8..08ebfc72880 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -43,14 +43,7 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: - self._value = state.state == STATE_ON - else: - self._value = None - - @property - def is_on(self): - """Return the state of the sensor.""" - return self._value + self._attr_is_on = state.state == STATE_ON async def async_update(self, now=None): """Update the state of the sensor.""" @@ -68,6 +61,6 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): self.async_write_ha_state() return - self._value = result.bits[0] & 1 + self._attr_is_on = result.bits[0] & 1 self._attr_available = True self.async_write_ha_state() diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 692d3c9a058..831f3c979cc 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -165,8 +165,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_available = False return -1 - self.unpack_structure_result(result.registers) - + self._value = self.unpack_structure_result(result.registers) self._attr_available = True if self._value is None: diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index e55fe6d92eb..64165412d27 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -109,22 +109,13 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): STATE_UNAVAILABLE: None, STATE_UNKNOWN: None, } - self._value = convert[state.state] + self._set_attr_state(convert[state.state]) - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self._value == self._state_opening - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return self._value == self._state_closing - - @property - def is_closed(self): - """Return if the cover is closed or not.""" - return self._value == self._state_closed + def _set_attr_state(self, value): + """Convert received value to HA state.""" + self._attr_is_opening = value == self._state_opening + self._attr_is_closing = value == self._state_closing + self._attr_is_closed = value == self._state_closed async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" @@ -160,7 +151,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): return None self._attr_available = True if self._input_type == CALL_TYPE_COIL: - self._value = bool(result.bits[0] & 1) + self._set_attr_state(bool(result.bits[0] & 1)) else: - self._value = int(result.registers[0]) + self._set_attr_state(int(result.registers[0])) self.async_write_ha_state() diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index 435d331bbc4..cf5c9762db8 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -43,3 +43,11 @@ class ModbusFan(BaseSwitch, FanEntity): ) -> None: """Set fan on.""" await self.async_turn(self.command_on) + + @property + def is_on(self): + """Return true if fan is on. + + This is needed due to the ongoing conversion of fan. + """ + return self._attr_is_on diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 7bb7e1cd049..fee3f53667d 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -54,12 +54,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: - self._value = state.state - - @property - def state(self): - """Return the state of the sensor.""" - return self._value + self._attr_state = state.state async def async_update(self, now=None): """Update the state of the sensor.""" @@ -73,6 +68,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): self.async_write_ha_state() return - self.unpack_structure_result(result.registers) + self._attr_state = self.unpack_structure_result(result.registers) self._attr_available = True self.async_write_ha_state() From 50068d2352ae745542e8bc1d25f1c2444df74bfc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 8 Aug 2021 14:47:50 -0700 Subject: [PATCH 227/903] Bump google-nest-sdm to 0.3.6 (#54287) Add google-nest-sdm to 0.3.6 to include static typing fixes. --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 6c9462e43db..5b078393d1e 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["ffmpeg", "http"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.3.5"], + "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.3.6"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 7a4f1153d70..a6695806dee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -708,7 +708,7 @@ google-cloud-pubsub==2.1.0 google-cloud-texttospeech==0.4.0 # homeassistant.components.nest -google-nest-sdm==0.3.5 +google-nest-sdm==0.3.6 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbd9cbbc7ff..8e490b65514 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ google-api-python-client==1.6.4 google-cloud-pubsub==2.1.0 # homeassistant.components.nest -google-nest-sdm==0.3.5 +google-nest-sdm==0.3.6 # homeassistant.components.google_travel_time googlemaps==2.5.1 From 13737554446bd94a52f505fca4546cca1f98218c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 9 Aug 2021 00:11:34 +0000 Subject: [PATCH 228/903] [ci skip] Translation update --- .../uptimerobot/translations/ca.json | 13 +++++++++- .../uptimerobot/translations/en.json | 24 ++++++++++--------- .../uptimerobot/translations/et.json | 13 +++++++++- .../uptimerobot/translations/ru.json | 13 +++++++++- .../uptimerobot/translations/zh-Hant.json | 13 +++++++++- .../components/weather/translations/ru.json | 4 ++-- 6 files changed, 63 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/uptimerobot/translations/ca.json b/homeassistant/components/uptimerobot/translations/ca.json index ee0d2416cc6..a3bccb98295 100644 --- a/homeassistant/components/uptimerobot/translations/ca.json +++ b/homeassistant/components/uptimerobot/translations/ca.json @@ -2,18 +2,29 @@ "config": { "abort": { "already_configured": "El compte ja ha estat configurat", + "reauth_failed_existing": "No s'ha pogut actualitzar l'entrada de configuraci\u00f3, elimina la integraci\u00f3 i torna-la a instal\u00b7lar.", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_api_key": "Clau API inv\u00e0lida", + "reauth_failed_matching_account": "La clau API proporcionada no correspon amb l'identificador del compte de la configuraci\u00f3 actual.", "unknown": "Error inesperat" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Clau API" + }, + "description": "Has de proporcionar una nova clau API de nom\u00e9s lectura d'Uptime Robot", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, "user": { "data": { "api_key": "Clau API" - } + }, + "description": "Has de proporcionar una clau API de nom\u00e9s lectura d'Uptime Robot" } } } diff --git a/homeassistant/components/uptimerobot/translations/en.json b/homeassistant/components/uptimerobot/translations/en.json index 8140c84897f..ae1a8cf5e45 100644 --- a/homeassistant/components/uptimerobot/translations/en.json +++ b/homeassistant/components/uptimerobot/translations/en.json @@ -2,27 +2,29 @@ "config": { "abort": { "already_configured": "Account is already configured", - "unknown": "Unexpected error", - "reauth_successful": "Re-authentication was successful" + "reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again.", + "reauth_successful": "Re-authentication was successful", + "unknown": "Unexpected error" }, "error": { "cannot_connect": "Failed to connect", "invalid_api_key": "Invalid API key", + "reauth_failed_matching_account": "The API key you provided does not match the account ID for existing configuration.", "unknown": "Unexpected error" }, "step": { - "user": { - "description": "You need to supply a read-only API key from Uptime Robot", - "data": { - "api_key": "API Key" - } - }, "reauth_confirm": { - "title": "Reauthenticate Integration", - "description": "You need to supply a read-only API key from Uptime Robot", "data": { "api_key": "API Key" - } + }, + "description": "You need to supply a new read-only API key from Uptime Robot", + "title": "Reauthenticate Integration" + }, + "user": { + "data": { + "api_key": "API Key" + }, + "description": "You need to supply a read-only API key from Uptime Robot" } } } diff --git a/homeassistant/components/uptimerobot/translations/et.json b/homeassistant/components/uptimerobot/translations/et.json index a0608c5fff6..c679ea3b19b 100644 --- a/homeassistant/components/uptimerobot/translations/et.json +++ b/homeassistant/components/uptimerobot/translations/et.json @@ -2,18 +2,29 @@ "config": { "abort": { "already_configured": "Konto on juba h\u00e4\u00e4lestatud", + "reauth_failed_existing": "Seadekirjet ei \u00f5nnestunud uuendada, eemalda sidumine ja seadista see uuesti.", + "reauth_successful": "Taastuvastamine \u00f5nnestus", "unknown": "Ootamatu t\u00f5rge" }, "error": { "cannot_connect": "\u00dchendamine nurjus", "invalid_api_key": "Vigane API v\u00f5ti", + "reauth_failed_matching_account": "Sisestatud API v\u00f5ti ei vasta olemasoleva konto ID s\u00e4tetele.", "unknown": "Ootamatu t\u00f5rge" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API v\u00f5ti" + }, + "description": "Pead sisestama uue Uptime Roboti kirjutuskaitstud API-v\u00f5tme", + "title": "Taastuvasta sidumine" + }, "user": { "data": { "api_key": "API v\u00f5ti" - } + }, + "description": "Pead sisestama Uptime Roboti kirjutuskaitstud API-v\u00f5tme" } } } diff --git a/homeassistant/components/uptimerobot/translations/ru.json b/homeassistant/components/uptimerobot/translations/ru.json index 60e7e8530d1..88da4b3b768 100644 --- a/homeassistant/components/uptimerobot/translations/ru.json +++ b/homeassistant/components/uptimerobot/translations/ru.json @@ -2,18 +2,29 @@ "config": { "abort": { "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "reauth_failed_existing": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438, \u0443\u0434\u0430\u043b\u0438\u0442\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0435\u0451 \u0441\u043d\u043e\u0432\u0430.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API.", + "reauth_failed_matching_account": "\u0423\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u0443 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u0434\u043b\u044f \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0435\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043d\u043e\u0432\u044b\u0439 \u043a\u043b\u044e\u0447 API Uptime Robot \u0441 \u043f\u0440\u0430\u0432\u0430\u043c\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0447\u0442\u0435\u043d\u0438\u044f", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, "user": { "data": { "api_key": "\u041a\u043b\u044e\u0447 API" - } + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043b\u044e\u0447 API Uptime Robot \u0441 \u043f\u0440\u0430\u0432\u0430\u043c\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0447\u0442\u0435\u043d\u0438\u044f" } } } diff --git a/homeassistant/components/uptimerobot/translations/zh-Hant.json b/homeassistant/components/uptimerobot/translations/zh-Hant.json index c100c6868b9..73d27aac1db 100644 --- a/homeassistant/components/uptimerobot/translations/zh-Hant.json +++ b/homeassistant/components/uptimerobot/translations/zh-Hant.json @@ -2,18 +2,29 @@ "config": { "abort": { "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_failed_existing": "\u7121\u6cd5\u66f4\u65b0\u8a2d\u5b9a\u5be6\u9ad4\uff0c\u8acb\u79fb\u9664\u6574\u5408\u4e26\u91cd\u65b0\u8a2d\u5b9a\u3002", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", "invalid_api_key": "API \u5bc6\u9470\u7121\u6548", + "reauth_failed_matching_account": "\u6240\u63d0\u4f9b\u7684\u5bc6\u9470\u8207\u73fe\u6709\u8a2d\u5b9a\u5e33\u865f ID \u4e0d\u7b26\u3002", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u5bc6\u9470" + }, + "description": "\u9700\u8981\u63d0\u4f9b\u7531 Uptime Robot \u53d6\u5f97\u4e00\u7d44\u65b0\u7684\u552f\u8b80 API \u5bc6\u9470", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "api_key": "API \u5bc6\u9470" - } + }, + "description": "\u9700\u8981\u63d0\u4f9b\u7531 Uptime Robot \u53d6\u5f97\u552f\u8b80 API \u5bc6\u9470" } } } diff --git a/homeassistant/components/weather/translations/ru.json b/homeassistant/components/weather/translations/ru.json index d2d0a066874..1f0458b7653 100644 --- a/homeassistant/components/weather/translations/ru.json +++ b/homeassistant/components/weather/translations/ru.json @@ -6,8 +6,8 @@ "exceptional": "\u041f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435", "fog": "\u0422\u0443\u043c\u0430\u043d", "hail": "\u0413\u0440\u0430\u0434", - "lightning": "\u041c\u043e\u043b\u043d\u0438\u044f", - "lightning-rainy": "\u041c\u043e\u043b\u043d\u0438\u044f, \u0434\u043e\u0436\u0434\u044c", + "lightning": "\u0413\u0440\u043e\u0437\u0430", + "lightning-rainy": "\u0414\u043e\u0436\u0434\u044c \u0441 \u0433\u0440\u043e\u0437\u043e\u0439", "partlycloudy": "\u041f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0441\u0442\u044c", "pouring": "\u041b\u0438\u0432\u0435\u043d\u044c", "rainy": "\u0414\u043e\u0436\u0434\u044c", From 160bd74baefc4038fdad5f6eaeefbc67a42fd73e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 8 Aug 2021 19:24:36 -0700 Subject: [PATCH 229/903] Update DeviceInfo static types (#54276) * Update nest static types from aditional PR feedback Update nest and device helper static types based on post-merge discussion in PR #53475 * Remove unused type: ignore in synology * Remove check for None device type Remove check for None device type in order to reduce untested code as this is a case not allowed by the nest python library. --- homeassistant/components/nest/device_info.py | 8 +++----- homeassistant/components/nest/legacy/__init__.py | 7 ++++--- homeassistant/components/synology_dsm/__init__.py | 14 +++++++------- homeassistant/helpers/entity.py | 10 +++++----- tests/components/nest/device_info_test.py | 4 ++-- tests/components/nest/sensor_sdm_test.py | 2 +- 6 files changed, 22 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 6278547f216..383c6d22258 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -40,7 +40,7 @@ class NestDeviceInfo: ) @property - def device_name(self) -> str: + def device_name(self) -> str | None: """Return the name of the physical device that includes the sensor.""" if InfoTrait.NAME in self._device.traits: trait: InfoTrait = self._device.traits[InfoTrait.NAME] @@ -56,11 +56,9 @@ class NestDeviceInfo: return self.device_model @property - def device_model(self) -> str: + def device_model(self) -> str | None: """Return device model information.""" # The API intentionally returns minimal information about specific # devices, instead relying on traits, but we can infer a generic model # name based on the type - if self._device.type in DEVICE_TYPE_MAP: - return DEVICE_TYPE_MAP[self._device.type] - return "Unknown" + return DEVICE_TYPE_MAP.get(self._device.type) diff --git a/homeassistant/components/nest/legacy/__init__.py b/homeassistant/components/nest/legacy/__init__.py index 04f7b1ac663..76ecf16b67b 100644 --- a/homeassistant/components/nest/legacy/__init__.py +++ b/homeassistant/components/nest/legacy/__init__.py @@ -9,6 +9,7 @@ from nest.nest import APIError, AuthorizationError import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -17,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity @@ -96,7 +97,7 @@ def nest_update_event_broker(hass, nest): _LOGGER.debug("Stop listening for nest.update_event") -async def async_setup_legacy(hass, config) -> bool: +async def async_setup_legacy(hass: HomeAssistant, config: dict) -> bool: """Set up Nest components using the legacy nest API.""" if DOMAIN not in config: return True @@ -122,7 +123,7 @@ async def async_setup_legacy(hass, config) -> bool: return True -async def async_setup_legacy_entry(hass, entry) -> bool: +async def async_setup_legacy_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nest from legacy config entry.""" nest = Nest(access_token=entry.data["tokens"]["access_token"]) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index a9ca7b4c48d..0bc88b683b7 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -686,10 +686,10 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): """Initialize the Synology DSM disk or volume entity.""" super().__init__(api, entity_type, entity_info, coordinator) self._device_id = device_id - self._device_name = None - self._device_manufacturer = None - self._device_model = None - self._device_firmware = None + self._device_name: str | None = None + self._device_manufacturer: str | None = None + self._device_model: str | None = None + self._device_firmware: str | None = None self._device_type = None if "volume" in entity_type: @@ -730,8 +730,8 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): (DOMAIN, f"{self._api.information.serial}_{self._device_id}") }, "name": f"Synology NAS ({self._device_name} - {self._device_type})", - "manufacturer": self._device_manufacturer, # type: ignore[typeddict-item] - "model": self._device_model, # type: ignore[typeddict-item] - "sw_version": self._device_firmware, # type: ignore[typeddict-item] + "manufacturer": self._device_manufacturer, + "model": self._device_model, + "sw_version": self._device_firmware, "via_device": (DOMAIN, self._api.information.serial), } diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 6383de15b4a..131460baa93 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -165,13 +165,13 @@ def get_unit_of_measurement(hass: HomeAssistant, entity_id: str) -> str | None: class DeviceInfo(TypedDict, total=False): """Entity device information for device registry.""" - name: str + name: str | None connections: set[tuple[str, str]] identifiers: set[tuple[str, str]] - manufacturer: str - model: str - suggested_area: str - sw_version: str + manufacturer: str | None + model: str | None + suggested_area: str | None + sw_version: str | None via_device: tuple[str, str] entry_type: str | None default_name: str diff --git a/tests/components/nest/device_info_test.py b/tests/components/nest/device_info_test.py index a0c6973c1d6..90b70f61d15 100644 --- a/tests/components/nest/device_info_test.py +++ b/tests/components/nest/device_info_test.py @@ -93,11 +93,11 @@ def test_device_invalid_type(): device_info = NestDeviceInfo(device) assert device_info.device_name == "My Doorbell" - assert device_info.device_model == "Unknown" + assert device_info.device_model is None assert device_info.device_brand == "Google Nest" assert device_info.device_info == { "identifiers": {("nest", "some-device-id")}, "name": "My Doorbell", "manufacturer": "Google Nest", - "model": "Unknown", + "model": None, } diff --git a/tests/components/nest/sensor_sdm_test.py b/tests/components/nest/sensor_sdm_test.py index cc18e8cd3ae..dfdfd58d546 100644 --- a/tests/components/nest/sensor_sdm_test.py +++ b/tests/components/nest/sensor_sdm_test.py @@ -208,5 +208,5 @@ async def test_device_with_unknown_type(hass): device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My Sensor" - assert device.model == "Unknown" + assert device.model is None assert device.identifiers == {("nest", "some-device-id")} From 5d56ce67f5a9b07c77bce0d1930072a6d4727863 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Aug 2021 22:33:40 -0500 Subject: [PATCH 230/903] Fix inconsistent supported_features return in demo lock (#54300) https://github.com/home-assistant/core/pull/51455#discussion_r684806197 --- homeassistant/components/demo/lock.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index 7eabf9bea2d..af61c0f6111 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -97,3 +97,4 @@ class DemoLock(LockEntity): """Flag supported features.""" if self._openable: return SUPPORT_OPEN + return 0 From 557cc792e90ce5b759833fccd5b73d8e84a30b45 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 8 Aug 2021 20:33:47 -0700 Subject: [PATCH 231/903] Fix SQLAlchemy test warnings (#54116) --- homeassistant/components/history/__init__.py | 2 +- homeassistant/components/recorder/models.py | 3 +-- homeassistant/components/recorder/util.py | 4 +++- tests/components/recorder/test_util.py | 10 ++++++++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 3651dd8295f..a1e0fd45167 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -393,7 +393,7 @@ class Filters: if includes and not excludes: return or_(*includes) - if not excludes and includes: + if not includes and excludes: return not_(or_(*excludes)) return or_(*includes) & not_(or_(*excludes)) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 929115bdf25..ff64deb60cd 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -20,8 +20,7 @@ from sqlalchemy import ( distinct, ) from sqlalchemy.dialects import mysql -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.const import ( diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 225eee6867f..e3af39b217a 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -10,6 +10,7 @@ import os import time from typing import TYPE_CHECKING, Callable +from sqlalchemy import text from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.session import Session @@ -332,4 +333,5 @@ def perodic_db_cleanups(instance: Recorder): if instance.engine.dialect.name == "sqlite": # Execute sqlite to create a wal checkpoint and free up disk space _LOGGER.debug("WAL checkpoint") - instance.engine.execute("PRAGMA wal_checkpoint(TRUNCATE);") + with instance.engine.connect() as connection: + connection.execute(text("PRAGMA wal_checkpoint(TRUNCATE);")) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 5b4b234fbbb..cb54f0404b9 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from sqlalchemy import text +from sqlalchemy.sql.elements import TextClause from homeassistant.components.recorder import run_information_with_session, util from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX @@ -253,6 +254,11 @@ def test_end_incomplete_runs(hass_recorder, caplog): def test_perodic_db_cleanups(hass_recorder): """Test perodic db cleanups.""" hass = hass_recorder() - with patch.object(hass.data[DATA_INSTANCE].engine, "execute") as execute_mock: + with patch.object(hass.data[DATA_INSTANCE].engine, "connect") as connect_mock: util.perodic_db_cleanups(hass.data[DATA_INSTANCE]) - assert execute_mock.call_args[0][0] == "PRAGMA wal_checkpoint(TRUNCATE);" + + text_obj = connect_mock.return_value.__enter__.return_value.execute.mock_calls[0][ + 1 + ][0] + assert isinstance(text_obj, TextClause) + assert str(text_obj) == "PRAGMA wal_checkpoint(TRUNCATE);" From 2e903c92c475d217240311492e75077d28504972 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 9 Aug 2021 00:41:51 -0400 Subject: [PATCH 232/903] Add siren support for available tones provided as a dict (#54198) * Add siren support for available tones provided as a dict * remove paranthesis * hopefully make logic even easier to read * drop last parenthesis --- homeassistant/components/siren/__init__.py | 31 +++++++++++++++++----- tests/components/siren/test_init.py | 27 +++++++++++++++++-- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index f301100fa6c..ed0e8b8645f 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -64,10 +64,29 @@ def process_turn_on_params( if not supported_features & SUPPORT_TONES: params.pop(ATTR_TONE, None) - elif (tone := params.get(ATTR_TONE)) is not None and ( - not siren.available_tones or tone not in siren.available_tones - ): - raise ValueError(f"Invalid tone received for entity {siren.entity_id}: {tone}") + elif (tone := params.get(ATTR_TONE)) is not None: + # Raise an exception if the specified tone isn't available + is_tone_dict_value = bool( + 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 + ): + raise ValueError( + f"Invalid tone specified for entity {siren.entity_id}: {tone}, " + "check the available_tones attribute for valid tones to pass in" + ) + + # If available tones is a dict, and the tone provided is a dict value, we need + # to transform it to the corresponding dict key before returning + if is_tone_dict_value: + assert isinstance(siren.available_tones, dict) + params[ATTR_TONE] = next( + key for key, value in siren.available_tones.items() if value == tone + ) if not supported_features & SUPPORT_DURATION: params.pop(ATTR_DURATION, None) @@ -131,7 +150,7 @@ class SirenEntity(ToggleEntity): """Representation of a siren device.""" entity_description: SirenEntityDescription - _attr_available_tones: list[int | str] | None = None + _attr_available_tones: list[int | str] | dict[int, str] | None = None @final @property @@ -145,7 +164,7 @@ class SirenEntity(ToggleEntity): return None @property - def available_tones(self) -> list[int | str] | None: + def available_tones(self) -> list[int | str] | dict[int, str] | None: """ Return a list of available tones. diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py index 729990ceaeb..e46fbbf8d5e 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -48,9 +48,32 @@ async def test_no_available_tones(hass): process_turn_on_params(siren, {"tone": "test"}) -async def test_missing_tones(hass): - """Test ValueError when setting a tone that is missing from available_tones.""" +async def test_available_tones_list(hass): + """Test that valid tones from tone list will get passed in.""" + siren = MockSirenEntity(SUPPORT_TONES, ["a", "b"]) + siren.hass = hass + assert process_turn_on_params(siren, {"tone": "a"}) == {"tone": "a"} + + +async def test_available_tones_dict(hass): + """Test that valid tones from available_tones dict will get passed in.""" + siren = MockSirenEntity(SUPPORT_TONES, {1: "a", 2: "b"}) + siren.hass = hass + assert process_turn_on_params(siren, {"tone": "a"}) == {"tone": 1} + assert process_turn_on_params(siren, {"tone": 1}) == {"tone": 1} + + +async def test_missing_tones_list(hass): + """Test ValueError when setting a tone that is missing from available_tones list.""" siren = MockSirenEntity(SUPPORT_TONES, ["a", "b"]) siren.hass = hass with pytest.raises(ValueError): process_turn_on_params(siren, {"tone": "test"}) + + +async def test_missing_tones_dict(hass): + """Test ValueError when setting a tone that is missing from available_tones dict.""" + siren = MockSirenEntity(SUPPORT_TONES, {1: "a", 2: "b"}) + siren.hass = hass + with pytest.raises(ValueError): + process_turn_on_params(siren, {"tone": 3}) From 0f68ebe92b4fc6372d8d380a55aafe4df5600285 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 9 Aug 2021 08:15:39 +0200 Subject: [PATCH 233/903] Add `unique_id` and `device_info` for SMS sensor (#54257) --- homeassistant/components/sms/sensor.py | 61 ++++++++++---------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index fc2310426e3..eb6c6ab22e1 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -3,7 +3,7 @@ import logging import gammu # pylint: disable=import-error -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import DEVICE_CLASS_SIGNAL_STRENGTH, SIGNAL_STRENGTH_DECIBELS from .const import DOMAIN, SMS_GATEWAY @@ -14,48 +14,40 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the GSM Signal Sensor sensor.""" gateway = hass.data[DOMAIN][SMS_GATEWAY] - entities = [] imei = await gateway.get_imei_async() - name = f"gsm_signal_imei_{imei}" - entities.append( - GSMSignalSensor( - hass, - gateway, - name, - ) + async_add_entities( + [ + GSMSignalSensor( + hass, + gateway, + imei, + SensorEntityDescription( + key="signal", + name=f"gsm_signal_imei_{imei}", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_registry_enabled_default=False, + ), + ) + ], + True, ) - async_add_entities(entities, True) class GSMSignalSensor(SensorEntity): """Implementation of a GSM Signal sensor.""" - def __init__( - self, - hass, - gateway, - name, - ): + def __init__(self, hass, gateway, imei, description): """Initialize the GSM Signal sensor.""" + self._attr_device_info = { + "identifiers": {(DOMAIN, str(imei))}, + "name": "SMS Gateway", + } + self._attr_unique_id = str(imei) self._hass = hass self._gateway = gateway - self._name = name self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return SIGNAL_STRENGTH_DECIBELS - - @property - def device_class(self): - """Return the class of this sensor.""" - return DEVICE_CLASS_SIGNAL_STRENGTH + self.entity_description = description @property def available(self): @@ -78,8 +70,3 @@ class GSMSignalSensor(SensorEntity): def extra_state_attributes(self): """Return the sensor attributes.""" return self._state - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False From a8354e729bf3c86115753fcd1e4e07109891c095 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 9 Aug 2021 02:21:07 -0500 Subject: [PATCH 234/903] Bump soco to 0.23.3 (#54288) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 4ce5623ac38..d9c2a2cc6c9 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -3,7 +3,7 @@ "name": "Sonos", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonos", - "requirements": ["soco==0.23.2"], + "requirements": ["soco==0.23.3"], "dependencies": ["ssdp"], "after_dependencies": ["plex", "zeroconf"], "zeroconf": ["_sonos._tcp.local."], diff --git a/requirements_all.txt b/requirements_all.txt index a6695806dee..0f2e0fdf1c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2152,7 +2152,7 @@ smhi-pkg==1.0.15 snapcast==2.1.3 # homeassistant.components.sonos -soco==0.23.2 +soco==0.23.3 # homeassistant.components.solaredge_local solaredge-local==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e490b65514..5d224c4112d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1183,7 +1183,7 @@ smarthab==0.21 smhi-pkg==1.0.15 # homeassistant.components.sonos -soco==0.23.2 +soco==0.23.3 # homeassistant.components.solaredge solaredge==0.0.2 From 952d11cb0390852656206cd164db7099ea8e22ee Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Aug 2021 00:38:09 -0700 Subject: [PATCH 235/903] Ensure internal/external URL have no path (#54304) * Ensure internal/external URL have no path * Fix comment typo Co-authored-by: Martin Hjelmare --- homeassistant/components/config/core.py | 4 +- homeassistant/config.py | 125 ++++++++++++--------- homeassistant/core.py | 43 ++++--- homeassistant/helpers/config_validation.py | 10 ++ tests/helpers/test_config_validation.py | 19 ++++ tests/test_config.py | 13 +++ tests/test_core.py | 15 +++ 7 files changed, 161 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index d9029dc497f..a6b39e556aa 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -44,8 +44,8 @@ class CheckConfigView(HomeAssistantView): vol.Optional("unit_system"): cv.unit_system, vol.Optional("location_name"): str, vol.Optional("time_zone"): cv.time_zone, - vol.Optional("external_url"): vol.Any(cv.url, None), - vol.Optional("internal_url"): vol.Any(cv.url, None), + vol.Optional("external_url"): vol.Any(cv.url_no_path, None), + vol.Optional("internal_url"): vol.Any(cv.url_no_path, None), vol.Optional("currency"): cv.currency, } ) diff --git a/homeassistant/config.py b/homeassistant/config.py index 12a39ab291b..cd159dfc8ce 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -10,6 +10,7 @@ import re import shutil from types import ModuleType from typing import Any, Callable +from urllib.parse import urlparse from awesomeversion import AwesomeVersion import voluptuous as vol @@ -161,6 +162,19 @@ def _no_duplicate_auth_mfa_module( return configs +def _filter_bad_internal_external_urls(conf: dict) -> dict: + """Filter internal/external URL with a path.""" + for key in CONF_INTERNAL_URL, CONF_EXTERNAL_URL: + if key in conf and urlparse(conf[key]).path not in ("", "/"): + # We warn but do not fix, because if this was incorrectly configured, + # adjusting this value might impact security. + _LOGGER.warning( + "Invalid %s set. It's not allowed to have a path (/bla)", key + ) + + return conf + + PACKAGES_CONFIG_SCHEMA = cv.schema_with_slug_keys( # Package names are slugs vol.Schema({cv.string: vol.Any(dict, list, None)}) # Component config ) @@ -188,59 +202,64 @@ CUSTOMIZE_CONFIG_SCHEMA = vol.Schema( } ) -CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend( - { - CONF_NAME: vol.Coerce(str), - CONF_LATITUDE: cv.latitude, - CONF_LONGITUDE: cv.longitude, - CONF_ELEVATION: vol.Coerce(int), - vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, - CONF_UNIT_SYSTEM: cv.unit_system, - CONF_TIME_ZONE: cv.time_zone, - vol.Optional(CONF_INTERNAL_URL): cv.url, - vol.Optional(CONF_EXTERNAL_URL): cv.url, - vol.Optional(CONF_ALLOWLIST_EXTERNAL_DIRS): vol.All( - cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter - ), - vol.Optional(LEGACY_CONF_WHITELIST_EXTERNAL_DIRS): vol.All( - cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter - ), - vol.Optional(CONF_ALLOWLIST_EXTERNAL_URLS): vol.All(cv.ensure_list, [cv.url]), - vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, - vol.Optional(CONF_AUTH_PROVIDERS): vol.All( - cv.ensure_list, - [ - auth_providers.AUTH_PROVIDER_SCHEMA.extend( - { - CONF_TYPE: vol.NotIn( - ["insecure_example"], - "The insecure_example auth provider" - " is for testing only.", - ) - } - ) - ], - _no_duplicate_auth_provider, - ), - vol.Optional(CONF_AUTH_MFA_MODULES): vol.All( - cv.ensure_list, - [ - auth_mfa_modules.MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend( - { - CONF_TYPE: vol.NotIn( - ["insecure_example"], - "The insecure_example mfa module is for testing only.", - ) - } - ) - ], - _no_duplicate_auth_mfa_module, - ), - # pylint: disable=no-value-for-parameter - vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()), - vol.Optional(CONF_LEGACY_TEMPLATES): cv.boolean, - vol.Optional(CONF_CURRENCY): cv.currency, - } +CORE_CONFIG_SCHEMA = vol.All( + CUSTOMIZE_CONFIG_SCHEMA.extend( + { + CONF_NAME: vol.Coerce(str), + CONF_LATITUDE: cv.latitude, + CONF_LONGITUDE: cv.longitude, + CONF_ELEVATION: vol.Coerce(int), + vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, + CONF_UNIT_SYSTEM: cv.unit_system, + CONF_TIME_ZONE: cv.time_zone, + vol.Optional(CONF_INTERNAL_URL): cv.url, + vol.Optional(CONF_EXTERNAL_URL): cv.url, + vol.Optional(CONF_ALLOWLIST_EXTERNAL_DIRS): vol.All( + cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter + ), + vol.Optional(LEGACY_CONF_WHITELIST_EXTERNAL_DIRS): vol.All( + cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter + ), + vol.Optional(CONF_ALLOWLIST_EXTERNAL_URLS): vol.All( + cv.ensure_list, [cv.url] + ), + vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, + vol.Optional(CONF_AUTH_PROVIDERS): vol.All( + cv.ensure_list, + [ + auth_providers.AUTH_PROVIDER_SCHEMA.extend( + { + CONF_TYPE: vol.NotIn( + ["insecure_example"], + "The insecure_example auth provider" + " is for testing only.", + ) + } + ) + ], + _no_duplicate_auth_provider, + ), + vol.Optional(CONF_AUTH_MFA_MODULES): vol.All( + cv.ensure_list, + [ + auth_mfa_modules.MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend( + { + CONF_TYPE: vol.NotIn( + ["insecure_example"], + "The insecure_example mfa module is for testing only.", + ) + } + ) + ], + _no_duplicate_auth_mfa_module, + ), + # pylint: disable=no-value-for-parameter + vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()), + vol.Optional(CONF_LEGACY_TEMPLATES): cv.boolean, + vol.Optional(CONF_CURRENCY): cv.currency, + } + ), + _filter_bad_internal_external_urls, ) diff --git a/homeassistant/core.py b/homeassistant/core.py index e2418321592..d95c25d93e2 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -19,6 +19,7 @@ import threading from time import monotonic from types import MappingProxyType from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast +from urllib.parse import urlparse import attr import voluptuous as vol @@ -1717,19 +1718,35 @@ class Config: ) data = await store.async_load() - if data: - self._update( - source=SOURCE_STORAGE, - latitude=data.get("latitude"), - longitude=data.get("longitude"), - elevation=data.get("elevation"), - unit_system=data.get("unit_system"), - location_name=data.get("location_name"), - time_zone=data.get("time_zone"), - external_url=data.get("external_url", _UNDEF), - internal_url=data.get("internal_url", _UNDEF), - currency=data.get("currency"), - ) + if not data: + return + + # In 2021.9 we fixed validation to disallow a path (because that's never correct) + # but this data still lives in storage, so we print a warning. + if "external_url" in data and urlparse(data["external_url"]).path not in ( + "", + "/", + ): + _LOGGER.warning("Invalid external_url set. It's not allowed to have a path") + + if "internal_url" in data and urlparse(data["internal_url"]).path not in ( + "", + "/", + ): + _LOGGER.warning("Invalid internal_url set. It's not allowed to have a path") + + self._update( + source=SOURCE_STORAGE, + latitude=data.get("latitude"), + longitude=data.get("longitude"), + elevation=data.get("elevation"), + unit_system=data.get("unit_system"), + location_name=data.get("location_name"), + time_zone=data.get("time_zone"), + external_url=data.get("external_url", _UNDEF), + internal_url=data.get("internal_url", _UNDEF), + currency=data.get("currency"), + ) async def async_store(self) -> None: """Store [homeassistant] core config.""" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 66d1c01d6d3..f8d69a6e49a 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -649,6 +649,16 @@ def url(value: Any) -> str: raise vol.Invalid("invalid url") +def url_no_path(value: Any) -> str: + """Validate a url without a path.""" + url_in = url(value) + + if urlparse(url_in).path not in ("", "/"): + raise vol.Invalid("url it not allowed to have a path component") + + return url_in + + def x10_address(value: str) -> str: """Validate an x10 address.""" regex = re.compile(r"([A-Pa-p]{1})(?:[2-9]|1[0-6]?)$") diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index c5e9f5880c4..79b558e5083 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -120,6 +120,25 @@ def test_url(): assert schema(value) +def test_url_no_path(): + """Test URL.""" + schema = vol.Schema(cv.url_no_path) + + for value in ( + "https://localhost/test/index.html", + "http://home-assistant.io/test/", + ): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ( + "http://localhost", + "http://home-assistant.io", + "https://community.home-assistant.io/", + ): + assert schema(value) + + def test_platform_config(): """Test platform config validation.""" options = ({}, {"hello": "world"}) diff --git a/tests/test_config.py b/tests/test_config.py index 96196c943aa..441029d27dc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -215,6 +215,19 @@ def test_core_config_schema(): ) +def test_core_config_schema_internal_external_warning(caplog): + """Test that we warn for internal/external URL with path.""" + config_util.CORE_CONFIG_SCHEMA( + { + "external_url": "https://www.example.com/bla", + "internal_url": "http://example.local/yo", + } + ) + + assert "Invalid external_url set" in caplog.text + assert "Invalid internal_url set" in caplog.text + + def test_customize_dict_schema(): """Test basic customize config validation.""" values = ({ATTR_FRIENDLY_NAME: None}, {ATTR_ASSUMED_STATE: "2"}) diff --git a/tests/test_core.py b/tests/test_core.py index 77ec07e6a63..df66fedd025 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1374,6 +1374,21 @@ async def test_additional_data_in_core_config(hass, hass_storage): assert config.location_name == "Test Name" +async def test_incorrect_internal_external_url(hass, hass_storage, caplog): + """Test that we warn when detecting invalid internal/extenral url.""" + config = ha.Config(hass) + hass_storage[ha.CORE_STORAGE_KEY] = { + "version": 1, + "data": { + "internal_url": "https://community.home-assistant.io/profile", + "external_url": "https://www.home-assistant.io/blue", + }, + } + await config.async_load() + assert "Invalid external_url set" in caplog.text + assert "Invalid internal_url set" in caplog.text + + async def test_start_events(hass): """Test events fired when starting Home Assistant.""" hass.state = ha.CoreState.not_running From a1abd4f0d61b42191a71c0a44d7ce4064aff718a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 9 Aug 2021 10:52:14 +0200 Subject: [PATCH 236/903] Fix external internal url core check (#54310) --- homeassistant/core.py | 4 ++-- tests/test_core.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index d95c25d93e2..1b1849ba548 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1723,13 +1723,13 @@ class Config: # In 2021.9 we fixed validation to disallow a path (because that's never correct) # but this data still lives in storage, so we print a warning. - if "external_url" in data and urlparse(data["external_url"]).path not in ( + if data.get("external_url") and urlparse(data["external_url"]).path not in ( "", "/", ): _LOGGER.warning("Invalid external_url set. It's not allowed to have a path") - if "internal_url" in data and urlparse(data["internal_url"]).path not in ( + if data.get("internal_url") and urlparse(data["internal_url"]).path not in ( "", "/", ): diff --git a/tests/test_core.py b/tests/test_core.py index df66fedd025..641a5e0dfda 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1377,6 +1377,18 @@ async def test_additional_data_in_core_config(hass, hass_storage): async def test_incorrect_internal_external_url(hass, hass_storage, caplog): """Test that we warn when detecting invalid internal/extenral url.""" config = ha.Config(hass) + + hass_storage[ha.CORE_STORAGE_KEY] = { + "version": 1, + "data": { + "internal_url": None, + "external_url": None, + }, + } + await config.async_load() + assert "Invalid external_url set" not in caplog.text + assert "Invalid internal_url set" not in caplog.text + hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, "data": { From d97f93933f28784f0a5e962f9784f42f6f4af568 Mon Sep 17 00:00:00 2001 From: ZeGuigui Date: Mon, 9 Aug 2021 11:38:16 +0200 Subject: [PATCH 237/903] Fix atom integration for long term statistics (#54285) * Fix atom integration for long term statistics * Remove commented code * Fix last_reset syntax * last_reset not an extra attribute * last_reset as utc * black formatting * isort fix --- homeassistant/components/atome/sensor.py | 40 +++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py index bcb7b4f1ece..7295a9cee41 100644 --- a/homeassistant/components/atome/sensor.py +++ b/homeassistant/components/atome/sensor.py @@ -14,12 +14,13 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_USERNAME, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, POWER_WATT, ) import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -87,12 +88,16 @@ class AtomeData: self._is_connected = None self._day_usage = None self._day_price = None + self._day_last_reset = None self._week_usage = None self._week_price = None + self._week_last_reset = None self._month_usage = None self._month_price = None + self._month_last_reset = None self._year_usage = None self._year_price = None + self._year_last_reset = None @property def live_power(self): @@ -137,6 +142,11 @@ class AtomeData: """Return latest daily usage value.""" return self._day_price + @property + def day_last_reset(self): + """Return latest daily last reset.""" + return self._day_last_reset + @Throttle(DAILY_SCAN_INTERVAL) def update_day_usage(self): """Return current daily power usage.""" @@ -144,6 +154,7 @@ class AtomeData: values = self.atome_client.get_consumption(DAILY_TYPE) self._day_usage = values["total"] / 1000 self._day_price = values["price"] + self._day_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome daily data. Got: %d", self._day_usage) except KeyError as error: @@ -159,6 +170,11 @@ class AtomeData: """Return latest weekly usage value.""" return self._week_price + @property + def week_last_reset(self): + """Return latest weekly last reset value.""" + return self._week_last_reset + @Throttle(WEEKLY_SCAN_INTERVAL) def update_week_usage(self): """Return current weekly power usage.""" @@ -166,6 +182,7 @@ class AtomeData: values = self.atome_client.get_consumption(WEEKLY_TYPE) self._week_usage = values["total"] / 1000 self._week_price = values["price"] + self._week_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome weekly data. Got: %d", self._week_usage) except KeyError as error: @@ -181,6 +198,11 @@ class AtomeData: """Return latest monthly usage value.""" return self._month_price + @property + def month_last_reset(self): + """Return latest monthly last reset value.""" + return self._month_last_reset + @Throttle(MONTHLY_SCAN_INTERVAL) def update_month_usage(self): """Return current monthly power usage.""" @@ -188,6 +210,7 @@ class AtomeData: values = self.atome_client.get_consumption(MONTHLY_TYPE) self._month_usage = values["total"] / 1000 self._month_price = values["price"] + self._month_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome monthly data. Got: %d", self._month_usage) except KeyError as error: @@ -203,6 +226,11 @@ class AtomeData: """Return latest yearly usage value.""" return self._year_price + @property + def year_last_reset(self): + """Return latest yearly last reset value.""" + return self._year_last_reset + @Throttle(YEARLY_SCAN_INTERVAL) def update_year_usage(self): """Return current yearly power usage.""" @@ -210,6 +238,7 @@ class AtomeData: values = self.atome_client.get_consumption(YEARLY_TYPE) self._year_usage = values["total"] / 1000 self._year_price = values["price"] + self._year_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome yearly data. Got: %d", self._year_usage) except KeyError as error: @@ -219,19 +248,19 @@ class AtomeData: class AtomeSensor(SensorEntity): """Representation of a sensor entity for Atome.""" - _attr_device_class = DEVICE_CLASS_POWER - def __init__(self, data, name, sensor_type): """Initialize the sensor.""" self._attr_name = name self._data = data self._sensor_type = sensor_type + self._attr_state_class = STATE_CLASS_MEASUREMENT if sensor_type == LIVE_TYPE: + self._attr_device_class = DEVICE_CLASS_POWER self._attr_unit_of_measurement = POWER_WATT - self._attr_state_class = STATE_CLASS_MEASUREMENT else: + self._attr_device_class = DEVICE_CLASS_ENERGY self._attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR def update(self): @@ -247,6 +276,9 @@ class AtomeSensor(SensorEntity): } else: self._attr_state = getattr(self._data, f"{self._sensor_type}_usage") + self._attr_last_reset = dt_util.as_utc( + getattr(self._data, f"{self._sensor_type}_last_reset") + ) self._attr_extra_state_attributes = { "price": getattr(self._data, f"{self._sensor_type}_price") } From e7f0768ae67f71d5a8a2e5dad57434bb105d6f44 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 9 Aug 2021 12:11:54 +0200 Subject: [PATCH 238/903] Convert base_config_test in modbus to existing Pytest.fixture (#53836) * Convert base_config_test to pytest.fixture. --- tests/components/modbus/conftest.py | 49 +++------- tests/components/modbus/test_sensor.py | 121 +++++++++++++------------ 2 files changed, 77 insertions(+), 93 deletions(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index db960f448ff..86eff5e44ad 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -39,9 +39,15 @@ def mock_pymodbus(): yield mock_pb -@pytest.fixture -async def mock_modbus(hass, do_config): +@pytest.fixture( + params=[ + {"testLoad": True}, + ], +) +async def mock_modbus(hass, caplog, request, do_config): """Load integration modbus using mocked pymodbus.""" + + caplog.set_level(logging.WARNING) config = { DOMAIN: [ { @@ -56,7 +62,10 @@ async def mock_modbus(hass, do_config): with mock.patch( "homeassistant.components.modbus.modbus.ModbusTcpClient", autospec=True ) as mock_pb: - assert await async_setup_component(hass, DOMAIN, config) is True + if request.param["testLoad"]: + assert await async_setup_component(hass, DOMAIN, config) is True + else: + await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield mock_pb @@ -88,7 +97,6 @@ async def base_test( register_words, expected, method_discovery=False, - check_config_only=False, config_modbus=None, scan_interval=None, expect_init_to_fail=False, @@ -173,8 +181,6 @@ async def base_test( assert device is None elif device is None: pytest.fail("CONFIG failed, see output") - if check_config_only: - return # Trigger update call with time_changed event now = now + timedelta(seconds=scan_interval + 60) @@ -187,37 +193,6 @@ async def base_test( return hass.states.get(entity_id).state -async def base_config_test( - hass, - config_device, - device_name, - entity_domain, - array_name_discovery, - array_name_old_config, - method_discovery=False, - config_modbus=None, - expect_init_to_fail=False, - expect_setup_to_fail=False, -): - """Check config of device for given config.""" - - await base_test( - hass, - config_device, - device_name, - entity_domain, - array_name_discovery, - array_name_old_config, - None, - None, - method_discovery=method_discovery, - check_config_only=True, - config_modbus=config_modbus, - expect_init_to_fail=expect_init_to_fail, - expect_setup_to_fail=expect_setup_to_fail, - ) - - async def prepare_service_update(hass, config): """Run test for service write_coil.""" diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index f01a3ef9da5..a5ec79d62e4 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,6 +1,4 @@ """The tests for the Modbus sensor component.""" -import logging - import pytest from homeassistant.components.modbus.const import ( @@ -37,7 +35,7 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_config_test, base_test, prepare_service_update +from .conftest import ReadResult, base_test, prepare_service_update SENSOR_NAME = "test_sensor" ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" @@ -133,95 +131,106 @@ async def test_config_sensor(hass, mock_modbus): assert SENSOR_DOMAIN in hass.config.components +@pytest.mark.parametrize("mock_modbus", [{"testLoad": False}], indirect=True) @pytest.mark.parametrize( "do_config,error_message", [ ( { - CONF_ADDRESS: 1234, - CONF_COUNT: 8, - CONF_PRECISION: 2, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_STRUCTURE: ">no struct", + CONF_SENSORS: [ + { + CONF_NAME: SENSOR_NAME, + CONF_ADDRESS: 1234, + CONF_COUNT: 8, + CONF_PRECISION: 2, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_STRUCTURE: ">no struct", + }, + ] }, "bad char in struct format", ), ( { - CONF_ADDRESS: 1234, - CONF_COUNT: 2, - CONF_PRECISION: 2, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_STRUCTURE: ">4f", + CONF_SENSORS: [ + { + CONF_NAME: SENSOR_NAME, + CONF_ADDRESS: 1234, + CONF_COUNT: 2, + CONF_PRECISION: 2, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_STRUCTURE: ">4f", + }, + ] }, "Structure request 16 bytes, but 2 registers have a size of 4 bytes", ), ( { - CONF_ADDRESS: 1234, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, - CONF_STRUCTURE: "invalid", + CONF_SENSORS: [ + { + CONF_NAME: SENSOR_NAME, + CONF_ADDRESS: 1234, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_COUNT: 4, + CONF_SWAP: CONF_SWAP_NONE, + CONF_STRUCTURE: "invalid", + }, + ] }, "bad char in struct format", ), ( { - CONF_ADDRESS: 1234, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, - CONF_STRUCTURE: "", + CONF_SENSORS: [ + { + CONF_NAME: SENSOR_NAME, + CONF_ADDRESS: 1234, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_COUNT: 4, + CONF_SWAP: CONF_SWAP_NONE, + CONF_STRUCTURE: "", + }, + ] }, "Error in sensor test_sensor. The `structure` field can not be empty", ), ( { - CONF_ADDRESS: 1234, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, - CONF_STRUCTURE: "1s", + CONF_SENSORS: [ + { + CONF_NAME: SENSOR_NAME, + CONF_ADDRESS: 1234, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_COUNT: 4, + CONF_SWAP: CONF_SWAP_NONE, + CONF_STRUCTURE: "1s", + }, + ] }, "Structure request 1 bytes, but 4 registers have a size of 8 bytes", ), ( { - CONF_ADDRESS: 1234, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_COUNT: 1, - CONF_STRUCTURE: "2s", - CONF_SWAP: CONF_SWAP_WORD, + CONF_SENSORS: [ + { + CONF_NAME: SENSOR_NAME, + CONF_ADDRESS: 1234, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_COUNT: 1, + CONF_STRUCTURE: "2s", + CONF_SWAP: CONF_SWAP_WORD, + }, + ] }, "Error in sensor test_sensor swap(word) not possible due to the registers count: 1, needed: 2", ), ], ) -async def test_config_wrong_struct_sensor( - hass, caplog, do_config, error_message, mock_pymodbus -): +async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, caplog): """Run test for sensor with wrong struct.""" - - config_sensor = { - CONF_NAME: SENSOR_NAME, - **do_config, - } - caplog.set_level(logging.WARNING) - caplog.clear() - - await base_config_test( - hass, - config_sensor, - SENSOR_NAME, - SENSOR_DOMAIN, - CONF_SENSORS, - None, - method_discovery=True, - expect_setup_to_fail=True, - ) - - assert caplog.text.count(error_message) + messages = str([x.message for x in caplog.get_records("setup")]) + assert error_message in messages @pytest.mark.parametrize( From 6ea50823c19227a99ee5d13d8bef8b0487b8a1fb Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 9 Aug 2021 12:16:35 +0200 Subject: [PATCH 239/903] Use SensorEntityDescription for arlo (#54223) * Use SensorEntityDescription. --- homeassistant/components/arlo/sensor.py | 125 +++++++++++++----------- tests/components/arlo/test_sensor.py | 7 +- 2 files changed, 75 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index c794bf1ef5e..883fd011c52 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -1,13 +1,19 @@ """Sensor support for Netgear Arlo IP cameras.""" +from dataclasses import replace import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONCENTRATION_PARTS_PER_MILLION, CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, @@ -22,17 +28,43 @@ from . import ATTRIBUTION, DATA_ARLO, DEFAULT_BRAND, SIGNAL_UPDATE_ARLO _LOGGER = logging.getLogger(__name__) -# sensor_type [ description, unit, icon ] -SENSOR_TYPES = { - "last_capture": ["Last", None, "run-fast"], - "total_cameras": ["Arlo Cameras", None, "video"], - "captured_today": ["Captured Today", None, "file-video"], - "battery_level": ["Battery Level", PERCENTAGE, "battery-50"], - "signal_strength": ["Signal Strength", None, "signal"], - "temperature": ["Temperature", TEMP_CELSIUS, "thermometer"], - "humidity": ["Humidity", PERCENTAGE, "water-percent"], - "air_quality": ["Air Quality", CONCENTRATION_PARTS_PER_MILLION, "biohazard"], -} +SENSOR_TYPES = ( + SensorEntityDescription(key="last_capture", name="Last", icon="mdi:run-fast"), + SensorEntityDescription(key="total_cameras", name="Arlo Cameras", icon="mdi:video"), + SensorEntityDescription( + key="captured_today", name="Captured Today", icon="mdi:file-video" + ), + SensorEntityDescription( + key="battery_level", + name="Battery Level", + unit_of_measurement=PERCENTAGE, + icon="mdi:battery-50", + device_class=DEVICE_CLASS_BATTERY, + ), + SensorEntityDescription( + key="signal_strength", name="Signal Strength", icon="mdi:signal" + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key="air_quality", + name="Air Quality", + unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon="mdi:biohazard", + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -50,24 +82,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return sensors = [] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - if sensor_type == "total_cameras": - sensors.append(ArloSensor(SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) + for sensor_original in SENSOR_TYPES: + if sensor_original.key not in config[CONF_MONITORED_CONDITIONS]: + continue + sensor_entry = replace(sensor_original) + if sensor_entry.key == "total_cameras": + sensors.append(ArloSensor(arlo, sensor_entry)) else: for camera in arlo.cameras: - if sensor_type in ("temperature", "humidity", "air_quality"): + if sensor_entry.key in ("temperature", "humidity", "air_quality"): continue - name = f"{SENSOR_TYPES[sensor_type][0]} {camera.name}" - sensors.append(ArloSensor(name, camera, sensor_type)) + sensor_entry.name = f"{sensor_entry.name} {camera.name}" + sensors.append(ArloSensor(camera, sensor_entry)) for base_station in arlo.base_stations: if ( - sensor_type in ("temperature", "humidity", "air_quality") + sensor_entry.key in ("temperature", "humidity", "air_quality") and base_station.model_id == "ABC1000" ): - name = f"{SENSOR_TYPES[sensor_type][0]} {base_station.name}" - sensors.append(ArloSensor(name, base_station, sensor_type)) + sensor_entry.name = f"{sensor_entry.name} {base_station.name}" + sensors.append(ArloSensor(base_station, sensor_entry)) add_entities(sensors, True) @@ -75,19 +110,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ArloSensor(SensorEntity): """An implementation of a Netgear Arlo IP sensor.""" - def __init__(self, name, device, sensor_type): + def __init__(self, device, sensor_entry): """Initialize an Arlo sensor.""" - _LOGGER.debug("ArloSensor created for %s", name) - self._name = name + self.entity_description = sensor_entry self._data = device - self._sensor_type = sensor_type self._state = None - self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}" - - @property - def name(self): - """Return the name of this camera.""" - return self._name async def async_added_to_hass(self): """Register callbacks.""" @@ -110,36 +137,22 @@ class ArloSensor(SensorEntity): @property def icon(self): """Icon to use in the frontend, if any.""" - if self._sensor_type == "battery_level" and self._state is not None: + if self.entity_description.key == "battery_level" and self._state is not None: return icon_for_battery_level( battery_level=int(self._state), charging=False ) - return self._icon - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return SENSOR_TYPES.get(self._sensor_type)[1] - - @property - def device_class(self): - """Return the device class of the sensor.""" - if self._sensor_type == "temperature": - return DEVICE_CLASS_TEMPERATURE - if self._sensor_type == "humidity": - return DEVICE_CLASS_HUMIDITY - return None + return self.entity_description.icon def update(self): """Get the latest data and updates the state.""" _LOGGER.debug("Updating Arlo sensor %s", self.name) - if self._sensor_type == "total_cameras": + if self.entity_description.key == "total_cameras": self._state = len(self._data.cameras) - elif self._sensor_type == "captured_today": + elif self.entity_description.key == "captured_today": self._state = len(self._data.captured_today) - elif self._sensor_type == "last_capture": + elif self.entity_description.key == "last_capture": try: video = self._data.last_video self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") @@ -151,31 +164,31 @@ class ArloSensor(SensorEntity): _LOGGER.debug(error_msg) self._state = None - elif self._sensor_type == "battery_level": + elif self.entity_description.key == "battery_level": try: self._state = self._data.battery_level except TypeError: self._state = None - elif self._sensor_type == "signal_strength": + elif self.entity_description.key == "signal_strength": try: self._state = self._data.signal_strength except TypeError: self._state = None - elif self._sensor_type == "temperature": + elif self.entity_description.key == "temperature": try: self._state = self._data.ambient_temperature except TypeError: self._state = None - elif self._sensor_type == "humidity": + elif self.entity_description.key == "humidity": try: self._state = self._data.ambient_humidity except TypeError: self._state = None - elif self._sensor_type == "air_quality": + elif self.entity_description.key == "air_quality": try: self._state = self._data.ambient_air_quality except TypeError: @@ -189,7 +202,7 @@ class ArloSensor(SensorEntity): attrs[ATTR_ATTRIBUTION] = ATTRIBUTION attrs["brand"] = DEFAULT_BRAND - if self._sensor_type != "total_cameras": + if self.entity_description.key != "total_cameras": attrs["model"] = self._data.model_id return attrs diff --git a/tests/components/arlo/test_sensor.py b/tests/components/arlo/test_sensor.py index b8389d1903f..34d5088397d 100644 --- a/tests/components/arlo/test_sensor.py +++ b/tests/components/arlo/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components.arlo import DATA_ARLO, sensor as arlo +from homeassistant.components.arlo.sensor import SENSOR_TYPES from homeassistant.const import ( ATTR_ATTRIBUTION, DEVICE_CLASS_HUMIDITY, @@ -20,7 +21,11 @@ def _get_named_tuple(input_dict): def _get_sensor(name="Last", sensor_type="last_capture", data=None): if data is None: data = {} - return arlo.ArloSensor(name, data, sensor_type) + sensor_entry = next( + sensor_entry for sensor_entry in SENSOR_TYPES if sensor_entry.key == sensor_type + ) + sensor_entry.name = name + return arlo.ArloSensor(data, sensor_entry) @pytest.fixture() From 608f406a2ca961a3e9829dab51b8785796652915 Mon Sep 17 00:00:00 2001 From: cpw Date: Mon, 9 Aug 2021 06:38:05 -0400 Subject: [PATCH 240/903] Update services.yaml for matrix service to fix Data field being replaced by [object Object] in UI (#54296) --- homeassistant/components/matrix/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/matrix/services.yaml b/homeassistant/components/matrix/services.yaml index 66988def22d..c58a27c3370 100644 --- a/homeassistant/components/matrix/services.yaml +++ b/homeassistant/components/matrix/services.yaml @@ -21,4 +21,4 @@ send_message: description: Extended information of notification. Supports list of images. Optional. example: "{'images': ['/tmp/test.jpg']}" selector: - text: + object: From 9b7b787fe41f0ec4876581d1d985b54923d19bb8 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 9 Aug 2021 13:13:11 +0200 Subject: [PATCH 241/903] Remove icon where device_class is defined. (#54323) --- homeassistant/components/arlo/sensor.py | 3 --- tests/components/arlo/test_sensor.py | 13 +++++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index 883fd011c52..e78c8b7bf49 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -38,7 +38,6 @@ SENSOR_TYPES = ( key="battery_level", name="Battery Level", unit_of_measurement=PERCENTAGE, - icon="mdi:battery-50", device_class=DEVICE_CLASS_BATTERY, ), SensorEntityDescription( @@ -48,14 +47,12 @@ SENSOR_TYPES = ( key="temperature", name="Temperature", unit_of_measurement=TEMP_CELSIUS, - icon="mdi:thermometer", device_class=DEVICE_CLASS_TEMPERATURE, ), SensorEntityDescription( key="humidity", name="Humidity", unit_of_measurement=PERCENTAGE, - icon="mdi:water-percent", device_class=DEVICE_CLASS_HUMIDITY, ), SensorEntityDescription( diff --git a/tests/components/arlo/test_sensor.py b/tests/components/arlo/test_sensor.py index 34d5088397d..bf67ab21c97 100644 --- a/tests/components/arlo/test_sensor.py +++ b/tests/components/arlo/test_sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.arlo import DATA_ARLO, sensor as arlo from homeassistant.components.arlo.sensor import SENSOR_TYPES from homeassistant.const import ( ATTR_ATTRIBUTION, + DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, @@ -161,14 +162,14 @@ def test_sensor_state_default(default_sensor): assert default_sensor.state is None -def test_sensor_icon_battery(battery_sensor): - """Test the battery icon.""" - assert battery_sensor.icon == "mdi:battery-50" +def test_sensor_device_class__battery(battery_sensor): + """Test the battery device_class.""" + assert battery_sensor.device_class == DEVICE_CLASS_BATTERY -def test_sensor_icon(temperature_sensor): - """Test the icon property.""" - assert temperature_sensor.icon == "mdi:thermometer" +def test_sensor_device_class(temperature_sensor): + """Test the device_class property.""" + assert temperature_sensor.device_class == DEVICE_CLASS_TEMPERATURE def test_unit_of_measure(default_sensor, battery_sensor): From 3742333a8943503417dff7ea514dc2314dff1d64 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 9 Aug 2021 04:21:41 -0700 Subject: [PATCH 242/903] Remove zwave_js transition on individual color channels (#54303) --- homeassistant/components/zwave_js/light.py | 2 +- tests/components/zwave_js/test_services.py | 9 +++++---- .../zwave_js/bulb_6_multi_color_state.json | 15 +++++---------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index f3cabe8b6a7..4f1de6c686d 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -301,7 +301,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # fallback to setting the color(s) one by one if multicolor fails # not sure this is needed at all, but just in case for color, value in colors.items(): - await self._async_set_color(color, value, zwave_transition) + await self._async_set_color(color, value) async def _async_set_color( self, diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 3ee656e40c0..4cc5b599f19 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -1021,8 +1021,7 @@ async def test_multicast_set_value_options( ], ATTR_COMMAND_CLASS: 51, ATTR_PROPERTY: "targetColor", - ATTR_PROPERTY_KEY: 2, - ATTR_VALUE: 2, + ATTR_VALUE: '{ "warmWhite": 0, "coldWhite": 0, "red": 255, "green": 0, "blue": 0 }', ATTR_OPTIONS: {"transitionDuration": 1}, }, blocking=True, @@ -1038,9 +1037,11 @@ async def test_multicast_set_value_options( assert args["valueId"] == { "commandClass": 51, "property": "targetColor", - "propertyKey": 2, } - assert args["value"] == 2 + assert ( + args["value"] + == '{ "warmWhite": 0, "coldWhite": 0, "red": 255, "green": 0, "blue": 0 }' + ) assert args["options"] == {"transitionDuration": 1} client.async_send_command.reset_mock() diff --git a/tests/fixtures/zwave_js/bulb_6_multi_color_state.json b/tests/fixtures/zwave_js/bulb_6_multi_color_state.json index dfa72af6aa4..58608131e90 100644 --- a/tests/fixtures/zwave_js/bulb_6_multi_color_state.json +++ b/tests/fixtures/zwave_js/bulb_6_multi_color_state.json @@ -267,8 +267,7 @@ "min": 0, "max": 255, "label": "Target value (Warm White)", - "description": "The target value of the Warm White color.", - "valueChangeOptions": ["transitionDuration"] + "description": "The target value of the Warm White color." } }, { @@ -286,8 +285,7 @@ "min": 0, "max": 255, "label": "Target value (Cold White)", - "description": "The target value of the Cold White color.", - "valueChangeOptions": ["transitionDuration"] + "description": "The target value of the Cold White color." } }, { @@ -305,8 +303,7 @@ "min": 0, "max": 255, "label": "Target value (Red)", - "description": "The target value of the Red color.", - "valueChangeOptions": ["transitionDuration"] + "description": "The target value of the Red color." } }, { @@ -324,8 +321,7 @@ "min": 0, "max": 255, "label": "Target value (Green)", - "description": "The target value of the Green color.", - "valueChangeOptions": ["transitionDuration"] + "description": "The target value of the Green color." } }, { @@ -343,8 +339,7 @@ "min": 0, "max": 255, "label": "Target value (Blue)", - "description": "The target value of the Blue color.", - "valueChangeOptions": ["transitionDuration"] + "description": "The target value of the Blue color." } }, { From c79ee53ab121dda1540b2a4814a1533ca87ca8e1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 9 Aug 2021 07:29:17 -0400 Subject: [PATCH 243/903] Use dict for zwave_js siren.available_tones (#54305) * Use dict for zwave_js siren.available_tones * update siren.turn_on service description --- homeassistant/components/siren/services.yaml | 2 +- homeassistant/components/zwave_js/siren.py | 18 ++----- tests/components/zwave_js/test_siren.py | 56 ++++++++++++++++++++ 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/siren/services.yaml b/homeassistant/components/siren/services.yaml index 8c5ed3be974..18bf782eaf2 100644 --- a/homeassistant/components/siren/services.yaml +++ b/homeassistant/components/siren/services.yaml @@ -7,7 +7,7 @@ turn_on: domain: siren fields: tone: - description: The tone to emit when turning the siren on. Must be supported by the integration. + description: The tone to emit when turning the siren on. When `available_tones` property is a map, either the key or the value can be used. Must be supported by the integration. example: fire required: false selector: diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index de74f55fa9a..c1b354f4faa 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -58,9 +58,9 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): """Initialize a ZwaveSirenEntity entity.""" super().__init__(config_entry, client, info) # Entity class attributes - self._attr_available_tones = list( - self.info.primary_value.metadata.states.values() - ) + self._attr_available_tones = { + int(id): val for id, val in self.info.primary_value.metadata.states.items() + } self._attr_supported_features = ( SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET ) @@ -82,23 +82,15 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - tone: str | None = kwargs.get(ATTR_TONE) + tone_id: int | None = kwargs.get(ATTR_TONE) options = {} if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: options["volume"] = round(volume * 100) # Play the default tone if a tone isn't provided - if tone is None: + if tone_id is None: await self.async_set_value(ToneID.DEFAULT, options) return - tone_id = int( - next( - key - for key, value in self.info.primary_value.metadata.states.items() - if value == tone - ) - ) - await self.async_set_value(tone_id, options) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 937b2c0fa67..ebe437eb981 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -2,6 +2,7 @@ from zwave_js_server.event import Event from homeassistant.components.siren import ATTR_TONE, ATTR_VOLUME_LEVEL +from homeassistant.components.siren.const import ATTR_AVAILABLE_TONES from homeassistant.const import STATE_OFF, STATE_ON SIREN_ENTITY = "siren.indoor_siren_6_2" @@ -65,6 +66,39 @@ async def test_siren(hass, client, aeotec_zw164_siren, integration): assert state assert state.state == STATE_OFF + assert state.attributes.get(ATTR_AVAILABLE_TONES) == { + 0: "off", + 1: "01DING~1 (5 sec)", + 2: "02DING~1 (9 sec)", + 3: "03TRAD~1 (11 sec)", + 4: "04ELEC~1 (2 sec)", + 5: "05WEST~1 (13 sec)", + 6: "06CHIM~1 (7 sec)", + 7: "07CUCK~1 (31 sec)", + 8: "08TRAD~1 (6 sec)", + 9: "09SMOK~1 (11 sec)", + 10: "10SMOK~1 (6 sec)", + 11: "11FIRE~1 (35 sec)", + 12: "12COSE~1 (5 sec)", + 13: "13KLAX~1 (38 sec)", + 14: "14DEEP~1 (41 sec)", + 15: "15WARN~1 (37 sec)", + 16: "16TORN~1 (46 sec)", + 17: "17ALAR~1 (35 sec)", + 18: "18DEEP~1 (62 sec)", + 19: "19ALAR~1 (15 sec)", + 20: "20ALAR~1 (7 sec)", + 21: "21DIGI~1 (8 sec)", + 22: "22ALER~1 (64 sec)", + 23: "23SHIP~1 (4 sec)", + 25: "25CHRI~1 (4 sec)", + 26: "26GONG~1 (12 sec)", + 27: "27SING~1 (1 sec)", + 28: "28TONA~1 (5 sec)", + 29: "29UPWA~1 (2 sec)", + 30: "30DOOR~1 (27 sec)", + 255: "default", + } # Test turn on with default await hass.services.async_call( @@ -105,6 +139,28 @@ async def test_siren(hass, client, aeotec_zw164_siren, integration): client.async_send_command.reset_mock() + # Test turn on with specific tone ID and volume level + await hass.services.async_call( + "siren", + "turn_on", + { + "entity_id": SIREN_ENTITY, + ATTR_TONE: 1, + ATTR_VOLUME_LEVEL: 0.5, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == TONE_ID_VALUE_ID + assert args["value"] == 1 + assert args["options"] == {"volume": 50} + + client.async_send_command.reset_mock() + # Test turn off await hass.services.async_call( "siren", From 1c7891fbee392b09c845e6c9fe9605c92fa371a0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 9 Aug 2021 14:54:42 +0200 Subject: [PATCH 244/903] Remove deprecated YAML configuration from Growatt (#54325) --- .../components/growatt_server/config_flow.py | 4 --- .../components/growatt_server/sensor.py | 31 ++----------------- .../growatt_server/test_config_flow.py | 24 -------------- 3 files changed, 2 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index 45f56a327b2..d6b2c7db9fe 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -76,7 +76,3 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() self.data.update(user_input) return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) - - async def async_step_import(self, import_data): - """Migrate old yaml config to config flow.""" - return await self.async_step_user(import_data) diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index fe6bdeb70e8..530842fab7b 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -5,10 +5,8 @@ import logging import re import growattServer -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -31,10 +29,9 @@ from homeassistant.const import ( POWER_WATT, TEMP_CELSIUS, ) -import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle, dt -from .const import CONF_PLANT_ID, DEFAULT_NAME, DEFAULT_PLANT_ID, DEFAULT_URL, DOMAIN +from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DEFAULT_URL _LOGGER = logging.getLogger(__name__) @@ -549,30 +546,6 @@ SENSOR_TYPES = { **MIX_SENSOR_TYPES, } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PLANT_ID, default=DEFAULT_PLANT_ID): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_URL, default=DEFAULT_URL): cv.string, - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up growatt server from yaml.""" - if not hass.config_entries.async_entries(DOMAIN): - _LOGGER.warning( - "Loading Growatt via platform setup is deprecated." - "Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - def get_device_list(api, config): """Retrieve the device list for the selected plant.""" diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index 662448c8118..096052fd6cf 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -149,30 +149,6 @@ async def test_one_plant_on_account(hass): assert result["data"][CONF_PLANT_ID] == "123456" -async def test_import_one_plant(hass): - """Test import step with a single plant.""" - import_data = FIXTURE_USER_INPUT.copy() - - with patch( - "growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE - ), patch( - "growattServer.GrowattApi.plant_list", - return_value=GROWATT_PLANT_LIST_RESPONSE, - ), patch( - "homeassistant.components.growatt_server.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=import_data, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] - assert result["data"][CONF_PLANT_ID] == "123456" - - async def test_existing_plant_configured(hass): """Test entering an existing plant_id.""" entry = MockConfigEntry(domain=DOMAIN, unique_id="123456") From 188919f079b1727d74e6fc904f9e807c48653e01 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 9 Aug 2021 09:45:12 -0700 Subject: [PATCH 245/903] Clean up zwave_js RGB code (#54336) --- homeassistant/components/zwave_js/light.py | 37 +---- tests/components/zwave_js/test_light.py | 149 ++++++--------------- 2 files changed, 46 insertions(+), 140 deletions(-) diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 4f1de6c686d..91a7f191e5d 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -287,39 +287,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): else: zwave_transition = {TRANSITION_DURATION: "default"} - if combined_color_val and isinstance(combined_color_val.value, dict): - colors_dict = {} - for color, value in colors.items(): - color_name = MULTI_COLOR_MAP[color] - colors_dict[color_name] = value - # set updated color object - await self.info.node.async_set_value( - combined_color_val, colors_dict, zwave_transition - ) - return - - # fallback to setting the color(s) one by one if multicolor fails - # not sure this is needed at all, but just in case + colors_dict = {} for color, value in colors.items(): - await self._async_set_color(color, value) - - async def _async_set_color( - self, - color: ColorComponent, - new_value: int, - transition: dict[str, str] | None = None, - ) -> None: - """Set defined color to given value.""" - # actually set the new color value - target_zwave_value = self.get_zwave_value( - "targetColor", - CommandClass.SWITCH_COLOR, - value_property_key=color.value, + color_name = MULTI_COLOR_MAP[color] + colors_dict[color_name] = value + # set updated color object + await self.info.node.async_set_value( + combined_color_val, colors_dict, zwave_transition ) - if target_zwave_value is None: - # guard for unsupported color - return - await self.info.node.async_set_value(target_zwave_value, new_value, transition) async def _async_set_brightness( self, brightness: int | None, transition: float | None = None diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index fa3c73a9a42..5ce66d6d8e2 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -223,58 +223,23 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 - warm_args = client.async_send_command.call_args_list[0][0][0] # red 255 - assert warm_args["command"] == "node.set_value" - assert warm_args["nodeId"] == 39 - assert warm_args["valueId"]["commandClassName"] == "Color Switch" - assert warm_args["valueId"]["commandClass"] == 51 - assert warm_args["valueId"]["endpoint"] == 0 - assert warm_args["valueId"]["metadata"]["label"] == "Target value (Red)" - assert warm_args["valueId"]["property"] == "targetColor" - assert warm_args["valueId"]["propertyName"] == "targetColor" - assert warm_args["value"] == 255 - - cold_args = client.async_send_command.call_args_list[1][0][0] # green 76 - assert cold_args["command"] == "node.set_value" - assert cold_args["nodeId"] == 39 - assert cold_args["valueId"]["commandClassName"] == "Color Switch" - assert cold_args["valueId"]["commandClass"] == 51 - assert cold_args["valueId"]["endpoint"] == 0 - assert cold_args["valueId"]["metadata"]["label"] == "Target value (Green)" - assert cold_args["valueId"]["property"] == "targetColor" - assert cold_args["valueId"]["propertyName"] == "targetColor" - assert cold_args["value"] == 76 - red_args = client.async_send_command.call_args_list[2][0][0] # blue 255 - assert red_args["command"] == "node.set_value" - assert red_args["nodeId"] == 39 - assert red_args["valueId"]["commandClassName"] == "Color Switch" - assert red_args["valueId"]["commandClass"] == 51 - assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Blue)" - assert red_args["valueId"]["property"] == "targetColor" - assert red_args["valueId"]["propertyName"] == "targetColor" - assert red_args["value"] == 255 - green_args = client.async_send_command.call_args_list[3][0][0] # warm white 0 - assert green_args["command"] == "node.set_value" - assert green_args["nodeId"] == 39 - assert green_args["valueId"]["commandClassName"] == "Color Switch" - assert green_args["valueId"]["commandClass"] == 51 - assert green_args["valueId"]["endpoint"] == 0 - assert green_args["valueId"]["metadata"]["label"] == "Target value (Warm White)" - assert green_args["valueId"]["property"] == "targetColor" - assert green_args["valueId"]["propertyName"] == "targetColor" - assert green_args["value"] == 0 - blue_args = client.async_send_command.call_args_list[4][0][0] # cold white 0 - assert blue_args["command"] == "node.set_value" - assert blue_args["nodeId"] == 39 - assert blue_args["valueId"]["commandClassName"] == "Color Switch" - assert blue_args["valueId"]["commandClass"] == 51 - assert blue_args["valueId"]["endpoint"] == 0 - assert blue_args["valueId"]["metadata"]["label"] == "Target value (Cold White)" - assert blue_args["valueId"]["property"] == "targetColor" - assert blue_args["valueId"]["propertyName"] == "targetColor" - assert blue_args["value"] == 0 + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 39 + assert args["valueId"]["commandClassName"] == "Color Switch" + assert args["valueId"]["commandClass"] == 51 + assert args["valueId"]["endpoint"] == 0 + assert args["valueId"]["metadata"]["label"] == "Target Color" + assert args["valueId"]["property"] == "targetColor" + assert args["valueId"]["propertyName"] == "targetColor" + assert args["value"] == { + "blue": 255, + "coldWhite": 0, + "green": 76, + "red": 255, + "warmWhite": 0, + } # Test rgb color update from value updated event red_event = Event( @@ -328,7 +293,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 + assert len(client.async_send_command.call_args_list) == 2 client.async_send_command.reset_mock() @@ -344,8 +309,8 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 - args = client.async_send_command.call_args_list[5][0][0] + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] assert args["options"]["transitionDuration"] == "20s" client.async_send_command.reset_mock() @@ -357,57 +322,23 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 - red_args = client.async_send_command.call_args_list[0][0][0] # red 0 - assert red_args["command"] == "node.set_value" - assert red_args["nodeId"] == 39 - assert red_args["valueId"]["commandClassName"] == "Color Switch" - assert red_args["valueId"]["commandClass"] == 51 - assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Red)" - assert red_args["valueId"]["property"] == "targetColor" - assert red_args["valueId"]["propertyName"] == "targetColor" - assert red_args["value"] == 0 - red_args = client.async_send_command.call_args_list[1][0][0] # green 0 - assert red_args["command"] == "node.set_value" - assert red_args["nodeId"] == 39 - assert red_args["valueId"]["commandClassName"] == "Color Switch" - assert red_args["valueId"]["commandClass"] == 51 - assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Green)" - assert red_args["valueId"]["property"] == "targetColor" - assert red_args["valueId"]["propertyName"] == "targetColor" - assert red_args["value"] == 0 - red_args = client.async_send_command.call_args_list[2][0][0] # blue 0 - assert red_args["command"] == "node.set_value" - assert red_args["nodeId"] == 39 - assert red_args["valueId"]["commandClassName"] == "Color Switch" - assert red_args["valueId"]["commandClass"] == 51 - assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Blue)" - assert red_args["valueId"]["property"] == "targetColor" - assert red_args["valueId"]["propertyName"] == "targetColor" - assert red_args["value"] == 0 - warm_args = client.async_send_command.call_args_list[3][0][0] # warm white 0 - assert warm_args["command"] == "node.set_value" - assert warm_args["nodeId"] == 39 - assert warm_args["valueId"]["commandClassName"] == "Color Switch" - assert warm_args["valueId"]["commandClass"] == 51 - assert warm_args["valueId"]["endpoint"] == 0 - assert warm_args["valueId"]["metadata"]["label"] == "Target value (Warm White)" - assert warm_args["valueId"]["property"] == "targetColor" - assert warm_args["valueId"]["propertyName"] == "targetColor" - assert warm_args["value"] == 20 - red_args = client.async_send_command.call_args_list[4][0][0] # cold white - assert red_args["command"] == "node.set_value" - assert red_args["nodeId"] == 39 - assert red_args["valueId"]["commandClassName"] == "Color Switch" - assert red_args["valueId"]["commandClass"] == 51 - assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Cold White)" - assert red_args["valueId"]["property"] == "targetColor" - assert red_args["valueId"]["propertyName"] == "targetColor" - assert red_args["value"] == 235 + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] # red 0 + assert args["command"] == "node.set_value" + assert args["nodeId"] == 39 + assert args["valueId"]["commandClassName"] == "Color Switch" + assert args["valueId"]["commandClass"] == 51 + assert args["valueId"]["endpoint"] == 0 + assert args["valueId"]["metadata"]["label"] == "Target Color" + assert args["valueId"]["property"] == "targetColor" + assert args["valueId"]["propertyName"] == "targetColor" + assert args["value"] == { + "blue": 0, + "coldWhite": 235, + "green": 0, + "red": 0, + "warmWhite": 20, + } client.async_send_command.reset_mock() @@ -466,7 +397,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 + assert len(client.async_send_command.call_args_list) == 2 client.async_send_command.reset_mock() @@ -482,8 +413,8 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 - args = client.async_send_command.call_args_list[5][0][0] + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] assert args["options"]["transitionDuration"] == "35s" client.async_send_command.reset_mock() From b88f0adbe91f61b23080467fd8f928af77d17672 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 9 Aug 2021 18:48:01 +0100 Subject: [PATCH 246/903] Restores unit_of_measurement (#54335) --- homeassistant/components/integration/sensor.py | 4 ++++ tests/components/integration/test_sensor.py | 1 + 2 files changed, 5 insertions(+) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 2b7d89decea..9308adc622d 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -145,6 +145,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity): ) self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) + self._unit_of_measurement = state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) + @callback def calc_integration(event): """Handle the sensor state changes.""" diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index dd6bf980d0f..36d3d4b3b30 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -73,6 +73,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: { "last_reset": "2019-10-06T21:00:00", "device_class": DEVICE_CLASS_ENERGY, + "unit_of_measurement": ENERGY_KILO_WATT_HOUR, }, ), ), From acf55f2f3af19a744bc8ac52edda38726c46111d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 9 Aug 2021 19:55:14 +0200 Subject: [PATCH 247/903] Add light transition for Shelly integration (#54327) * Add support for light transition * Limit transition to 5 seconds * Update MODELS_SUPPORTING_LIGHT_TRANSITION list --- homeassistant/components/shelly/const.py | 17 ++++++++++++++ homeassistant/components/shelly/light.py | 29 +++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 49e33dfd5e1..ea6b9320cb1 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -1,6 +1,7 @@ """Constants for the Shelly integration.""" from __future__ import annotations +import re from typing import Final COAP: Final = "coap" @@ -11,6 +12,22 @@ REST: Final = "rest" CONF_COAP_PORT: Final = "coap_port" DEFAULT_COAP_PORT: Final = 5683 +FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})") + +# Firmware 1.11.0 release date, this firmware supports light transition +LIGHT_TRANSITION_MIN_FIRMWARE_DATE: Final = 20210226 + +# max light transition time in milliseconds +MAX_TRANSITION_TIME: Final = 5000 + +MODELS_SUPPORTING_LIGHT_TRANSITION: Final = ( + "SHBDUO-1", + "SHCB-1", + "SHDM-1", + "SHDM-2", + "SHRGBW2", + "SHVIN-1", +) # Used in "_async_update_data" as timeout for polling data from devices. POLLING_TIMEOUT_SEC: Final = 18 diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 047a105a30f..86624410708 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -14,12 +14,14 @@ from homeassistant.components.light import ( ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, + ATTR_TRANSITION, COLOR_MODE_BRIGHTNESS, COLOR_MODE_COLOR_TEMP, COLOR_MODE_ONOFF, COLOR_MODE_RGB, COLOR_MODE_RGBW, SUPPORT_EFFECT, + SUPPORT_TRANSITION, LightEntity, brightness_supported, ) @@ -37,9 +39,13 @@ from .const import ( COAP, DATA_CONFIG_ENTRY, DOMAIN, + FIRMWARE_PATTERN, KELVIN_MAX_VALUE, KELVIN_MIN_VALUE_COLOR, KELVIN_MIN_VALUE_WHITE, + LIGHT_TRANSITION_MIN_FIRMWARE_DATE, + MAX_TRANSITION_TIME, + MODELS_SUPPORTING_LIGHT_TRANSITION, SHBLB_1_RGB_EFFECTS, STANDARD_RGB_EFFECTS, ) @@ -110,6 +116,14 @@ class ShellyLight(ShellyBlockEntity, LightEntity): if hasattr(block, "effect"): self._supported_features |= SUPPORT_EFFECT + if wrapper.model in MODELS_SUPPORTING_LIGHT_TRANSITION: + match = FIRMWARE_PATTERN.search(wrapper.device.settings.get("fw")) + if ( + match is not None + and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE + ): + self._supported_features |= SUPPORT_TRANSITION + @property def supported_features(self) -> int: """Supported features.""" @@ -261,6 +275,11 @@ class ShellyLight(ShellyBlockEntity, LightEntity): supported_color_modes = self._supported_color_modes params: dict[str, Any] = {"turn": "on"} + if ATTR_TRANSITION in kwargs: + params["transition"] = min( + int(kwargs[ATTR_TRANSITION] * 1000), MAX_TRANSITION_TIME + ) + if ATTR_BRIGHTNESS in kwargs and brightness_supported(supported_color_modes): brightness_pct = int(100 * (kwargs[ATTR_BRIGHTNESS] + 1) / 255) if hasattr(self.block, "gain"): @@ -312,7 +331,15 @@ class ShellyLight(ShellyBlockEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" - self.control_result = await self.set_state(turn="off") + params: dict[str, Any] = {"turn": "off"} + + if ATTR_TRANSITION in kwargs: + params["transition"] = min( + int(kwargs[ATTR_TRANSITION] * 1000), MAX_TRANSITION_TIME + ) + + self.control_result = await self.set_state(**params) + self.async_write_ha_state() async def set_light_mode(self, set_mode: str | None) -> bool: From a23da30c292c687c35958b3869b4d9b7e84900b9 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 9 Aug 2021 20:33:34 +0200 Subject: [PATCH 248/903] Yeelight local push updates (#51160) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- homeassistant/components/yeelight/__init__.py | 109 +++++---- .../components/yeelight/binary_sensor.py | 1 + homeassistant/components/yeelight/light.py | 222 ++++++++---------- .../components/yeelight/manifest.json | 6 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/yeelight/__init__.py | 24 +- .../components/yeelight/test_binary_sensor.py | 2 +- tests/components/yeelight/test_config_flow.py | 4 +- tests/components/yeelight/test_init.py | 77 ++++-- tests/components/yeelight/test_light.py | 136 ++++++----- 12 files changed, 328 insertions(+), 259 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 86622690fb9..d2e756c0d0d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -584,7 +584,7 @@ homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yale_smart_alarm/* @gjohansson-ST homeassistant/components/yamaha_musiccast/* @vigonotion @micha91 homeassistant/components/yandex_transport/* @rishatik92 @devbis -homeassistant/components/yeelight/* @rytilahti @zewelor @shenxn +homeassistant/components/yeelight/* @rytilahti @zewelor @shenxn @starkillerOG homeassistant/components/yeelightsunflower/* @lindsaymarkward homeassistant/components/yi/* @bachya homeassistant/components/youless/* @gjong diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 2cb754ce6a7..2a4ba4eac55 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -6,7 +6,8 @@ from datetime import timedelta import logging import voluptuous as vol -from yeelight import Bulb, BulbException, discover_bulbs +from yeelight import BulbException, discover_bulbs +from yeelight.aio import KEY_CONNECTED, AsyncBulb from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady from homeassistant.const import ( @@ -14,13 +15,15 @@ from homeassistant.const import ( CONF_HOST, CONF_ID, CONF_NAME, - CONF_SCAN_INTERVAL, + EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.event import async_track_time_interval _LOGGER = logging.getLogger(__name__) @@ -46,7 +49,6 @@ CONF_NIGHTLIGHT_SWITCH = "nightlight_switch" DATA_CONFIG_ENTRIES = "config_entries" DATA_CUSTOM_EFFECTS = "custom_effects" -DATA_SCAN_INTERVAL = "scan_interval" DATA_DEVICE = "device" DATA_REMOVE_INIT_DISPATCHER = "remove_init_dispatcher" DATA_PLATFORMS_LOADED = "platforms_loaded" @@ -65,7 +67,6 @@ ACTIVE_COLOR_FLOWING = "1" NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light" -SCAN_INTERVAL = timedelta(seconds=30) DISCOVERY_INTERVAL = timedelta(seconds=60) YEELIGHT_RGB_TRANSITION = "RGBTransition" @@ -114,7 +115,6 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: vol.Schema( { vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, vol.Optional(CONF_CUSTOM_EFFECTS): [ { vol.Required(CONF_NAME): cv.string, @@ -158,7 +158,6 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: hass.data[DOMAIN] = { DATA_CUSTOM_EFFECTS: conf.get(CONF_CUSTOM_EFFECTS, {}), DATA_CONFIG_ENTRIES: {}, - DATA_SCAN_INTERVAL: conf.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), } # Import manually configured devices @@ -196,14 +195,25 @@ async def _async_initialize( device = await _async_get_device(hass, host, entry) entry_data[DATA_DEVICE] = device + # start listening for local pushes + await device.bulb.async_listen(device.async_update_callback) + + # register stop callback to shutdown listening for local pushes + async def async_stop_listen_task(event): + """Stop listen thread.""" + _LOGGER.debug("Shutting down Yeelight Listener") + await device.bulb.async_stop_listening() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_listen_task) + entry.async_on_unload( async_dispatcher_connect( hass, DEVICE_INITIALIZED.format(host), _async_load_platforms ) ) - entry.async_on_unload(device.async_unload) - await device.async_setup() + # fetch initial state + asyncio.create_task(device.async_update()) @callback @@ -248,14 +258,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Otherwise fall through to discovery else: # manually added device - await _async_initialize(hass, entry, entry.data[CONF_HOST], device=device) + try: + await _async_initialize( + hass, entry, entry.data[CONF_HOST], device=device + ) + except BulbException as ex: + raise ConfigEntryNotReady from ex return True # discovery scanner = YeelightScanner.async_get(hass) async def _async_from_discovery(host: str) -> None: - await _async_initialize(hass, entry, host) + try: + await _async_initialize(hass, entry, host) + except BulbException: + _LOGGER.exception("Failed to connect to bulb at %s", host) scanner.async_register_callback(entry.data[CONF_ID], _async_from_discovery) return True @@ -275,6 +293,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: scanner = YeelightScanner.async_get(hass) scanner.async_unregister_callback(entry.data[CONF_ID]) + device = entry_data[DATA_DEVICE] + _LOGGER.debug("Shutting down Yeelight Listener") + await device.bulb.async_stop_listening() + _LOGGER.debug("Yeelight Listener stopped") + data_config_entries.pop(entry.entry_id) return True @@ -331,7 +354,7 @@ class YeelightScanner: if len(self._callbacks) == 0: self._async_stop_scan() - await asyncio.sleep(SCAN_INTERVAL.total_seconds()) + await asyncio.sleep(DISCOVERY_INTERVAL.total_seconds()) self._scan_task = self._hass.loop.create_task(self._async_scan()) @callback @@ -382,7 +405,6 @@ class YeelightDevice: self._capabilities = capabilities or {} self._device_type = None self._available = False - self._remove_time_tracker = None self._initialized = False self._name = host # Default name is host @@ -478,34 +500,36 @@ class YeelightDevice: return self._device_type - def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None, power_mode=None): + async def async_turn_on( + self, duration=DEFAULT_TRANSITION, light_type=None, power_mode=None + ): """Turn on device.""" try: - self.bulb.turn_on( + await self.bulb.async_turn_on( duration=duration, light_type=light_type, power_mode=power_mode ) except BulbException as ex: _LOGGER.error("Unable to turn the bulb on: %s", ex) - def turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): + async def async_turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn off device.""" try: - self.bulb.turn_off(duration=duration, light_type=light_type) + await self.bulb.async_turn_off(duration=duration, light_type=light_type) except BulbException as ex: _LOGGER.error( "Unable to turn the bulb off: %s, %s: %s", self._host, self.name, ex ) - def _update_properties(self): + async def _async_update_properties(self): """Read new properties from the device.""" if not self.bulb: return try: - self.bulb.get_properties(UPDATE_REQUEST_PROPERTIES) + await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES) self._available = True if not self._initialized: - self._initialize_device() + await self._async_initialize_device() except BulbException as ex: if self._available: # just inform once _LOGGER.error( @@ -515,10 +539,10 @@ class YeelightDevice: return self._available - def _get_capabilities(self): + async def _async_get_capabilities(self): """Request device capabilities.""" try: - self.bulb.get_capabilities() + await self._hass.async_add_executor_job(self.bulb.get_capabilities) _LOGGER.debug( "Device %s, %s capabilities: %s", self._host, @@ -533,31 +557,24 @@ class YeelightDevice: ex, ) - def _initialize_device(self): - self._get_capabilities() + async def _async_initialize_device(self): + await self._async_get_capabilities() self._initialized = True - dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) + async_dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) - def update(self): + async def async_update(self): """Update device properties and send data updated signal.""" - self._update_properties() - dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) - - async def async_setup(self): - """Set up the device.""" - - async def _async_update(_): - await self._hass.async_add_executor_job(self.update) - - await _async_update(None) - self._remove_time_tracker = async_track_time_interval( - self._hass, _async_update, self._hass.data[DOMAIN][DATA_SCAN_INTERVAL] - ) + if self._initialized and self._available: + # No need to poll, already connected + return + await self._async_update_properties() + async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) @callback - def async_unload(self): - """Unload the device.""" - self._remove_time_tracker() + def async_update_callback(self, data): + """Update push from device.""" + self._available = data.get(KEY_CONNECTED, True) + async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) class YeelightEntity(Entity): @@ -597,9 +614,9 @@ class YeelightEntity(Entity): """No polling needed.""" return False - def update(self) -> None: + async def async_update(self) -> None: """Update the entity.""" - self._device.update() + await self._device.async_update() async def _async_get_device( @@ -609,7 +626,7 @@ async def _async_get_device( model = entry.options.get(CONF_MODEL) # Set up device - bulb = Bulb(host, model=model or None) + bulb = AsyncBulb(host, model=model or None) capabilities = await hass.async_add_executor_job(bulb.get_capabilities) return YeelightDevice(hass, host, entry.options, bulb, capabilities) diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 4fe3709cdd2..185bb504a1b 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -33,6 +33,7 @@ class YeelightNightlightModeSensor(YeelightEntity, BinarySensorEntity): self.async_write_ha_state, ) ) + await super().async_added_to_hass() @property def unique_id(self) -> str: diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 8d0a3b0ffd4..d2ddc92bb8d 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,7 +1,6 @@ """Light platform support for yeelight.""" from __future__ import annotations -from functools import partial import logging import voluptuous as vol @@ -234,17 +233,17 @@ def _parse_custom_effects(effects_config): return effects -def _cmd(func): +def _async_cmd(func): """Define a wrapper to catch exceptions from the bulb.""" - def _wrap(self, *args, **kwargs): + async def _async_wrap(self, *args, **kwargs): try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) - return func(self, *args, **kwargs) + return await func(self, *args, **kwargs) except BulbException as ex: _LOGGER.error("Error when calling %s: %s", func, ex) - return _wrap + return _async_wrap async def async_setup_entry( @@ -306,36 +305,27 @@ def _async_setup_services(hass: HomeAssistant): params = {**service_call.data} params.pop(ATTR_ENTITY_ID) params[ATTR_TRANSITIONS] = _transitions_config_parser(params[ATTR_TRANSITIONS]) - await hass.async_add_executor_job(partial(entity.start_flow, **params)) + await entity.async_start_flow(**params) async def _async_set_color_scene(entity, service_call): - await hass.async_add_executor_job( - partial( - entity.set_scene, - SceneClass.COLOR, - *service_call.data[ATTR_RGB_COLOR], - service_call.data[ATTR_BRIGHTNESS], - ) + await entity.async_set_scene( + SceneClass.COLOR, + *service_call.data[ATTR_RGB_COLOR], + service_call.data[ATTR_BRIGHTNESS], ) async def _async_set_hsv_scene(entity, service_call): - await hass.async_add_executor_job( - partial( - entity.set_scene, - SceneClass.HSV, - *service_call.data[ATTR_HS_COLOR], - service_call.data[ATTR_BRIGHTNESS], - ) + await entity.async_set_scene( + SceneClass.HSV, + *service_call.data[ATTR_HS_COLOR], + service_call.data[ATTR_BRIGHTNESS], ) async def _async_set_color_temp_scene(entity, service_call): - await hass.async_add_executor_job( - partial( - entity.set_scene, - SceneClass.CT, - service_call.data[ATTR_KELVIN], - service_call.data[ATTR_BRIGHTNESS], - ) + await entity.async_set_scene( + SceneClass.CT, + service_call.data[ATTR_KELVIN], + service_call.data[ATTR_BRIGHTNESS], ) async def _async_set_color_flow_scene(entity, service_call): @@ -344,24 +334,19 @@ def _async_setup_services(hass: HomeAssistant): action=Flow.actions[service_call.data[ATTR_ACTION]], transitions=_transitions_config_parser(service_call.data[ATTR_TRANSITIONS]), ) - await hass.async_add_executor_job( - partial(entity.set_scene, SceneClass.CF, flow) - ) + await entity.async_set_scene(SceneClass.CF, flow) async def _async_set_auto_delay_off_scene(entity, service_call): - await hass.async_add_executor_job( - partial( - entity.set_scene, - SceneClass.AUTO_DELAY_OFF, - service_call.data[ATTR_BRIGHTNESS], - service_call.data[ATTR_MINUTES], - ) + await entity.async_set_scene( + SceneClass.AUTO_DELAY_OFF, + service_call.data[ATTR_BRIGHTNESS], + service_call.data[ATTR_MINUTES], ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( - SERVICE_SET_MODE, SERVICE_SCHEMA_SET_MODE, "set_mode" + SERVICE_SET_MODE, SERVICE_SCHEMA_SET_MODE, "async_set_mode" ) platform.async_register_entity_service( SERVICE_START_FLOW, SERVICE_SCHEMA_START_FLOW, _async_start_flow @@ -405,8 +390,6 @@ class YeelightGenericLight(YeelightEntity, LightEntity): self.config = device.config self._color_temp = None - self._hs = None - self._rgb = None self._effect = None model_specs = self._bulb.get_model_specs() @@ -420,19 +403,16 @@ class YeelightGenericLight(YeelightEntity, LightEntity): else: self._custom_effects = {} - @callback - def _schedule_immediate_update(self): - self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): """Handle entity which will be added.""" self.async_on_remove( async_dispatcher_connect( self.hass, DATA_UPDATED.format(self._device.host), - self._schedule_immediate_update, + self.async_write_ha_state, ) ) + await super().async_added_to_hass() @property def supported_features(self) -> int: @@ -502,16 +482,33 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @property def hs_color(self) -> tuple: """Return the color property.""" - return self._hs + hue = self._get_property("hue") + sat = self._get_property("sat") + if hue is None or sat is None: + return None + + return (int(hue), int(sat)) @property def rgb_color(self) -> tuple: """Return the color property.""" - return self._rgb + rgb = self._get_property("rgb") + + if rgb is None: + return None + + rgb = int(rgb) + blue = rgb & 0xFF + green = (rgb >> 8) & 0xFF + red = (rgb >> 16) & 0xFF + + return (red, green, blue) @property def effect(self): """Return the current effect.""" + if not self.device.is_color_flow_enabled: + return None return self._effect @property @@ -561,33 +558,9 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """Return yeelight device.""" return self._device - def update(self): + async def async_update(self): """Update light properties.""" - self._hs = self._get_hs_from_properties() - self._rgb = self._get_rgb_from_properties() - if not self.device.is_color_flow_enabled: - self._effect = None - - def _get_hs_from_properties(self): - hue = self._get_property("hue") - sat = self._get_property("sat") - if hue is None or sat is None: - return None - - return (int(hue), int(sat)) - - def _get_rgb_from_properties(self): - rgb = self._get_property("rgb") - - if rgb is None: - return None - - rgb = int(rgb) - blue = rgb & 0xFF - green = (rgb >> 8) & 0xFF - red = (rgb >> 16) & 0xFF - - return (red, green, blue) + await self.device.async_update() def set_music_mode(self, music_mode) -> None: """Set the music mode on or off.""" @@ -599,53 +572,51 @@ class YeelightGenericLight(YeelightEntity, LightEntity): else: self._bulb.stop_music() - self.device.update() - - @_cmd - def set_brightness(self, brightness, duration) -> None: + @_async_cmd + async def async_set_brightness(self, brightness, duration) -> None: """Set bulb brightness.""" if brightness: _LOGGER.debug("Setting brightness: %s", brightness) - self._bulb.set_brightness( + await self._bulb.async_set_brightness( brightness / 255 * 100, duration=duration, light_type=self.light_type ) - @_cmd - def set_hs(self, hs_color, duration) -> None: + @_async_cmd + async def async_set_hs(self, hs_color, duration) -> None: """Set bulb's color.""" if hs_color and COLOR_MODE_HS in self.supported_color_modes: _LOGGER.debug("Setting HS: %s", hs_color) - self._bulb.set_hsv( + await self._bulb.async_set_hsv( hs_color[0], hs_color[1], duration=duration, light_type=self.light_type ) - @_cmd - def set_rgb(self, rgb, duration) -> None: + @_async_cmd + async def async_set_rgb(self, rgb, duration) -> None: """Set bulb's color.""" if rgb and COLOR_MODE_RGB in self.supported_color_modes: _LOGGER.debug("Setting RGB: %s", rgb) - self._bulb.set_rgb( + await self._bulb.async_set_rgb( rgb[0], rgb[1], rgb[2], duration=duration, light_type=self.light_type ) - @_cmd - def set_colortemp(self, colortemp, duration) -> None: + @_async_cmd + async def async_set_colortemp(self, colortemp, duration) -> None: """Set bulb's color temperature.""" if colortemp and COLOR_MODE_COLOR_TEMP in self.supported_color_modes: temp_in_k = mired_to_kelvin(colortemp) _LOGGER.debug("Setting color temp: %s K", temp_in_k) - self._bulb.set_color_temp( + await self._bulb.async_set_color_temp( temp_in_k, duration=duration, light_type=self.light_type ) - @_cmd - def set_default(self) -> None: + @_async_cmd + async def async_set_default(self) -> None: """Set current options as default.""" - self._bulb.set_default() + await self._bulb.async_set_default() - @_cmd - def set_flash(self, flash) -> None: + @_async_cmd + async def async_set_flash(self, flash) -> None: """Activate flash.""" if flash: if int(self._bulb.last_properties["color_mode"]) != 1: @@ -660,7 +631,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): count = 1 duration = transition * 2 - red, green, blue = color_util.color_hs_to_RGB(*self._hs) + red, green, blue = color_util.color_hs_to_RGB(*self.hs_color) transitions = [] transitions.append( @@ -675,18 +646,18 @@ class YeelightGenericLight(YeelightEntity, LightEntity): flow = Flow(count=count, transitions=transitions) try: - self._bulb.start_flow(flow, light_type=self.light_type) + await self._bulb.async_start_flow(flow, light_type=self.light_type) except BulbException as ex: _LOGGER.error("Unable to set flash: %s", ex) - @_cmd - def set_effect(self, effect) -> None: + @_async_cmd + async def async_set_effect(self, effect) -> None: """Activate effect.""" if not effect: return if effect == EFFECT_STOP: - self._bulb.stop_flow(light_type=self.light_type) + await self._bulb.async_stop_flow(light_type=self.light_type) return if effect in self.custom_effects_names: @@ -705,12 +676,12 @@ class YeelightGenericLight(YeelightEntity, LightEntity): return try: - self._bulb.start_flow(flow, light_type=self.light_type) + await self._bulb.async_start_flow(flow, light_type=self.light_type) self._effect = effect except BulbException as ex: _LOGGER.error("Unable to set effect: %s", ex) - def turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs) -> None: """Turn the bulb on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) colortemp = kwargs.get(ATTR_COLOR_TEMP) @@ -723,15 +694,18 @@ class YeelightGenericLight(YeelightEntity, LightEntity): if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - self.device.turn_on( - duration=duration, - light_type=self.light_type, - power_mode=self._turn_on_power_mode, - ) + if not self.is_on: + await self.device.async_turn_on( + duration=duration, + light_type=self.light_type, + power_mode=self._turn_on_power_mode, + ) if self.config[CONF_MODE_MUSIC] and not self._bulb.music_mode: try: - self.set_music_mode(self.config[CONF_MODE_MUSIC]) + await self.hass.async_add_executor_job( + self.set_music_mode, self.config[CONF_MODE_MUSIC] + ) except BulbException as ex: _LOGGER.error( "Unable to turn on music mode, consider disabling it: %s", ex @@ -739,12 +713,12 @@ class YeelightGenericLight(YeelightEntity, LightEntity): try: # values checked for none in methods - self.set_hs(hs_color, duration) - self.set_rgb(rgb, duration) - self.set_colortemp(colortemp, duration) - self.set_brightness(brightness, duration) - self.set_flash(flash) - self.set_effect(effect) + await self.async_set_hs(hs_color, duration) + await self.async_set_rgb(rgb, duration) + await self.async_set_colortemp(colortemp, duration) + await self.async_set_brightness(brightness, duration) + await self.async_set_flash(flash) + await self.async_set_effect(effect) except BulbException as ex: _LOGGER.error("Unable to set bulb properties: %s", ex) return @@ -752,50 +726,48 @@ class YeelightGenericLight(YeelightEntity, LightEntity): # save the current state if we had a manual change. if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb): try: - self.set_default() + await self.async_set_default() except BulbException as ex: _LOGGER.error("Unable to set the defaults: %s", ex) return - self.device.update() - def turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs) -> None: """Turn off.""" + if not self.is_on: + return + duration = int(self.config[CONF_TRANSITION]) # in ms if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - self.device.turn_off(duration=duration, light_type=self.light_type) - self.device.update() + await self.device.async_turn_off(duration=duration, light_type=self.light_type) - def set_mode(self, mode: str): + async def async_set_mode(self, mode: str): """Set a power mode.""" try: - self._bulb.set_power_mode(PowerMode[mode.upper()]) - self.device.update() + await self._bulb.async_set_power_mode(PowerMode[mode.upper()]) except BulbException as ex: _LOGGER.error("Unable to set the power mode: %s", ex) - def start_flow(self, transitions, count=0, action=ACTION_RECOVER): + async def async_start_flow(self, transitions, count=0, action=ACTION_RECOVER): """Start flow.""" try: flow = Flow( count=count, action=Flow.actions[action], transitions=transitions ) - self._bulb.start_flow(flow, light_type=self.light_type) - self.device.update() + await self._bulb.async_start_flow(flow, light_type=self.light_type) except BulbException as ex: _LOGGER.error("Unable to set effect: %s", ex) - def set_scene(self, scene_class, *args): + async def async_set_scene(self, scene_class, *args): """ Set the light directly to the specified state. If the light is off, it will first be turned on. """ try: - self._bulb.set_scene(scene_class, *args) - self.device.update() + await self._bulb.async_set_scene(scene_class, *args) except BulbException as ex: _LOGGER.error("Unable to set scene: %s", ex) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 0bf6249b647..7b78f540289 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,10 +2,10 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.6.3"], - "codeowners": ["@rytilahti", "@zewelor", "@shenxn"], + "requirements": ["yeelight==0.7.2"], + "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, - "iot_class": "local_polling", + "iot_class": "local_push", "dhcp": [{ "hostname": "yeelink-*" }], diff --git a/requirements_all.txt b/requirements_all.txt index 0f2e0fdf1c3..1ce754f9db1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2421,7 +2421,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.6.3 +yeelight==0.7.2 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d224c4112d..f5fc75e41ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1338,7 +1338,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.6.3 +yeelight==0.7.2 # homeassistant.components.youless youless-api==0.10 diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 5725880f942..9fa864d6213 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -1,5 +1,5 @@ """Tests for the Yeelight integration.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS @@ -84,16 +84,34 @@ def _mocked_bulb(cannot_connect=False): type(bulb).get_capabilities = MagicMock( return_value=None if cannot_connect else CAPABILITIES ) + type(bulb).async_get_properties = AsyncMock( + side_effect=BulbException if cannot_connect else None + ) type(bulb).get_properties = MagicMock( side_effect=BulbException if cannot_connect else None ) type(bulb).get_model_specs = MagicMock(return_value=_MODEL_SPECS[MODEL]) - bulb.capabilities = CAPABILITIES + bulb.capabilities = CAPABILITIES.copy() bulb.model = MODEL bulb.bulb_type = BulbType.Color - bulb.last_properties = PROPERTIES + bulb.last_properties = PROPERTIES.copy() bulb.music_mode = False + bulb.async_get_properties = AsyncMock() + bulb.async_listen = AsyncMock() + bulb.async_stop_listening = AsyncMock() + bulb.async_update = AsyncMock() + bulb.async_turn_on = AsyncMock() + bulb.async_turn_off = AsyncMock() + bulb.async_set_brightness = AsyncMock() + bulb.async_set_color_temp = AsyncMock() + bulb.async_set_hsv = AsyncMock() + bulb.async_set_rgb = AsyncMock() + bulb.async_start_flow = AsyncMock() + bulb.async_stop_flow = AsyncMock() + bulb.async_set_power_mode = AsyncMock() + bulb.async_set_scene = AsyncMock() + bulb.async_set_default = AsyncMock() return bulb diff --git a/tests/components/yeelight/test_binary_sensor.py b/tests/components/yeelight/test_binary_sensor.py index f716469fc9a..472d8de4919 100644 --- a/tests/components/yeelight/test_binary_sensor.py +++ b/tests/components/yeelight/test_binary_sensor.py @@ -14,7 +14,7 @@ ENTITY_BINARY_SENSOR = f"binary_sensor.{NAME}_nightlight" async def test_nightlight(hass: HomeAssistant): """Test nightlight sensor.""" mocked_bulb = _mocked_bulb() - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb), patch( + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), patch( f"{MODULE}.config_flow.yeelight.Bulb", return_value=mocked_bulb ): await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION) diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 8994c8e3360..247630ecfc3 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -219,7 +219,7 @@ async def test_options(hass: HomeAssistant): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -241,7 +241,7 @@ async def test_options(hass: HomeAssistant): config[CONF_NIGHTLIGHT_SWITCH] = True user_input = {**config} user_input.pop(CONF_NAME) - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input ) diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 2d1113d1896..575ad4cb594 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -1,7 +1,7 @@ """Test Yeelight.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from yeelight import BulbType +from yeelight import BulbException, BulbType from homeassistant.components.yeelight import ( CONF_NIGHTLIGHT_SWITCH, @@ -56,7 +56,7 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant): ) _discovered_devices = [{"capabilities": CAPABILITIES, "ip": IP_ADDRESS}] - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb), patch( + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), patch( f"{MODULE}.discover_bulbs", return_value=_discovered_devices ): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -65,14 +65,12 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant): binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( f"yeelight_color_{ID}" ) - entity_registry = er.async_get(hass) - assert entity_registry.async_get(binary_sensor_entity_id) is None - await hass.async_block_till_done() + type(mocked_bulb).async_get_properties = AsyncMock(None) - type(mocked_bulb).get_properties = MagicMock(None) - - hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE].update() + await hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][ + DATA_DEVICE + ].async_update() await hass.async_block_till_done() await hass.async_block_till_done() @@ -91,7 +89,7 @@ async def test_ip_changes_id_missing_cannot_fallback(hass: HomeAssistant): side_effect=[OSError, CAPABILITIES, CAPABILITIES] ) - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -104,7 +102,9 @@ async def test_setup_discovery(hass: HomeAssistant): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with _patch_discovery(MODULE), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -127,7 +127,7 @@ async def test_setup_import(hass: HomeAssistant): """Test import from yaml.""" mocked_bulb = _mocked_bulb() name = "yeelight" - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb), patch( + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), patch( f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb ): assert await async_setup_component( @@ -162,7 +162,9 @@ async def test_unique_ids_device(hass: HomeAssistant): mocked_bulb = _mocked_bulb() mocked_bulb.bulb_type = BulbType.WhiteTempMood - with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with _patch_discovery(MODULE), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -186,7 +188,9 @@ async def test_unique_ids_entry(hass: HomeAssistant): mocked_bulb = _mocked_bulb() mocked_bulb.bulb_type = BulbType.WhiteTempMood - with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with _patch_discovery(MODULE), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -216,7 +220,7 @@ async def test_bulb_off_while_adding_in_ha(hass: HomeAssistant): mocked_bulb = _mocked_bulb(True) mocked_bulb.bulb_type = BulbType.WhiteTempMood - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb), patch( + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), patch( f"{MODULE}.config_flow.yeelight.Bulb", return_value=mocked_bulb ): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -225,15 +229,52 @@ async def test_bulb_off_while_adding_in_ha(hass: HomeAssistant): binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( IP_ADDRESS.replace(".", "_") ) - entity_registry = er.async_get(hass) - assert entity_registry.async_get(binary_sensor_entity_id) is None type(mocked_bulb).get_capabilities = MagicMock(CAPABILITIES) type(mocked_bulb).get_properties = MagicMock(None) - hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE].update() + await hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][ + DATA_DEVICE + ].async_update() + hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][ + DATA_DEVICE + ].async_update_callback({}) await hass.async_block_till_done() await hass.async_block_till_done() entity_registry = er.async_get(hass) assert entity_registry.async_get(binary_sensor_entity_id) is not None + + +async def test_async_listen_error_late_discovery(hass, caplog): + """Test the async listen error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + mocked_bulb.async_listen = AsyncMock(side_effect=BulbException) + + with _patch_discovery(MODULE), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert "Failed to connect to bulb at" in caplog.text + + +async def test_async_listen_error_has_host(hass: HomeAssistant): + """Test the async listen error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_ID: ID, CONF_HOST: "127.0.0.1"} + ) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + mocked_bulb.async_listen = AsyncMock(side_effect=BulbException) + + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 9283514cb70..9a1f632242b 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -1,6 +1,6 @@ """Test the Yeelight light.""" import logging -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from yeelight import ( BulbException, @@ -131,7 +131,9 @@ async def test_services(hass: HomeAssistant, caplog): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with _patch_discovery(MODULE), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -146,8 +148,11 @@ async def test_services(hass: HomeAssistant, caplog): err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) # success - mocked_method = MagicMock() - setattr(type(mocked_bulb), method, mocked_method) + if method.startswith("async_"): + mocked_method = AsyncMock() + else: + mocked_method = MagicMock() + setattr(mocked_bulb, method, mocked_method) await hass.services.async_call(domain, service, data, blocking=True) if payload is None: mocked_method.assert_called_once() @@ -161,8 +166,11 @@ async def test_services(hass: HomeAssistant, caplog): # failure if failure_side_effect: - mocked_method = MagicMock(side_effect=failure_side_effect) - setattr(type(mocked_bulb), method, mocked_method) + if method.startswith("async_"): + mocked_method = AsyncMock(side_effect=failure_side_effect) + else: + mocked_method = MagicMock(side_effect=failure_side_effect) + setattr(mocked_bulb, method, mocked_method) await hass.services.async_call(domain, service, data, blocking=True) assert ( len([x for x in caplog.records if x.levelno == logging.ERROR]) @@ -173,6 +181,7 @@ async def test_services(hass: HomeAssistant, caplog): brightness = 100 rgb_color = (0, 128, 255) transition = 2 + mocked_bulb.last_properties["power"] = "off" await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -186,30 +195,30 @@ async def test_services(hass: HomeAssistant, caplog): }, blocking=True, ) - mocked_bulb.turn_on.assert_called_once_with( + mocked_bulb.async_turn_on.assert_called_once_with( duration=transition * 1000, light_type=LightType.Main, power_mode=PowerMode.NORMAL, ) - mocked_bulb.turn_on.reset_mock() + mocked_bulb.async_turn_on.reset_mock() mocked_bulb.start_music.assert_called_once() mocked_bulb.start_music.reset_mock() - mocked_bulb.set_brightness.assert_called_once_with( + mocked_bulb.async_set_brightness.assert_called_once_with( brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main ) - mocked_bulb.set_brightness.reset_mock() - mocked_bulb.set_color_temp.assert_not_called() - mocked_bulb.set_color_temp.reset_mock() - mocked_bulb.set_hsv.assert_not_called() - mocked_bulb.set_hsv.reset_mock() - mocked_bulb.set_rgb.assert_called_once_with( + mocked_bulb.async_set_brightness.reset_mock() + mocked_bulb.async_set_color_temp.assert_not_called() + mocked_bulb.async_set_color_temp.reset_mock() + mocked_bulb.async_set_hsv.assert_not_called() + mocked_bulb.async_set_hsv.reset_mock() + mocked_bulb.async_set_rgb.assert_called_once_with( *rgb_color, duration=transition * 1000, light_type=LightType.Main ) - mocked_bulb.set_rgb.reset_mock() - mocked_bulb.start_flow.assert_called_once() # flash - mocked_bulb.start_flow.reset_mock() - mocked_bulb.stop_flow.assert_called_once_with(light_type=LightType.Main) - mocked_bulb.stop_flow.reset_mock() + mocked_bulb.async_set_rgb.reset_mock() + mocked_bulb.async_start_flow.assert_called_once() # flash + mocked_bulb.async_start_flow.reset_mock() + mocked_bulb.async_stop_flow.assert_called_once_with(light_type=LightType.Main) + mocked_bulb.async_stop_flow.reset_mock() # turn_on hs_color brightness = 100 @@ -228,35 +237,36 @@ async def test_services(hass: HomeAssistant, caplog): }, blocking=True, ) - mocked_bulb.turn_on.assert_called_once_with( + mocked_bulb.async_turn_on.assert_called_once_with( duration=transition * 1000, light_type=LightType.Main, power_mode=PowerMode.NORMAL, ) - mocked_bulb.turn_on.reset_mock() + mocked_bulb.async_turn_on.reset_mock() mocked_bulb.start_music.assert_called_once() mocked_bulb.start_music.reset_mock() - mocked_bulb.set_brightness.assert_called_once_with( + mocked_bulb.async_set_brightness.assert_called_once_with( brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main ) - mocked_bulb.set_brightness.reset_mock() - mocked_bulb.set_color_temp.assert_not_called() - mocked_bulb.set_color_temp.reset_mock() - mocked_bulb.set_hsv.assert_called_once_with( + mocked_bulb.async_set_brightness.reset_mock() + mocked_bulb.async_set_color_temp.assert_not_called() + mocked_bulb.async_set_color_temp.reset_mock() + mocked_bulb.async_set_hsv.assert_called_once_with( *hs_color, duration=transition * 1000, light_type=LightType.Main ) - mocked_bulb.set_hsv.reset_mock() - mocked_bulb.set_rgb.assert_not_called() - mocked_bulb.set_rgb.reset_mock() - mocked_bulb.start_flow.assert_called_once() # flash - mocked_bulb.start_flow.reset_mock() - mocked_bulb.stop_flow.assert_called_once_with(light_type=LightType.Main) - mocked_bulb.stop_flow.reset_mock() + mocked_bulb.async_set_hsv.reset_mock() + mocked_bulb.async_set_rgb.assert_not_called() + mocked_bulb.async_set_rgb.reset_mock() + mocked_bulb.async_start_flow.assert_called_once() # flash + mocked_bulb.async_start_flow.reset_mock() + mocked_bulb.async_stop_flow.assert_called_once_with(light_type=LightType.Main) + mocked_bulb.async_stop_flow.reset_mock() # turn_on color_temp brightness = 100 color_temp = 200 transition = 1 + mocked_bulb.last_properties["power"] = "off" await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -270,31 +280,32 @@ async def test_services(hass: HomeAssistant, caplog): }, blocking=True, ) - mocked_bulb.turn_on.assert_called_once_with( + mocked_bulb.async_turn_on.assert_called_once_with( duration=transition * 1000, light_type=LightType.Main, power_mode=PowerMode.NORMAL, ) - mocked_bulb.turn_on.reset_mock() + mocked_bulb.async_turn_on.reset_mock() mocked_bulb.start_music.assert_called_once() - mocked_bulb.set_brightness.assert_called_once_with( + mocked_bulb.async_set_brightness.assert_called_once_with( brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main ) - mocked_bulb.set_color_temp.assert_called_once_with( + mocked_bulb.async_set_color_temp.assert_called_once_with( color_temperature_mired_to_kelvin(color_temp), duration=transition * 1000, light_type=LightType.Main, ) - mocked_bulb.set_hsv.assert_not_called() - mocked_bulb.set_rgb.assert_not_called() - mocked_bulb.start_flow.assert_called_once() # flash - mocked_bulb.stop_flow.assert_called_once_with(light_type=LightType.Main) + mocked_bulb.async_set_hsv.assert_not_called() + mocked_bulb.async_set_rgb.assert_not_called() + mocked_bulb.async_start_flow.assert_called_once() # flash + mocked_bulb.async_stop_flow.assert_called_once_with(light_type=LightType.Main) + mocked_bulb.last_properties["power"] = "off" # turn_on nightlight await _async_test_service( SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_NIGHTLIGHT}, - "turn_on", + "async_turn_on", payload={ "duration": DEFAULT_TRANSITION, "light_type": LightType.Main, @@ -303,11 +314,12 @@ async def test_services(hass: HomeAssistant, caplog): domain="light", ) + mocked_bulb.last_properties["power"] = "on" # turn_off await _async_test_service( SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITION: transition}, - "turn_off", + "async_turn_off", domain="light", payload={"duration": transition * 1000, "light_type": LightType.Main}, ) @@ -317,7 +329,7 @@ async def test_services(hass: HomeAssistant, caplog): await _async_test_service( SERVICE_SET_MODE, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MODE: "rgb"}, - "set_power_mode", + "async_set_power_mode", [PowerMode[mode.upper()]], ) @@ -328,7 +340,7 @@ async def test_services(hass: HomeAssistant, caplog): ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITIONS: [{YEELIGHT_TEMPERATURE_TRANSACTION: [1900, 2000, 60]}], }, - "start_flow", + "async_start_flow", ) # set_color_scene @@ -339,7 +351,7 @@ async def test_services(hass: HomeAssistant, caplog): ATTR_RGB_COLOR: [10, 20, 30], ATTR_BRIGHTNESS: 50, }, - "set_scene", + "async_set_scene", [SceneClass.COLOR, 10, 20, 30, 50], ) @@ -347,7 +359,7 @@ async def test_services(hass: HomeAssistant, caplog): await _async_test_service( SERVICE_SET_HSV_SCENE, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_HS_COLOR: [180, 50], ATTR_BRIGHTNESS: 50}, - "set_scene", + "async_set_scene", [SceneClass.HSV, 180, 50, 50], ) @@ -355,7 +367,7 @@ async def test_services(hass: HomeAssistant, caplog): await _async_test_service( SERVICE_SET_COLOR_TEMP_SCENE, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_KELVIN: 4000, ATTR_BRIGHTNESS: 50}, - "set_scene", + "async_set_scene", [SceneClass.CT, 4000, 50], ) @@ -366,14 +378,14 @@ async def test_services(hass: HomeAssistant, caplog): ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITIONS: [{YEELIGHT_TEMPERATURE_TRANSACTION: [1900, 2000, 60]}], }, - "set_scene", + "async_set_scene", ) # set_auto_delay_off_scene await _async_test_service( SERVICE_SET_AUTO_DELAY_OFF_SCENE, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MINUTES: 1, ATTR_BRIGHTNESS: 50}, - "set_scene", + "async_set_scene", [SceneClass.AUTO_DELAY_OFF, 50, 1], ) @@ -401,6 +413,7 @@ async def test_services(hass: HomeAssistant, caplog): failure_side_effect=None, ) # test _cmd wrapper error handler + mocked_bulb.last_properties["power"] = "off" err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) type(mocked_bulb).turn_on = MagicMock() type(mocked_bulb).set_brightness = MagicMock(side_effect=BulbException) @@ -424,8 +437,11 @@ async def test_device_types(hass: HomeAssistant, caplog): mocked_bulb.last_properties = properties async def _async_setup(config_entry): - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): - await hass.config_entries.async_setup(config_entry.entry_id) + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # We use asyncio.create_task now to avoid + # blocking starting so we need to block again await hass.async_block_till_done() async def _async_test( @@ -447,6 +463,7 @@ async def test_device_types(hass: HomeAssistant, caplog): await _async_setup(config_entry) state = hass.states.get(entity_id) + assert state.state == "on" target_properties["friendly_name"] = name target_properties["flowing"] = False @@ -481,6 +498,7 @@ async def test_device_types(hass: HomeAssistant, caplog): await hass.config_entries.async_unload(config_entry.entry_id) await config_entry.async_remove(hass) registry.async_clear_config_entry(config_entry.entry_id) + await hass.async_block_till_done() bright = round(255 * int(PROPERTIES["bright"]) / 100) current_brightness = round(255 * int(PROPERTIES["current_brightness"]) / 100) @@ -841,7 +859,9 @@ async def test_effects(hass: HomeAssistant): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with _patch_discovery(MODULE), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -850,8 +870,8 @@ async def test_effects(hass: HomeAssistant): ) == YEELIGHT_COLOR_EFFECT_LIST + ["mock_effect"] async def _async_test_effect(name, target=None, called=True): - mocked_start_flow = MagicMock() - type(mocked_bulb).start_flow = mocked_start_flow + async_mocked_start_flow = AsyncMock() + mocked_bulb.async_start_flow = async_mocked_start_flow await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -860,10 +880,10 @@ async def test_effects(hass: HomeAssistant): ) if not called: return - mocked_start_flow.assert_called_once() + async_mocked_start_flow.assert_called_once() if target is None: return - args, _ = mocked_start_flow.call_args + args, _ = async_mocked_start_flow.call_args flow = args[0] assert flow.count == target.count assert flow.action == target.action From 74d41ac5e52f05c36843b6b438be53e8676ddf3a Mon Sep 17 00:00:00 2001 From: Reuben Gow Date: Mon, 9 Aug 2021 19:47:38 +0100 Subject: [PATCH 249/903] Force an attempted subscribe on speaker reboot (#54100) * Force an attempted subscribe on speaker reboot * Recreate subscriptions and timers explicitly on speaker reboot * only create poll timer if there is not one already Co-authored-by: jjlawren * Black Co-authored-by: jjlawren --- homeassistant/components/sonos/speaker.py | 29 ++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 434717f7a85..919e03cf39b 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -496,9 +496,7 @@ class SonosSpeaker: self.async_write_entity_states() - async def async_unseen( - self, now: datetime.datetime | None = None, will_reconnect: bool = False - ) -> None: + async def async_unseen(self, now: datetime.datetime | None = None) -> None: """Make this player unavailable when it was not seen recently.""" if self._seen_timer: self._seen_timer() @@ -527,9 +525,8 @@ class SonosSpeaker: await self.async_unsubscribe() - if not will_reconnect: - self.hass.data[DATA_SONOS].discovery_known.discard(self.soco.uid) - self.async_write_entity_states() + self.hass.data[DATA_SONOS].discovery_known.discard(self.soco.uid) + self.async_write_entity_states() async def async_rebooted(self, soco: SoCo) -> None: """Handle a detected speaker reboot.""" @@ -538,8 +535,24 @@ class SonosSpeaker: self.zone_name, soco, ) - await self.async_unseen(will_reconnect=True) - await self.async_seen(soco) + await self.async_unsubscribe() + self.soco = soco + await self.async_subscribe() + if self._seen_timer: + self._seen_timer() + self._seen_timer = self.hass.helpers.event.async_call_later( + SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen + ) + if not self._poll_timer: + self._poll_timer = self.hass.helpers.event.async_track_time_interval( + partial( + async_dispatcher_send, + self.hass, + f"{SONOS_POLL_UPDATE}-{self.soco.uid}", + ), + SCAN_INTERVAL, + ) + self.async_write_entity_states() # # Battery management From 511af66b22186d795a0dfbbfb05100b806cee345 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 9 Aug 2021 20:53:30 +0200 Subject: [PATCH 250/903] Fix ondilo_ico name attribute (#54290) --- homeassistant/components/ondilo_ico/sensor.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 26a61ddfe4c..7449524d9e5 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -141,9 +141,9 @@ class OndiloICO(CoordinatorEntity, SensorEntity): self._poolid = self.coordinator.data[poolidx]["id"] pooldata = self._pooldata() - self._unique_id = f"{pooldata['ICO']['serial_number']}-{description.key}" + self._attr_unique_id = f"{pooldata['ICO']['serial_number']}-{description.key}" self._device_name = pooldata["name"] - self._name = f"{self._device_name} {description.name}" + self._attr_name = f"{self._device_name} {description.name}" def _pooldata(self): """Get pool data dict.""" @@ -168,11 +168,6 @@ class OndiloICO(CoordinatorEntity, SensorEntity): """Last value of the sensor.""" return self._devdata()["value"] - @property - def unique_id(self): - """Return the unique ID of this entity.""" - return self._unique_id - @property def device_info(self): """Return the device info for the sensor.""" From a54ee7b3664079399111fdd4a72c69c6ed81201e Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Mon, 9 Aug 2021 14:55:58 -0400 Subject: [PATCH 251/903] Use correct state attribute for alarmdecoder binary sensor (#54286) Co-authored-by: Martin Hjelmare --- homeassistant/components/alarmdecoder/binary_sensor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py index 397394e256b..430a4f73262 100644 --- a/homeassistant/components/alarmdecoder/binary_sensor.py +++ b/homeassistant/components/alarmdecoder/binary_sensor.py @@ -111,13 +111,13 @@ class AlarmDecoderBinarySensor(BinarySensorEntity): def _fault_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: - self._attr_state = 1 + self._attr_is_on = True self.schedule_update_ha_state() def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or (int(zone) == self._zone_number and not self._loop): - self._attr_state = 0 + self._attr_is_on = False self.schedule_update_ha_state() def _rfx_message_callback(self, message): @@ -125,7 +125,7 @@ class AlarmDecoderBinarySensor(BinarySensorEntity): if self._rfid and message and message.serial_number == self._rfid: rfstate = message.value if self._loop: - self._attr_state = 1 if message.loop[self._loop - 1] else 0 + self._attr_is_on = bool(message.loop[self._loop - 1]) attr = {CONF_ZONE_NUMBER: self._zone_number} if self._rfid and rfstate is not None: attr[ATTR_RF_BIT0] = bool(rfstate & 0x01) @@ -150,5 +150,5 @@ class AlarmDecoderBinarySensor(BinarySensorEntity): message.channel, message.value, ) - self._attr_state = message.value + self._attr_is_on = bool(message.value) self.schedule_update_ha_state() From 01490958246f319bb6b1264d3312e12e505b63a9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 9 Aug 2021 20:57:36 +0200 Subject: [PATCH 252/903] Fix xiaomi air fresh fan preset modes (#54342) --- homeassistant/components/xiaomi_miio/fan.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index c58d9ad0c66..05e32507b20 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1138,6 +1138,7 @@ class XiaomiAirFresh(XiaomiGenericDevice): self._speed_list = OPERATION_MODES_AIRFRESH self._speed_count = 4 self._preset_modes = PRESET_MODES_AIRFRESH + self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE self._state_attrs.update( {attribute: None for attribute in self._available_attributes} ) From f37b164d602bf79ff446d425e513aa5e634fae80 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Aug 2021 13:58:27 -0500 Subject: [PATCH 253/903] Bump zeroconf to 0.34.3 (#54294) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 1847a1c806b..83db312601c 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.33.4"], + "requirements": ["zeroconf==0.34.3"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6d22aa51b24..963cccf9ad2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.17 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.33.4 +zeroconf==0.34.3 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 1ce754f9db1..c6d29e84dec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2439,7 +2439,7 @@ zeep[async]==4.0.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.33.4 +zeroconf==0.34.3 # homeassistant.components.zha zha-quirks==0.0.59 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5fc75e41ae..a96149ad0b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1347,7 +1347,7 @@ youless-api==0.10 zeep[async]==4.0.0 # homeassistant.components.zeroconf -zeroconf==0.33.4 +zeroconf==0.34.3 # homeassistant.components.zha zha-quirks==0.0.59 From 8eff0e9312b734b8965befb9d2c04c0f2b9f7498 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Aug 2021 14:03:55 -0500 Subject: [PATCH 254/903] Ensure hunterdouglas_powerview model type is a string (#54299) --- homeassistant/components/hunterdouglas_powerview/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index bf0d5d564ff..db4b984703c 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -71,7 +71,7 @@ class ShadeEntity(HDEntity): "name": self._shade_name, "suggested_area": self._room_name, "manufacturer": MANUFACTURER, - "model": self._shade.raw_data[ATTR_TYPE], + "model": str(self._shade.raw_data[ATTR_TYPE]), "via_device": (DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER]), } From 7050b532648a6444394309d813bbec9a457faddd Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Mon, 9 Aug 2021 21:11:53 +0200 Subject: [PATCH 255/903] Fix login to BMW services for rest_of_world and north_america (#54261) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 17aaa166942..8131ac1415c 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.16"], + "requirements": ["bimmer_connected==0.7.18"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index c6d29e84dec..1d52b811eb9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,7 +368,7 @@ beautifulsoup4==4.9.3 bellows==0.26.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.16 +bimmer_connected==0.7.18 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a96149ad0b4..1bec0b54f5b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -223,7 +223,7 @@ base36==0.1.1 bellows==0.26.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.16 +bimmer_connected==0.7.18 # homeassistant.components.blebox blebox_uniapi==1.3.3 From 74a30af79ba491197d3f48c7b07e2e2217249de7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Aug 2021 14:13:55 -0500 Subject: [PATCH 256/903] Always set interfaces explicitly when IPv6 is present (#54268) --- homeassistant/components/zeroconf/__init__.py | 17 +++---- tests/components/zeroconf/test_init.py | 47 +++++++++++++++++-- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index cdb46318578..e7132f56b55 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -142,7 +142,15 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: zc_args: dict = {} adapters = await network.async_get_adapters(hass) - if _async_use_default_interface(adapters): + + ipv6 = True + if not any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): + ipv6 = False + zc_args["ip_version"] = IPVersion.V4Only + else: + zc_args["ip_version"] = IPVersion.All + + if not ipv6 and _async_use_default_interface(adapters): zc_args["interfaces"] = InterfaceChoice.Default else: interfaces = zc_args["interfaces"] = [] @@ -158,13 +166,6 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: if adapter["ipv6"] and adapter["index"] not in interfaces: interfaces.append(adapter["index"]) - ipv6 = True - if not any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): - ipv6 = False - zc_args["ip_version"] = IPVersion.V4Only - else: - zc_args["ip_version"] = IPVersion.All - aio_zc = await _async_get_instance(hass, **zc_args) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types, homekit_models = await asyncio.gather( diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index e1e346621fe..0db8f0f5227 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -794,11 +794,6 @@ async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zero ), patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, - ), patch( - "socket.if_nametoindex", - side_effect=lambda iface: {"eth0": 1, "eth1": 2, "eth2": 3, "vtun0": 4}.get( - iface, 0 - ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -827,3 +822,45 @@ async def test_get_announced_addresses(hass, mock_async_zeroconf): first_ip = ip_address("192.168.1.5").packed actual = _get_announced_addresses(_ADAPTERS_WITH_MANUAL_CONFIG, first_ip) assert actual[0] == first_ip and set(actual) == expected + + +_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6 = [ + { + "auto": True, + "default": True, + "enabled": True, + "index": 1, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [ + { + "address": "fe80::dead:beef:dead:beef", + "network_prefix": 64, + "flowinfo": 1, + "scope_id": 3, + } + ], + "name": "eth1", + } +] + + +async def test_async_detect_interfaces_explicitly_set_ipv6(hass, mock_async_zeroconf): + """Test interfaces are explicitly set when IPv6 is present.""" + with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object( + hass.config_entries.flow, "async_init" + ), patch.object( + zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, + ), patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_service_info_mock, + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + 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", 1], ip_version=IPVersion.All + ) From 38cb0553f3a791f9960af3fcb8fdeb683fc2dd87 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 9 Aug 2021 22:27:09 +0200 Subject: [PATCH 257/903] Update frontend to 20210809.0 (#54350) --- homeassistant/components/frontend/manifest.json | 10 +++++++--- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b9a84cbec02..135c0ec0244 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,9 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20210804.0"], + "requirements": [ + "home-assistant-frontend==20210809.0" + ], "dependencies": [ "api", "auth", @@ -15,6 +17,8 @@ "system_log", "websocket_api" ], - "codeowners": ["@home-assistant/frontend"], + "codeowners": [ + "@home-assistant/frontend" + ], "quality_scale": "internal" -} +} \ No newline at end of file diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 963cccf9ad2..2e07d0adc18 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210804.0 +home-assistant-frontend==20210809.0 httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 1d52b811eb9..b71b7fcc2c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -786,7 +786,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210804.0 +home-assistant-frontend==20210809.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1bec0b54f5b..774721010f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -452,7 +452,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210804.0 +home-assistant-frontend==20210809.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From d79fc2c5066cd6235d2d21936ad1751b68d8a292 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 9 Aug 2021 23:38:58 +0200 Subject: [PATCH 258/903] Use EntityDescription - pi_hole (#54319) --- homeassistant/components/pi_hole/const.py | 83 +++++++++++++++------- homeassistant/components/pi_hole/sensor.py | 44 +++--------- 2 files changed, 69 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index f1871bf27c8..40a3a16de3a 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -1,6 +1,9 @@ """Constants for the pi_hole integration.""" +from __future__ import annotations + from datetime import timedelta +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import PERCENTAGE DOMAIN = "pi_hole" @@ -25,28 +28,60 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) DATA_KEY_API = "api" DATA_KEY_COORDINATOR = "coordinator" -SENSOR_DICT = { - "ads_blocked_today": ["Ads Blocked Today", "ads", "mdi:close-octagon-outline"], - "ads_percentage_today": [ - "Ads Percentage Blocked Today", - PERCENTAGE, - "mdi:close-octagon-outline", - ], - "clients_ever_seen": ["Seen Clients", "clients", "mdi:account-outline"], - "dns_queries_today": [ - "DNS Queries Today", - "queries", - "mdi:comment-question-outline", - ], - "domains_being_blocked": ["Domains Blocked", "domains", "mdi:block-helper"], - "queries_cached": ["DNS Queries Cached", "queries", "mdi:comment-question-outline"], - "queries_forwarded": [ - "DNS Queries Forwarded", - "queries", - "mdi:comment-question-outline", - ], - "unique_clients": ["DNS Unique Clients", "clients", "mdi:account-outline"], - "unique_domains": ["DNS Unique Domains", "domains", "mdi:domain"], -} -SENSOR_LIST = list(SENSOR_DICT) +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="ads_blocked_today", + name="Ads Blocked Today", + unit_of_measurement="ads", + icon="mdi:close-octagon-outline", + ), + SensorEntityDescription( + key="ads_percentage_today", + name="Ads Percentage Blocked Today", + unit_of_measurement=PERCENTAGE, + icon="mdi:close-octagon-outline", + ), + SensorEntityDescription( + key="clients_ever_seen", + name="Seen Clients", + unit_of_measurement="clients", + icon="mdi:account-outline", + ), + SensorEntityDescription( + key="dns_queries_today", + name="DNS Queries Today", + unit_of_measurement="queries", + icon="mdi:comment-question-outline", + ), + SensorEntityDescription( + key="domains_being_blocked", + name="Domains Blocked", + unit_of_measurement="domains", + icon="mdi:block-helper", + ), + SensorEntityDescription( + key="queries_cached", + name="DNS Queries Cached", + unit_of_measurement="queries", + icon="mdi:comment-question-outline", + ), + SensorEntityDescription( + key="queries_forwarded", + name="DNS Queries Forwarded", + unit_of_measurement="queries", + icon="mdi:comment-question-outline", + ), + SensorEntityDescription( + key="unique_clients", + name="DNS Unique Clients", + unit_of_measurement="clients", + icon="mdi:account-outline", + ), + SensorEntityDescription( + key="unique_domains", + name="DNS Unique Domains", + unit_of_measurement="domains", + icon="mdi:domain", + ), +) diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 95aee56f7cc..38b0b192e14 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -5,7 +5,7 @@ from typing import Any from hole import Hole -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant @@ -18,8 +18,7 @@ from .const import ( DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN, - SENSOR_DICT, - SENSOR_LIST, + SENSOR_TYPES, ) @@ -34,10 +33,10 @@ async def async_setup_entry( hole_data[DATA_KEY_API], hole_data[DATA_KEY_COORDINATOR], name, - sensor_name, entry.entry_id, + description, ) - for sensor_name in SENSOR_LIST + for description in SENSOR_TYPES ] async_add_entities(sensors, True) @@ -50,46 +49,23 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): api: Hole, coordinator: DataUpdateCoordinator, name: str, - sensor_name: str, server_unique_id: str, + description: SensorEntityDescription, ) -> None: """Initialize a Pi-hole sensor.""" super().__init__(api, coordinator, name, server_unique_id) + self.entity_description = description - self._condition = sensor_name - - variable_info = SENSOR_DICT[sensor_name] - self._condition_name = variable_info[0] - self._unit_of_measurement = variable_info[1] - self._icon = variable_info[2] - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._name} {self._condition_name}" - - @property - def unique_id(self) -> str: - """Return the unique id of the sensor.""" - return f"{self._server_unique_id}/{self._condition_name}" - - @property - def icon(self) -> str: - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def unit_of_measurement(self) -> str: - """Return the unit the value is expressed in.""" - return self._unit_of_measurement + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{self._server_unique_id}/{description.name}" @property def state(self) -> Any: """Return the state of the device.""" try: - return round(self.api.data[self._condition], 2) + return round(self.api.data[self.entity_description.key], 2) except TypeError: - return self.api.data[self._condition] + return self.api.data[self.entity_description.key] @property def extra_state_attributes(self) -> dict[str, Any]: From 4133cc05ebe539a6574fc344e729b51dd24a5f10 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 9 Aug 2021 23:40:57 +0200 Subject: [PATCH 259/903] Use EntityDescription - abode (#54321) --- homeassistant/components/abode/sensor.py | 60 +++++++++++++++--------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index f1f744a5511..a0681e0440f 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -1,7 +1,9 @@ """Support for Abode Security System sensors.""" +from __future__ import annotations + import abodepy.helpers.constants as CONST -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -11,12 +13,23 @@ from homeassistant.const import ( from . import AbodeDevice from .const import DOMAIN -# Sensor types: Name, icon -SENSOR_TYPES = { - CONST.TEMP_STATUS_KEY: ["Temperature", DEVICE_CLASS_TEMPERATURE], - CONST.HUMI_STATUS_KEY: ["Humidity", DEVICE_CLASS_HUMIDITY], - CONST.LUX_STATUS_KEY: ["Lux", DEVICE_CLASS_ILLUMINANCE], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=CONST.TEMP_STATUS_KEY, + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=CONST.HUMI_STATUS_KEY, + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=CONST.LUX_STATUS_KEY, + name="Lux", + device_class=DEVICE_CLASS_ILLUMINANCE, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -26,10 +39,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] for device in data.abode.get_devices(generic_type=CONST.TYPE_SENSOR): - for sensor_type in SENSOR_TYPES: - if sensor_type not in device.get_value(CONST.STATUSES_KEY): - continue - entities.append(AbodeSensor(data, device, sensor_type)) + conditions = device.get_value(CONST.STATUSES_KEY) + entities.extend( + [ + AbodeSensor(data, device, description) + for description in SENSOR_TYPES + if description.key in conditions + ] + ) async_add_entities(entities) @@ -37,26 +54,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AbodeSensor(AbodeDevice, SensorEntity): """A sensor implementation for Abode devices.""" - def __init__(self, data, device, sensor_type): + def __init__(self, data, device, description: SensorEntityDescription): """Initialize a sensor for an Abode device.""" super().__init__(data, device) - self._sensor_type = sensor_type - self._attr_name = f"{device.name} {SENSOR_TYPES[sensor_type][0]}" - self._attr_device_class = SENSOR_TYPES[self._sensor_type][1] - self._attr_unique_id = f"{device.device_uuid}-{sensor_type}" - if self._sensor_type == CONST.TEMP_STATUS_KEY: + self.entity_description = description + self._attr_name = f"{device.name} {description.name}" + self._attr_unique_id = f"{device.device_uuid}-{description.key}" + if description.key == CONST.TEMP_STATUS_KEY: self._attr_unit_of_measurement = device.temp_unit - elif self._sensor_type == CONST.HUMI_STATUS_KEY: + elif description.key == CONST.HUMI_STATUS_KEY: self._attr_unit_of_measurement = device.humidity_unit - elif self._sensor_type == CONST.LUX_STATUS_KEY: + elif description.key == CONST.LUX_STATUS_KEY: self._attr_unit_of_measurement = device.lux_unit @property def state(self): """Return the state of the sensor.""" - if self._sensor_type == CONST.TEMP_STATUS_KEY: + if self.entity_description.key == CONST.TEMP_STATUS_KEY: return self._device.temp - if self._sensor_type == CONST.HUMI_STATUS_KEY: + if self.entity_description.key == CONST.HUMI_STATUS_KEY: return self._device.humidity - if self._sensor_type == CONST.LUX_STATUS_KEY: + if self.entity_description.key == CONST.LUX_STATUS_KEY: return self._device.lux From d55c7640485806439307f93c26e7db5af312c1fa Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 9 Aug 2021 23:43:59 +0200 Subject: [PATCH 260/903] Fix Xiaomi-miio turn fan on with speed, percentage or preset (#54353) --- homeassistant/components/xiaomi_miio/fan.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 05e32507b20..feeadf2bccc 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -615,6 +615,10 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): **kwargs, ) -> None: """Turn the device on.""" + result = await self._try_command( + "Turning the miio device on failed.", self._device.on + ) + # Remove the async_set_speed call is async_set_percentage and async_set_preset_modes have been implemented if speed: await self.async_set_speed(speed) @@ -623,10 +627,6 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): await self.async_set_percentage(percentage) if preset_mode: await self.async_set_preset_mode(preset_mode) - else: - result = await self._try_command( - "Turning the miio device on failed.", self._device.on - ) if result: self._state = True From c07b1423ee6a185899020ae9d239e0fe1f379307 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Mon, 9 Aug 2021 23:45:15 +0200 Subject: [PATCH 261/903] Remove useless attribute in devolo Home Control (#54284) --- homeassistant/components/devolo_home_control/devolo_device.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/devolo_home_control/devolo_device.py b/homeassistant/components/devolo_home_control/devolo_device.py index 781799cbf37..03f850579be 100644 --- a/homeassistant/components/devolo_home_control/devolo_device.py +++ b/homeassistant/components/devolo_home_control/devolo_device.py @@ -45,7 +45,6 @@ class DevoloDeviceEntity(Entity): self.subscriber: Subscriber | None = None self.sync_callback = self._sync self._value: int - self._unit = "" async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" From dcf4eb5e0dc5b79905a1a8acaa12dcfa6075f53d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Aug 2021 16:49:51 -0500 Subject: [PATCH 262/903] Remove HomeKit event guards (#54343) --- .../components/homekit/type_covers.py | 28 ++++------- homeassistant/components/homekit/type_fans.py | 14 ++---- .../components/homekit/type_humidifiers.py | 11 ++--- .../components/homekit/type_lights.py | 26 ++++------- .../components/homekit/type_locks.py | 8 +--- .../components/homekit/type_media_players.py | 18 +++----- .../components/homekit/type_remotes.py | 9 ++-- .../homekit/type_security_systems.py | 19 ++++---- .../components/homekit/type_sensors.py | 39 +++++++--------- .../components/homekit/type_switches.py | 28 +++++------ .../components/homekit/type_thermostats.py | 46 ++++++------------- 11 files changed, 84 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 099eced62d3..4c501208ca5 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -178,18 +178,11 @@ class GarageDoorOpener(HomeAccessory): obstruction_detected = ( new_state.attributes[ATTR_OBSTRUCTION_DETECTED] is True ) - if self.char_obstruction_detected.value != obstruction_detected: - self.char_obstruction_detected.set_value(obstruction_detected) + self.char_obstruction_detected.set_value(obstruction_detected) - if ( - target_door_state is not None - and self.char_target_state.value != target_door_state - ): + if target_door_state is not None: self.char_target_state.set_value(target_door_state) - if ( - current_door_state is not None - and self.char_current_state.value != current_door_state - ): + if current_door_state is not None: self.char_current_state.set_value(current_door_state) @@ -260,10 +253,8 @@ class OpeningDeviceBase(HomeAccessory): # We'll have to normalize to [0,100] current_tilt = (current_tilt / 100.0 * 180.0) - 90.0 current_tilt = int(current_tilt) - if self.char_current_tilt.value != current_tilt: - self.char_current_tilt.set_value(current_tilt) - if self.char_target_tilt.value != current_tilt: - self.char_target_tilt.set_value(current_tilt) + self.char_current_tilt.set_value(current_tilt) + self.char_target_tilt.set_value(current_tilt) class OpeningDevice(OpeningDeviceBase, HomeAccessory): @@ -312,14 +303,11 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): current_position = new_state.attributes.get(ATTR_CURRENT_POSITION) if isinstance(current_position, (float, int)): current_position = int(current_position) - if self.char_current_position.value != current_position: - self.char_current_position.set_value(current_position) - if self.char_target_position.value != current_position: - self.char_target_position.set_value(current_position) + self.char_current_position.set_value(current_position) + self.char_target_position.set_value(current_position) position_state = _hass_state_to_position_start(new_state.state) - if self.char_position_state.value != position_state: - self.char_position_state.set_value(position_state) + self.char_position_state.set_value(position_state) super().async_update_state(new_state) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 1a0bb41774c..85157dd9367 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -193,16 +193,14 @@ class Fan(HomeAccessory): state = new_state.state if state in (STATE_ON, STATE_OFF): self._state = 1 if state == STATE_ON else 0 - if self.char_active.value != self._state: - self.char_active.set_value(self._state) + self.char_active.set_value(self._state) # Handle Direction if self.char_direction is not None: direction = new_state.attributes.get(ATTR_DIRECTION) if direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): hk_direction = 1 if direction == DIRECTION_REVERSE else 0 - if self.char_direction.value != hk_direction: - self.char_direction.set_value(hk_direction) + self.char_direction.set_value(hk_direction) # Handle Speed if self.char_speed is not None and state != STATE_OFF: @@ -222,7 +220,7 @@ class Fan(HomeAccessory): # in order to avoid this incorrect behavior. if percentage == 0 and state == STATE_ON: percentage = 1 - if percentage is not None and self.char_speed.value != percentage: + if percentage is not None: self.char_speed.set_value(percentage) # Handle Oscillating @@ -230,11 +228,9 @@ class Fan(HomeAccessory): oscillating = new_state.attributes.get(ATTR_OSCILLATING) if isinstance(oscillating, bool): hk_oscillating = 1 if oscillating else 0 - if self.char_swing.value != hk_oscillating: - self.char_swing.set_value(hk_oscillating) + self.char_swing.set_value(hk_oscillating) current_preset_mode = new_state.attributes.get(ATTR_PRESET_MODE) for preset_mode, char in self.preset_mode_chars.items(): hk_value = 1 if preset_mode == current_preset_mode else 0 - if char.value != hk_value: - char.set_value(hk_value) + char.set_value(hk_value) diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index a4a73abf998..6371f883b09 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -224,8 +224,7 @@ class HumidifierDehumidifier(HomeAccessory): is_active = new_state.state == STATE_ON # Update active state - if self.char_active.value != is_active: - self.char_active.set_value(is_active) + self.char_active.set_value(is_active) # Set current state if is_active: @@ -235,13 +234,9 @@ class HumidifierDehumidifier(HomeAccessory): current_state = HC_STATE_DEHUMIDIFYING else: current_state = HC_STATE_INACTIVE - if self.char_current_humidifier_dehumidifier.value != current_state: - self.char_current_humidifier_dehumidifier.set_value(current_state) + self.char_current_humidifier_dehumidifier.set_value(current_state) # Update target humidity target_humidity = new_state.attributes.get(ATTR_HUMIDITY) - if ( - isinstance(target_humidity, (int, float)) - and self.char_target_humidity.value != target_humidity - ): + if isinstance(target_humidity, (int, float)): self.char_target_humidity.set_value(target_humidity) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 88e21272a4f..169130a194a 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -205,13 +205,10 @@ class Light(HomeAccessory): color_temp_mode = color_mode == COLOR_MODE_COLOR_TEMP primary_on_value = char_on_value if not color_temp_mode else 0 secondary_on_value = char_on_value if color_temp_mode else 0 - if self.char_on_primary.value != primary_on_value: - self.char_on_primary.set_value(primary_on_value) - if self.char_on_secondary.value != secondary_on_value: - self.char_on_secondary.set_value(secondary_on_value) + self.char_on_primary.set_value(primary_on_value) + self.char_on_secondary.set_value(secondary_on_value) else: - if self.char_on_primary.value != char_on_value: - self.char_on_primary.set_value(char_on_value) + self.char_on_primary.set_value(char_on_value) # Handle Brightness if self.is_brightness_supported: @@ -230,12 +227,8 @@ class Light(HomeAccessory): # order to avoid this incorrect behavior. if brightness == 0 and state == STATE_ON: brightness = 1 - if self.char_brightness_primary.value != brightness: - self.char_brightness_primary.set_value(brightness) - if ( - self.color_and_temp_supported - and self.char_brightness_secondary.value != brightness - ): + self.char_brightness_primary.set_value(brightness) + if self.color_and_temp_supported: self.char_brightness_secondary.set_value(brightness) # Handle color temperature @@ -243,8 +236,7 @@ class Light(HomeAccessory): color_temperature = attributes.get(ATTR_COLOR_TEMP) if isinstance(color_temperature, (int, float)): color_temperature = round(color_temperature, 0) - if self.char_color_temperature.value != color_temperature: - self.char_color_temperature.set_value(color_temperature) + self.char_color_temperature.set_value(color_temperature) # Handle Color if self.is_color_supported: @@ -252,7 +244,5 @@ class Light(HomeAccessory): if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)): hue = round(hue, 0) saturation = round(saturation, 0) - if hue != self.char_hue.value: - self.char_hue.set_value(hue) - if saturation != self.char_saturation.value: - self.char_saturation.set_value(saturation) + self.char_hue.set_value(hue) + self.char_saturation.set_value(saturation) diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 3a10a0a2f5a..af7501e1869 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -106,14 +106,10 @@ class Lock(HomeAccessory): # LockTargetState only supports locked and unlocked # Must set lock target state before current state # or there will be no notification - if ( - target_lock_state is not None - and self.char_target_state.value != target_lock_state - ): + if target_lock_state is not None: self.char_target_state.set_value(target_lock_state) # Set lock current state ONLY after ensuring that # target state is correct or there will be no # notification - if self.char_current_state.value != current_lock_state: - self.char_current_state.set_value(current_lock_state) + self.char_current_state.set_value(current_lock_state) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 081053d2591..7be1b98dcdb 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -180,8 +180,7 @@ class MediaPlayer(HomeAccessory): _LOGGER.debug( '%s: Set current state for "on_off" to %s', self.entity_id, hk_state ) - if self.chars[FEATURE_ON_OFF].value != hk_state: - self.chars[FEATURE_ON_OFF].set_value(hk_state) + self.chars[FEATURE_ON_OFF].set_value(hk_state) if self.chars[FEATURE_PLAY_PAUSE]: hk_state = current_state == STATE_PLAYING @@ -190,8 +189,7 @@ class MediaPlayer(HomeAccessory): self.entity_id, hk_state, ) - if self.chars[FEATURE_PLAY_PAUSE].value != hk_state: - self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) + self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) if self.chars[FEATURE_PLAY_STOP]: hk_state = current_state == STATE_PLAYING @@ -200,8 +198,7 @@ class MediaPlayer(HomeAccessory): self.entity_id, hk_state, ) - if self.chars[FEATURE_PLAY_STOP].value != hk_state: - self.chars[FEATURE_PLAY_STOP].set_value(hk_state) + self.chars[FEATURE_PLAY_STOP].set_value(hk_state) if self.chars[FEATURE_TOGGLE_MUTE]: current_state = bool(new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED)) @@ -210,8 +207,7 @@ class MediaPlayer(HomeAccessory): self.entity_id, current_state, ) - if self.chars[FEATURE_TOGGLE_MUTE].value != current_state: - self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) + self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) @TYPES.register("TelevisionMediaPlayer") @@ -341,8 +337,7 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): if current_state not in MEDIA_PLAYER_OFF_STATES: hk_state = 1 _LOGGER.debug("%s: Set current active state to %s", self.entity_id, hk_state) - if self.char_active.value != hk_state: - self.char_active.set_value(hk_state) + self.char_active.set_value(hk_state) # Set mute state if CHAR_VOLUME_SELECTOR in self.chars_speaker: @@ -352,7 +347,6 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): self.entity_id, current_mute_state, ) - if self.char_mute.value != current_mute_state: - self.char_mute.set_value(current_mute_state) + self.char_mute.set_value(current_mute_state) self._async_update_input_state(hk_state, new_state) diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 9e54221430c..53659adef77 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -154,8 +154,7 @@ class RemoteInputSelectAccessory(HomeAccessory): _LOGGER.debug("%s: Set current input to %s", self.entity_id, source_name) if source_name in self.sources: index = self.sources.index(source_name) - if self.char_input_source.value != index: - self.char_input_source.set_value(index) + self.char_input_source.set_value(index) return possible_sources = new_state.attributes.get(self.source_list_key, []) @@ -174,8 +173,7 @@ class RemoteInputSelectAccessory(HomeAccessory): source_name, possible_sources, ) - if self.char_input_source.value != 0: - self.char_input_source.set_value(0) + self.char_input_source.set_value(0) @TYPES.register("ActivityRemote") @@ -225,7 +223,6 @@ class ActivityRemote(RemoteInputSelectAccessory): # Power state remote hk_state = 1 if current_state == STATE_ON else 0 _LOGGER.debug("%s: Set current active state to %s", self.entity_id, hk_state) - if self.char_active.value != hk_state: - self.char_active.set_value(hk_state) + self.char_active.set_value(hk_state) self._async_update_input_state(hk_state, new_state) diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 6fe1a4e9e29..d76fbf0f534 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -158,15 +158,12 @@ class SecuritySystem(HomeAccessory): """Update security state after state changed.""" hass_state = new_state.state if (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None: - if self.char_current_state.value != current_state: - self.char_current_state.set_value(current_state) - _LOGGER.debug( - "%s: Updated current state to %s (%d)", - self.entity_id, - hass_state, - current_state, - ) - + self.char_current_state.set_value(current_state) + _LOGGER.debug( + "%s: Updated current state to %s (%d)", + self.entity_id, + hass_state, + current_state, + ) if (target_state := HASS_TO_HOMEKIT_TARGET.get(hass_state)) is not None: - if self.char_target_state.value != target_state: - self.char_target_state.set_value(target_state) + self.char_target_state.set_value(target_state) diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index b6cc4b05125..bcef7564fa3 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -101,11 +101,10 @@ class TemperatureSensor(HomeAccessory): temperature = convert_to_float(new_state.state) if temperature: temperature = temperature_to_homekit(temperature, unit) - if self.char_temp.value != temperature: - self.char_temp.set_value(temperature) - _LOGGER.debug( - "%s: Current temperature set to %.1f°C", self.entity_id, temperature - ) + self.char_temp.set_value(temperature) + _LOGGER.debug( + "%s: Current temperature set to %.1f°C", self.entity_id, temperature + ) @TYPES.register("HumiditySensor") @@ -128,7 +127,7 @@ class HumiditySensor(HomeAccessory): def async_update_state(self, new_state): """Update accessory after state change.""" humidity = convert_to_float(new_state.state) - if humidity and self.char_humidity.value != humidity: + if humidity: self.char_humidity.set_value(humidity) _LOGGER.debug("%s: Percent set to %d%%", self.entity_id, humidity) @@ -161,9 +160,8 @@ class AirQualitySensor(HomeAccessory): self.char_density.set_value(density) _LOGGER.debug("%s: Set density to %d", self.entity_id, density) air_quality = density_to_air_quality(density) - if self.char_quality.value != air_quality: - self.char_quality.set_value(air_quality) - _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) + self.char_quality.set_value(air_quality) + _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) @TYPES.register("CarbonMonoxideSensor") @@ -194,14 +192,12 @@ class CarbonMonoxideSensor(HomeAccessory): """Update accessory after state change.""" value = convert_to_float(new_state.state) if value: - if self.char_level.value != value: - self.char_level.set_value(value) + self.char_level.set_value(value) if value > self.char_peak.value: self.char_peak.set_value(value) co_detected = value > THRESHOLD_CO - if self.char_detected.value is not co_detected: - self.char_detected.set_value(co_detected) - _LOGGER.debug("%s: Set to %d", self.entity_id, value) + self.char_detected.set_value(co_detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) @TYPES.register("CarbonDioxideSensor") @@ -232,14 +228,12 @@ class CarbonDioxideSensor(HomeAccessory): """Update accessory after state change.""" value = convert_to_float(new_state.state) if value: - if self.char_level.value != value: - self.char_level.set_value(value) + self.char_level.set_value(value) if value > self.char_peak.value: self.char_peak.set_value(value) co2_detected = value > THRESHOLD_CO2 - if self.char_detected.value is not co2_detected: - self.char_detected.set_value(co2_detected) - _LOGGER.debug("%s: Set to %d", self.entity_id, value) + self.char_detected.set_value(co2_detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) @TYPES.register("LightSensor") @@ -262,7 +256,7 @@ class LightSensor(HomeAccessory): def async_update_state(self, new_state): """Update accessory after state change.""" luminance = convert_to_float(new_state.state) - if luminance and self.char_light.value != luminance: + if luminance: self.char_light.set_value(luminance) _LOGGER.debug("%s: Set to %d", self.entity_id, luminance) @@ -297,6 +291,5 @@ class BinarySensor(HomeAccessory): """Update accessory after state change.""" state = new_state.state detected = self.format(state in (STATE_ON, STATE_HOME)) - if self.char_detected.value != detected: - self.char_detected.set_value(detected) - _LOGGER.debug("%s: Set to %d", self.entity_id, detected) + self.char_detected.set_value(detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, detected) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index ef9dadff287..3bb496a2abc 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -91,9 +91,8 @@ class Outlet(HomeAccessory): def async_update_state(self, new_state): """Update switch state after state changed.""" current_state = new_state.state == STATE_ON - if self.char_on.value is not current_state: - _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) - self.char_on.set_value(current_state) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) @TYPES.register("Switch") @@ -123,8 +122,7 @@ class Switch(HomeAccessory): def reset_switch(self, *args): """Reset switch to emulate activate click.""" _LOGGER.debug("%s: Reset switch to off", self.entity_id) - if self.char_on.value is not False: - self.char_on.set_value(False) + self.char_on.set_value(False) def set_state(self, value): """Move switch state to value if call came from HomeKit.""" @@ -156,9 +154,8 @@ class Switch(HomeAccessory): return current_state = new_state.state == STATE_ON - if self.char_on.value is not current_state: - _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) - self.char_on.set_value(current_state) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) @TYPES.register("Vacuum") @@ -186,9 +183,8 @@ class Vacuum(Switch): def async_update_state(self, new_state): """Update switch state after state changed.""" current_state = new_state.state in (STATE_CLEANING, STATE_ON) - if self.char_on.value is not current_state: - _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) - self.char_on.set_value(current_state) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) @TYPES.register("Valve") @@ -226,9 +222,7 @@ class Valve(HomeAccessory): def async_update_state(self, new_state): """Update switch state after state changed.""" current_state = 1 if new_state.state == STATE_ON else 0 - if self.char_active.value != current_state: - _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) - self.char_active.set_value(current_state) - if self.char_in_use.value != current_state: - _LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state) - self.char_in_use.set_value(current_state) + _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) + self.char_active.set_value(current_state) + _LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state) + self.char_in_use.set_value(current_state) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index fb3063704c2..c36a32b0d5b 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -446,8 +446,7 @@ class Thermostat(HomeAccessory): if hvac_mode and hvac_mode in HC_HASS_TO_HOMEKIT: homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] if homekit_hvac_mode in self.hc_homekit_to_hass: - if self.char_target_heat_cool.value != homekit_hvac_mode: - self.char_target_heat_cool.set_value(homekit_hvac_mode) + self.char_target_heat_cool.set_value(homekit_hvac_mode) else: _LOGGER.error( "Cannot map hvac target mode: %s to homekit as only %s modes are supported", @@ -459,30 +458,23 @@ class Thermostat(HomeAccessory): hvac_action = new_state.attributes.get(ATTR_HVAC_ACTION) if hvac_action: homekit_hvac_action = HC_HASS_TO_HOMEKIT_ACTION[hvac_action] - if self.char_current_heat_cool.value != homekit_hvac_action: - self.char_current_heat_cool.set_value(homekit_hvac_action) + self.char_current_heat_cool.set_value(homekit_hvac_action) # Update current temperature current_temp = _get_current_temperature(new_state, self._unit) - if current_temp is not None and self.char_current_temp.value != current_temp: + if current_temp is not None: self.char_current_temp.set_value(current_temp) # Update current humidity if CHAR_CURRENT_HUMIDITY in self.chars: current_humdity = new_state.attributes.get(ATTR_CURRENT_HUMIDITY) - if ( - isinstance(current_humdity, (int, float)) - and self.char_current_humidity.value != current_humdity - ): + if isinstance(current_humdity, (int, float)): self.char_current_humidity.set_value(current_humdity) # Update target humidity if CHAR_TARGET_HUMIDITY in self.chars: target_humdity = new_state.attributes.get(ATTR_HUMIDITY) - if ( - isinstance(target_humdity, (int, float)) - and self.char_target_humidity.value != target_humdity - ): + if isinstance(target_humdity, (int, float)): self.char_target_humidity.set_value(target_humdity) # Update cooling threshold temperature if characteristic exists @@ -490,16 +482,14 @@ class Thermostat(HomeAccessory): cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) if isinstance(cooling_thresh, (int, float)): cooling_thresh = self._temperature_to_homekit(cooling_thresh) - if self.char_heating_thresh_temp.value != cooling_thresh: - self.char_cooling_thresh_temp.set_value(cooling_thresh) + self.char_cooling_thresh_temp.set_value(cooling_thresh) # Update heating threshold temperature if characteristic exists if self.char_heating_thresh_temp: heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) if isinstance(heating_thresh, (int, float)): heating_thresh = self._temperature_to_homekit(heating_thresh) - if self.char_heating_thresh_temp.value != heating_thresh: - self.char_heating_thresh_temp.set_value(heating_thresh) + self.char_heating_thresh_temp.set_value(heating_thresh) # Update target temperature target_temp = _get_target_temperature(new_state, self._unit) @@ -515,14 +505,13 @@ class Thermostat(HomeAccessory): temp_high = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) if isinstance(temp_high, (int, float)): target_temp = self._temperature_to_homekit(temp_high) - if target_temp and self.char_target_temp.value != target_temp: + if target_temp: self.char_target_temp.set_value(target_temp) # Update display units if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: unit = UNIT_HASS_TO_HOMEKIT[self._unit] - if self.char_display_units.value != unit: - self.char_display_units.set_value(unit) + self.char_display_units.set_value(unit) @TYPES.register("WaterHeater") @@ -580,7 +569,7 @@ class WaterHeater(HomeAccessory): """Change operation mode to value if call came from HomeKit.""" _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) hass_value = HC_HOMEKIT_TO_HASS[value] - if hass_value != HVAC_MODE_HEAT and self.char_target_heat_cool.value != 1: + if hass_value != HVAC_MODE_HEAT: self.char_target_heat_cool.set_value(1) # Heat def set_target_temperature(self, value): @@ -600,28 +589,21 @@ class WaterHeater(HomeAccessory): """Update water_heater state after state change.""" # Update current and target temperature target_temperature = _get_target_temperature(new_state, self._unit) - if ( - target_temperature is not None - and target_temperature != self.char_target_temp.value - ): + if target_temperature is not None: self.char_target_temp.set_value(target_temperature) current_temperature = _get_current_temperature(new_state, self._unit) - if ( - current_temperature is not None - and current_temperature != self.char_current_temp.value - ): + if current_temperature is not None: self.char_current_temp.set_value(current_temperature) # Update display units if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: unit = UNIT_HASS_TO_HOMEKIT[self._unit] - if self.char_display_units.value != unit: - self.char_display_units.set_value(unit) + self.char_display_units.set_value(unit) # Update target operation mode operation_mode = new_state.state - if operation_mode and self.char_target_heat_cool.value != 1: + if operation_mode: self.char_target_heat_cool.set_value(1) # Heat From f1c244e914265510867ea0f5e8d1bedb232c2371 Mon Sep 17 00:00:00 2001 From: dailow Date: Mon, 9 Aug 2021 14:50:09 -0700 Subject: [PATCH 263/903] Fix aqualogic state attribute update (#54354) --- homeassistant/components/aqualogic/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 01f31757c9d..fff73cf00fa 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -93,9 +93,10 @@ class AquaLogicSensor(SensorEntity): if panel is not None: if panel.is_metric: self._attr_unit_of_measurement = SENSOR_TYPES[self._type][1][0] - self._attr_state = getattr(panel, self._type) - self.async_write_ha_state() else: self._attr_unit_of_measurement = SENSOR_TYPES[self._type][1][1] + + self._attr_state = getattr(panel, self._type) + self.async_write_ha_state() else: self._attr_unit_of_measurement = None From 1cd575df533f60712b1dfe54f078ec25afd0f072 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 9 Aug 2021 14:50:39 -0700 Subject: [PATCH 264/903] Cast SimpliSafe version number as a string in device info (#54356) --- homeassistant/components/simplisafe/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 0853aa3974c..924cf398f64 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -431,7 +431,7 @@ class SimpliSafeEntity(CoordinatorEntity): self._attr_device_info = { "identifiers": {(DOMAIN, system.system_id)}, "manufacturer": "SimpliSafe", - "model": system.version, + "model": str(system.version), "name": name, "via_device": (DOMAIN, system.serial), } From b42ac8b48f7ea5c91fd11293dc53456f31323cc0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 9 Aug 2021 23:53:39 +0200 Subject: [PATCH 265/903] Use EntityDescription - growatt_server (#54316) --- .../components/growatt_server/sensor.py | 1149 +++++++++-------- 1 file changed, 633 insertions(+), 516 deletions(-) diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 530842fab7b..6eb225e7535 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -1,4 +1,7 @@ """Read status of growatt inverters.""" +from __future__ import annotations + +from dataclasses import dataclass import datetime import json import logging @@ -6,7 +9,7 @@ import re import growattServer -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -37,514 +40,643 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(minutes=1) -# Sensor type order is: Sensor name, Unit of measurement, api data name, additional options -TOTAL_SENSOR_TYPES = { - "total_money_today": ("Total money today", CURRENCY_EURO, "plantMoneyText", {}), - "total_money_total": ("Money lifetime", CURRENCY_EURO, "totalMoneyText", {}), - "total_energy_today": ( - "Energy Today", - ENERGY_KILO_WATT_HOUR, - "todayEnergy", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "total_output_power": ( - "Output Power", - POWER_WATT, - "invTodayPpv", - {"device_class": DEVICE_CLASS_POWER}, - ), - "total_energy_output": ( - "Lifetime energy output", - ENERGY_KILO_WATT_HOUR, - "totalEnergy", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "total_maximum_output": ( - "Maximum power", - POWER_WATT, - "nominalPower", - {"device_class": DEVICE_CLASS_POWER}, - ), -} -INVERTER_SENSOR_TYPES = { - "inverter_energy_today": ( - "Energy today", - ENERGY_KILO_WATT_HOUR, - "powerToday", - {"round": 1, "device_class": DEVICE_CLASS_ENERGY}, - ), - "inverter_energy_total": ( - "Lifetime energy output", - ENERGY_KILO_WATT_HOUR, - "powerTotal", - {"round": 1, "device_class": DEVICE_CLASS_ENERGY}, - ), - "inverter_voltage_input_1": ( - "Input 1 voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv1", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_amperage_input_1": ( - "Input 1 Amperage", - ELECTRIC_CURRENT_AMPERE, - "ipv1", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_wattage_input_1": ( - "Input 1 Wattage", - POWER_WATT, - "ppv1", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_voltage_input_2": ( - "Input 2 voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv2", - {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_amperage_input_2": ( - "Input 2 Amperage", - ELECTRIC_CURRENT_AMPERE, - "ipv2", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_wattage_input_2": ( - "Input 2 Wattage", - POWER_WATT, - "ppv2", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_voltage_input_3": ( - "Input 3 voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv3", - {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_amperage_input_3": ( - "Input 3 Amperage", - ELECTRIC_CURRENT_AMPERE, - "ipv3", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_wattage_input_3": ( - "Input 3 Wattage", - POWER_WATT, - "ppv3", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_internal_wattage": ( - "Internal wattage", - POWER_WATT, - "ppv", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_reactive_voltage": ( - "Reactive voltage", - ELECTRIC_POTENTIAL_VOLT, - "vacr", - {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_inverter_reactive_amperage": ( - "Reactive amperage", - ELECTRIC_CURRENT_AMPERE, - "iacr", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_frequency": ("AC frequency", FREQUENCY_HERTZ, "fac", {"round": 1}), - "inverter_current_wattage": ( - "Output power", - POWER_WATT, - "pac", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_current_reactive_wattage": ( - "Reactive wattage", - POWER_WATT, - "pacr", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_ipm_temperature": ( - "Intelligent Power Management temperature", - TEMP_CELSIUS, - "ipmTemperature", - {"device_class": DEVICE_CLASS_TEMPERATURE, "round": 1}, - ), - "inverter_temperature": ( - "Temperature", - TEMP_CELSIUS, - "temperature", - {"device_class": DEVICE_CLASS_TEMPERATURE, "round": 1}, - ), -} +@dataclass +class GrowattRequiredKeysMixin: + """Mixin for required keys.""" -STORAGE_SENSOR_TYPES = { - "storage_storage_production_today": ( - "Storage production today", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_storage_production_lifetime": ( - "Lifetime Storage production", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_grid_discharge_today": ( - "Grid discharged today", - ENERGY_KILO_WATT_HOUR, - "eacDisChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_load_consumption_today": ( - "Load consumption today", - ENERGY_KILO_WATT_HOUR, - "eopDischrToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_load_consumption_lifetime": ( - "Lifetime load consumption", - ENERGY_KILO_WATT_HOUR, - "eopDischrTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_grid_charged_today": ( - "Grid charged today", - ENERGY_KILO_WATT_HOUR, - "eacChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_charge_storage_lifetime": ( - "Lifetime storaged charged", - ENERGY_KILO_WATT_HOUR, - "eChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_solar_production": ( - "Solar power production", - POWER_WATT, - "ppv", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_battery_percentage": ( - "Battery percentage", - PERCENTAGE, - "capacity", - {"device_class": DEVICE_CLASS_BATTERY}, - ), - "storage_power_flow": ( - "Storage charging/ discharging(-ve)", - POWER_WATT, - "pCharge", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_load_consumption_solar_storage": ( - "Load consumption(Solar + Storage)", - "VA", - "rateVA", - {}, - ), - "storage_charge_today": ( - "Charge today", - ENERGY_KILO_WATT_HOUR, - "eChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_import_from_grid": ( - "Import from grid", - POWER_WATT, - "pAcInPut", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_import_from_grid_today": ( - "Import from grid today", - ENERGY_KILO_WATT_HOUR, - "eToUserToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_import_from_grid_total": ( - "Import from grid total", - ENERGY_KILO_WATT_HOUR, - "eToUserTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_load_consumption": ( - "Load consumption", - POWER_WATT, - "outPutPower", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_grid_voltage": ( - "AC input voltage", - ELECTRIC_POTENTIAL_VOLT, - "vGrid", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_pv_charging_voltage": ( - "PV charging voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_ac_input_frequency_out": ( - "AC input frequency", - FREQUENCY_HERTZ, - "freqOutPut", - {"round": 2}, - ), - "storage_output_voltage": ( - "Output voltage", - ELECTRIC_POTENTIAL_VOLT, - "outPutVolt", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_ac_output_frequency": ( - "Ac output frequency", - FREQUENCY_HERTZ, - "freqGrid", - {"round": 2}, - ), - "storage_current_PV": ( - "Solar charge current", - ELECTRIC_CURRENT_AMPERE, - "iAcCharge", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_current_1": ( - "Solar current to storage", - ELECTRIC_CURRENT_AMPERE, - "iChargePV1", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_grid_amperage_input": ( - "Grid charge current", - ELECTRIC_CURRENT_AMPERE, - "chgCurr", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_grid_out_current": ( - "Grid out current", - ELECTRIC_CURRENT_AMPERE, - "outPutCurrent", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_battery_voltage": ( - "Battery voltage", - ELECTRIC_POTENTIAL_VOLT, - "vBat", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_load_percentage": ( - "Load percentage", - PERCENTAGE, - "loadPercent", - {"device_class": DEVICE_CLASS_BATTERY, "round": 2}, - ), -} + api_key: str -MIX_SENSOR_TYPES = { + +@dataclass +class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin): + """Describes Growatt sensor entity.""" + + precision: int | None = None + + +TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="total_money_today", + name="Total money today", + api_key="plantMoneyText", + unit_of_measurement=CURRENCY_EURO, + ), + GrowattSensorEntityDescription( + key="total_money_total", + name="Money lifetime", + api_key="totalMoneyText", + unit_of_measurement=CURRENCY_EURO, + ), + GrowattSensorEntityDescription( + key="total_energy_today", + name="Energy Today", + api_key="todayEnergy", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="total_output_power", + name="Output Power", + api_key="invTodayPpv", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="total_energy_output", + name="Lifetime energy output", + api_key="totalEnergy", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="total_maximum_output", + name="Maximum power", + api_key="nominalPower", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), +) + +INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="inverter_energy_today", + name="Energy today", + api_key="powerToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_energy_total", + name="Lifetime energy output", + api_key="powerTotal", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_voltage_input_1", + name="Input 1 voltage", + api_key="vpv1", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="inverter_amperage_input_1", + name="Input 1 Amperage", + api_key="ipv1", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_wattage_input_1", + name="Input 1 Wattage", + api_key="ppv1", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_voltage_input_2", + name="Input 2 voltage", + api_key="vpv2", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_amperage_input_2", + name="Input 2 Amperage", + api_key="ipv2", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_wattage_input_2", + name="Input 2 Wattage", + api_key="ppv2", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_voltage_input_3", + name="Input 3 voltage", + api_key="vpv3", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_amperage_input_3", + name="Input 3 Amperage", + api_key="ipv3", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_wattage_input_3", + name="Input 3 Wattage", + api_key="ppv3", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_internal_wattage", + name="Internal wattage", + api_key="ppv", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_reactive_voltage", + name="Reactive voltage", + api_key="vacr", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_inverter_reactive_amperage", + name="Reactive amperage", + api_key="iacr", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_frequency", + name="AC frequency", + api_key="fac", + unit_of_measurement=FREQUENCY_HERTZ, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_current_wattage", + name="Output power", + api_key="pac", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_current_reactive_wattage", + name="Reactive wattage", + api_key="pacr", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_ipm_temperature", + name="Intelligent Power Management temperature", + api_key="ipmTemperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_temperature", + name="Temperature", + api_key="temperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), +) + +STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="storage_storage_production_today", + name="Storage production today", + api_key="eBatDisChargeToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_storage_production_lifetime", + name="Lifetime Storage production", + api_key="eBatDisChargeTotal", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_grid_discharge_today", + name="Grid discharged today", + api_key="eacDisChargeToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption_today", + name="Load consumption today", + api_key="eopDischrToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption_lifetime", + name="Lifetime load consumption", + api_key="eopDischrTotal", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_grid_charged_today", + name="Grid charged today", + api_key="eacChargeToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_charge_storage_lifetime", + name="Lifetime storaged charged", + api_key="eChargeTotal", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_solar_production", + name="Solar power production", + api_key="ppv", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="storage_battery_percentage", + name="Battery percentage", + api_key="capacity", + unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + ), + GrowattSensorEntityDescription( + key="storage_power_flow", + name="Storage charging/ discharging(-ve)", + api_key="pCharge", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption_solar_storage", + name="Load consumption(Solar + Storage)", + api_key="rateVA", + unit_of_measurement="VA", + ), + GrowattSensorEntityDescription( + key="storage_charge_today", + name="Charge today", + api_key="eChargeToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_import_from_grid", + name="Import from grid", + api_key="pAcInPut", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="storage_import_from_grid_today", + name="Import from grid today", + api_key="eToUserToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_import_from_grid_total", + name="Import from grid total", + api_key="eToUserTotal", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption", + name="Load consumption", + api_key="outPutPower", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="storage_grid_voltage", + name="AC input voltage", + api_key="vGrid", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_pv_charging_voltage", + name="PV charging voltage", + api_key="vpv", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_ac_input_frequency_out", + name="AC input frequency", + api_key="freqOutPut", + unit_of_measurement=FREQUENCY_HERTZ, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_output_voltage", + name="Output voltage", + api_key="outPutVolt", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_ac_output_frequency", + name="Ac output frequency", + api_key="freqGrid", + unit_of_measurement=FREQUENCY_HERTZ, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_current_PV", + name="Solar charge current", + api_key="iAcCharge", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_current_1", + name="Solar current to storage", + api_key="iChargePV1", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_grid_amperage_input", + name="Grid charge current", + api_key="chgCurr", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_grid_out_current", + name="Grid out current", + api_key="outPutCurrent", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_battery_voltage", + name="Battery voltage", + api_key="vBat", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_load_percentage", + name="Load percentage", + api_key="loadPercent", + unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + precision=2, + ), +) + +MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( # Values from 'mix_info' API call - "mix_statement_of_charge": ( - "Statement of charge", - PERCENTAGE, - "capacity", - {"device_class": DEVICE_CLASS_BATTERY}, + GrowattSensorEntityDescription( + key="mix_statement_of_charge", + name="Statement of charge", + api_key="capacity", + unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, ), - "mix_battery_charge_today": ( - "Battery charged today", - ENERGY_KILO_WATT_HOUR, - "eBatChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_battery_charge_today", + name="Battery charged today", + api_key="eBatChargeToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_battery_charge_lifetime": ( - "Lifetime battery charged", - ENERGY_KILO_WATT_HOUR, - "eBatChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_battery_charge_lifetime", + name="Lifetime battery charged", + api_key="eBatChargeTotal", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_battery_discharge_today": ( - "Battery discharged today", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_battery_discharge_today", + name="Battery discharged today", + api_key="eBatDisChargeToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_battery_discharge_lifetime": ( - "Lifetime battery discharged", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_battery_discharge_lifetime", + name="Lifetime battery discharged", + api_key="eBatDisChargeTotal", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_solar_generation_today": ( - "Solar energy today", - ENERGY_KILO_WATT_HOUR, - "epvToday", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_solar_generation_today", + name="Solar energy today", + api_key="epvToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_solar_generation_lifetime": ( - "Lifetime solar energy", - ENERGY_KILO_WATT_HOUR, - "epvTotal", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_solar_generation_lifetime", + name="Lifetime solar energy", + api_key="epvTotal", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_battery_discharge_w": ( - "Battery discharging W", - POWER_WATT, - "pDischarge1", - {"device_class": DEVICE_CLASS_POWER}, + GrowattSensorEntityDescription( + key="mix_battery_discharge_w", + name="Battery discharging W", + api_key="pDischarge1", + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, ), - "mix_battery_voltage": ( - "Battery voltage", - ELECTRIC_POTENTIAL_VOLT, - "vbat", - {"device_class": DEVICE_CLASS_VOLTAGE}, + GrowattSensorEntityDescription( + key="mix_battery_voltage", + name="Battery voltage", + api_key="vbat", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, ), - "mix_pv1_voltage": ( - "PV1 voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv1", - {"device_class": DEVICE_CLASS_VOLTAGE}, + GrowattSensorEntityDescription( + key="mix_pv1_voltage", + name="PV1 voltage", + api_key="vpv1", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, ), - "mix_pv2_voltage": ( - "PV2 voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv2", - {"device_class": DEVICE_CLASS_VOLTAGE}, + GrowattSensorEntityDescription( + key="mix_pv2_voltage", + name="PV2 voltage", + api_key="vpv2", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, ), # Values from 'mix_totals' API call - "mix_load_consumption_today": ( - "Load consumption today", - ENERGY_KILO_WATT_HOUR, - "elocalLoadToday", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_load_consumption_today", + name="Load consumption today", + api_key="elocalLoadToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_load_consumption_lifetime": ( - "Lifetime load consumption", - ENERGY_KILO_WATT_HOUR, - "elocalLoadTotal", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_load_consumption_lifetime", + name="Lifetime load consumption", + api_key="elocalLoadTotal", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_export_to_grid_today": ( - "Export to grid today", - ENERGY_KILO_WATT_HOUR, - "etoGridToday", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_export_to_grid_today", + name="Export to grid today", + api_key="etoGridToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_export_to_grid_lifetime": ( - "Lifetime export to grid", - ENERGY_KILO_WATT_HOUR, - "etogridTotal", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_export_to_grid_lifetime", + name="Lifetime export to grid", + api_key="etogridTotal", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), # Values from 'mix_system_status' API call - "mix_battery_charge": ( - "Battery charging", - POWER_KILO_WATT, - "chargePower", - {"device_class": DEVICE_CLASS_POWER}, + GrowattSensorEntityDescription( + key="mix_battery_charge", + name="Battery charging", + api_key="chargePower", + unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, ), - "mix_load_consumption": ( - "Load consumption", - POWER_KILO_WATT, - "pLocalLoad", - {"device_class": DEVICE_CLASS_POWER}, + GrowattSensorEntityDescription( + key="mix_load_consumption", + name="Load consumption", + api_key="pLocalLoad", + unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, ), - "mix_wattage_pv_1": ( - "PV1 Wattage", - POWER_KILO_WATT, - "pPv1", - {"device_class": DEVICE_CLASS_POWER}, + GrowattSensorEntityDescription( + key="mix_wattage_pv_1", + name="PV1 Wattage", + api_key="pPv1", + unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, ), - "mix_wattage_pv_2": ( - "PV2 Wattage", - POWER_KILO_WATT, - "pPv2", - {"device_class": DEVICE_CLASS_POWER}, + GrowattSensorEntityDescription( + key="mix_wattage_pv_2", + name="PV2 Wattage", + api_key="pPv2", + unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, ), - "mix_wattage_pv_all": ( - "All PV Wattage", - POWER_KILO_WATT, - "ppv", - {"device_class": DEVICE_CLASS_POWER}, + GrowattSensorEntityDescription( + key="mix_wattage_pv_all", + name="All PV Wattage", + api_key="ppv", + unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, ), - "mix_export_to_grid": ( - "Export to grid", - POWER_KILO_WATT, - "pactogrid", - {"device_class": DEVICE_CLASS_POWER}, + GrowattSensorEntityDescription( + key="mix_export_to_grid", + name="Export to grid", + api_key="pactogrid", + unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, ), - "mix_import_from_grid": ( - "Import from grid", - POWER_KILO_WATT, - "pactouser", - {"device_class": DEVICE_CLASS_POWER}, + GrowattSensorEntityDescription( + key="mix_import_from_grid", + name="Import from grid", + api_key="pactouser", + unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, ), - "mix_battery_discharge_kw": ( - "Battery discharging kW", - POWER_KILO_WATT, - "pdisCharge1", - {"device_class": DEVICE_CLASS_POWER}, + GrowattSensorEntityDescription( + key="mix_battery_discharge_kw", + name="Battery discharging kW", + api_key="pdisCharge1", + unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, ), - "mix_grid_voltage": ( - "Grid voltage", - ELECTRIC_POTENTIAL_VOLT, - "vAc1", - {"device_class": DEVICE_CLASS_VOLTAGE}, + GrowattSensorEntityDescription( + key="mix_grid_voltage", + name="Grid voltage", + api_key="vAc1", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, ), # Values from 'mix_detail' API call - "mix_system_production_today": ( - "System production today (self-consumption + export)", - ENERGY_KILO_WATT_HOUR, - "eCharge", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_system_production_today", + name="System production today (self-consumption + export)", + api_key="eCharge", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_load_consumption_solar_today": ( - "Load consumption today (solar)", - ENERGY_KILO_WATT_HOUR, - "eChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_load_consumption_solar_today", + name="Load consumption today (solar)", + api_key="eChargeToday", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_self_consumption_today": ( - "Self consumption today (solar + battery)", - ENERGY_KILO_WATT_HOUR, - "eChargeToday1", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_self_consumption_today", + name="Self consumption today (solar + battery)", + api_key="eChargeToday1", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_load_consumption_battery_today": ( - "Load consumption today (battery)", - ENERGY_KILO_WATT_HOUR, - "echarge1", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_load_consumption_battery_today", + name="Load consumption today (battery)", + api_key="echarge1", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - "mix_import_from_grid_today": ( - "Import from grid today (load)", - ENERGY_KILO_WATT_HOUR, - "etouser", - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_import_from_grid_today", + name="Import from grid today (load)", + api_key="etouser", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), # This sensor is manually created using the most recent X-Axis value from the chartData - "mix_last_update": ( - "Last Data Update", - None, - "lastdataupdate", - {"device_class": DEVICE_CLASS_TIMESTAMP}, + GrowattSensorEntityDescription( + key="mix_last_update", + name="Last Data Update", + api_key="lastdataupdate", + unit_of_measurement=None, + device_class=DEVICE_CLASS_TIMESTAMP, ), # Values from 'dashboard_data' API call - "mix_import_from_grid_today_combined": ( - "Import from grid today (load + charging)", - ENERGY_KILO_WATT_HOUR, - "etouser_combined", # This id is not present in the raw API data, it is added by the sensor - {"device_class": DEVICE_CLASS_ENERGY}, + GrowattSensorEntityDescription( + key="mix_import_from_grid_today_combined", + name="Import from grid today (load + charging)", + api_key="etouser_combined", # This id is not present in the raw API data, it is added by the sensor + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), -} - -SENSOR_TYPES = { - **TOTAL_SENSOR_TYPES, - **INVERTER_SENSOR_TYPES, - **STORAGE_SENSOR_TYPES, - **MIX_SENSOR_TYPES, -} +) def get_device_list(api, config): @@ -579,42 +711,48 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) - entities = [] probe = GrowattData(api, username, password, plant_id, "total") - for sensor in TOTAL_SENSOR_TYPES: - entities.append( - GrowattInverter(probe, f"{name} Total", sensor, f"{plant_id}-{sensor}") + entities = [ + GrowattInverter( + probe, + name=f"{name} Total", + unique_id=f"{plant_id}-{description.key}", + description=description, ) + for description in TOTAL_SENSOR_TYPES + ] # Add sensors for each device in the specified plant. for device in devices: probe = GrowattData( api, username, password, device["deviceSn"], device["deviceType"] ) - sensors = [] + sensor_descriptions = () if device["deviceType"] == "inverter": - sensors = INVERTER_SENSOR_TYPES + sensor_descriptions = INVERTER_SENSOR_TYPES elif device["deviceType"] == "storage": probe.plant_id = plant_id - sensors = STORAGE_SENSOR_TYPES + sensor_descriptions = STORAGE_SENSOR_TYPES elif device["deviceType"] == "mix": probe.plant_id = plant_id - sensors = MIX_SENSOR_TYPES + sensor_descriptions = MIX_SENSOR_TYPES else: _LOGGER.debug( "Device type %s was found but is not supported right now", device["deviceType"], ) - for sensor in sensors: - entities.append( + entities.extend( + [ GrowattInverter( probe, - f"{device['deviceAilas']}", - sensor, - f"{device['deviceSn']}-{sensor}", + name=f"{device['deviceAilas']}", + unique_id=f"{device['deviceSn']}-{description.key}", + description=description, ) - ) + for description in sensor_descriptions + ] + ) async_add_entities(entities, True) @@ -622,48 +760,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class GrowattInverter(SensorEntity): """Representation of a Growatt Sensor.""" - def __init__(self, probe, name, sensor, unique_id): + entity_description: GrowattSensorEntityDescription + + def __init__( + self, probe, name, unique_id, description: GrowattSensorEntityDescription + ): """Initialize a PVOutput sensor.""" - self.sensor = sensor self.probe = probe - self._name = name - self._state = None - self._unique_id = unique_id + self.entity_description = description - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {SENSOR_TYPES[self.sensor][0]}" - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return self._unique_id - - @property - def icon(self): - """Return the icon of the sensor.""" - return "mdi:solar-power" + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id + self._attr_icon = "mdi:solar-power" @property def state(self): """Return the state of the sensor.""" - result = self.probe.get_data(SENSOR_TYPES[self.sensor][2]) - round_to = SENSOR_TYPES[self.sensor][3].get("round") - if round_to is not None: - result = round(result, round_to) + result = self.probe.get_data(self.entity_description.api_key) + if self.entity_description.precision is not None: + result = round(result, self.entity_description.precision) return result - @property - def device_class(self): - """Return the device class of the sensor.""" - return SENSOR_TYPES[self.sensor][3].get("device_class") - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self.sensor][1] - def update(self): """Get the latest data from the Growat API and updates the state.""" self.probe.update() From aeb7a6c09058b035f5ba74d688ead78ae3826198 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 9 Aug 2021 23:54:07 +0200 Subject: [PATCH 266/903] Use EntityDescription - bitcoin (#54320) * Use EntityDescription - bitcoin * Remove default values --- homeassistant/components/bitcoin/sensor.py | 193 +++++++++++++++------ 1 file changed, 139 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index d11c2a2b726..29945bd56dc 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -1,11 +1,17 @@ """Bitcoin information service that uses blockchain.com.""" +from __future__ import annotations + from datetime import timedelta import logging from blockchain import exchangerates, statistics import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_CURRENCY, @@ -25,34 +31,112 @@ ICON = "mdi:currency-btc" SCAN_INTERVAL = timedelta(minutes=5) -OPTION_TYPES = { - "exchangerate": ["Exchange rate (1 BTC)", None], - "trade_volume_btc": ["Trade volume", "BTC"], - "miners_revenue_usd": ["Miners revenue", "USD"], - "btc_mined": ["Mined", "BTC"], - "trade_volume_usd": ["Trade volume", "USD"], - "difficulty": ["Difficulty", None], - "minutes_between_blocks": ["Time between Blocks", TIME_MINUTES], - "number_of_transactions": ["No. of Transactions", None], - "hash_rate": ["Hash rate", f"PH/{TIME_SECONDS}"], - "timestamp": ["Timestamp", None], - "mined_blocks": ["Mined Blocks", None], - "blocks_size": ["Block size", None], - "total_fees_btc": ["Total fees", "BTC"], - "total_btc_sent": ["Total sent", "BTC"], - "estimated_btc_sent": ["Estimated sent", "BTC"], - "total_btc": ["Total", "BTC"], - "total_blocks": ["Total Blocks", None], - "next_retarget": ["Next retarget", None], - "estimated_transaction_volume_usd": ["Est. Transaction volume", "USD"], - "miners_revenue_btc": ["Miners revenue", "BTC"], - "market_price_usd": ["Market price", "USD"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="exchangerate", + name="Exchange rate (1 BTC)", + ), + SensorEntityDescription( + key="trade_volume_btc", + name="Trade volume", + unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="miners_revenue_usd", + name="Miners revenue", + unit_of_measurement="USD", + ), + SensorEntityDescription( + key="btc_mined", + name="Mined", + unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="trade_volume_usd", + name="Trade volume", + unit_of_measurement="USD", + ), + SensorEntityDescription( + key="difficulty", + name="Difficulty", + ), + SensorEntityDescription( + key="minutes_between_blocks", + name="Time between Blocks", + unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key="number_of_transactions", + name="No. of Transactions", + ), + SensorEntityDescription( + key="hash_rate", + name="Hash rate", + unit_of_measurement=f"PH/{TIME_SECONDS}", + ), + SensorEntityDescription( + key="timestamp", + name="Timestamp", + ), + SensorEntityDescription( + key="mined_blocks", + name="Mined Blocks", + ), + SensorEntityDescription( + key="blocks_size", + name="Block size", + ), + SensorEntityDescription( + key="total_fees_btc", + name="Total fees", + unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="total_btc_sent", + name="Total sent", + unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="estimated_btc_sent", + name="Estimated sent", + unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="total_btc", + name="Total", + unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="total_blocks", + name="Total Blocks", + ), + SensorEntityDescription( + key="next_retarget", + name="Next retarget", + ), + SensorEntityDescription( + key="estimated_transaction_volume_usd", + name="Est. Transaction volume", + unit_of_measurement="USD", + ), + SensorEntityDescription( + key="miners_revenue_btc", + name="Miners revenue", + unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="market_price_usd", + name="Market price", + unit_of_measurement="USD", + ), +) + +OPTION_KEYS = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_DISPLAY_OPTIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(OPTION_TYPES)] + cv.ensure_list, [vol.In(OPTION_KEYS)] ), vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, } @@ -69,11 +153,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): currency = DEFAULT_CURRENCY data = BitcoinData() - dev = [] - for variable in config[CONF_DISPLAY_OPTIONS]: - dev.append(BitcoinSensor(data, variable, currency)) + entities = [ + BitcoinSensor(data, currency, description) + for description in SENSOR_TYPES + if description.key in config[CONF_DISPLAY_OPTIONS] + ] - add_entities(dev, True) + add_entities(entities, True) class BitcoinSensor(SensorEntity): @@ -82,13 +168,11 @@ class BitcoinSensor(SensorEntity): _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON - def __init__(self, data, option_type, currency): + def __init__(self, data, currency, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._attr_name = OPTION_TYPES[option_type][0] - self._attr_unit_of_measurement = OPTION_TYPES[option_type][1] self._currency = currency - self.type = option_type def update(self): """Get the latest data and updates the states.""" @@ -96,48 +180,49 @@ class BitcoinSensor(SensorEntity): stats = self.data.stats ticker = self.data.ticker - if self.type == "exchangerate": + sensor_type = self.entity_description.key + if sensor_type == "exchangerate": self._attr_state = ticker[self._currency].p15min self._attr_unit_of_measurement = self._currency - elif self.type == "trade_volume_btc": + elif sensor_type == "trade_volume_btc": self._attr_state = f"{stats.trade_volume_btc:.1f}" - elif self.type == "miners_revenue_usd": + elif sensor_type == "miners_revenue_usd": self._attr_state = f"{stats.miners_revenue_usd:.0f}" - elif self.type == "btc_mined": + elif sensor_type == "btc_mined": self._attr_state = str(stats.btc_mined * 0.00000001) - elif self.type == "trade_volume_usd": + elif sensor_type == "trade_volume_usd": self._attr_state = f"{stats.trade_volume_usd:.1f}" - elif self.type == "difficulty": + elif sensor_type == "difficulty": self._attr_state = f"{stats.difficulty:.0f}" - elif self.type == "minutes_between_blocks": + elif sensor_type == "minutes_between_blocks": self._attr_state = f"{stats.minutes_between_blocks:.2f}" - elif self.type == "number_of_transactions": + elif sensor_type == "number_of_transactions": self._attr_state = str(stats.number_of_transactions) - elif self.type == "hash_rate": + elif sensor_type == "hash_rate": self._attr_state = f"{stats.hash_rate * 0.000001:.1f}" - elif self.type == "timestamp": + elif sensor_type == "timestamp": self._attr_state = stats.timestamp - elif self.type == "mined_blocks": + elif sensor_type == "mined_blocks": self._attr_state = str(stats.mined_blocks) - elif self.type == "blocks_size": + elif sensor_type == "blocks_size": self._attr_state = f"{stats.blocks_size:.1f}" - elif self.type == "total_fees_btc": + elif sensor_type == "total_fees_btc": self._attr_state = f"{stats.total_fees_btc * 0.00000001:.2f}" - elif self.type == "total_btc_sent": + elif sensor_type == "total_btc_sent": self._attr_state = f"{stats.total_btc_sent * 0.00000001:.2f}" - elif self.type == "estimated_btc_sent": + elif sensor_type == "estimated_btc_sent": self._attr_state = f"{stats.estimated_btc_sent * 0.00000001:.2f}" - elif self.type == "total_btc": + elif sensor_type == "total_btc": self._attr_state = f"{stats.total_btc * 0.00000001:.2f}" - elif self.type == "total_blocks": + elif sensor_type == "total_blocks": self._attr_state = f"{stats.total_blocks:.0f}" - elif self.type == "next_retarget": + elif sensor_type == "next_retarget": self._attr_state = f"{stats.next_retarget:.2f}" - elif self.type == "estimated_transaction_volume_usd": + elif sensor_type == "estimated_transaction_volume_usd": self._attr_state = f"{stats.estimated_transaction_volume_usd:.2f}" - elif self.type == "miners_revenue_btc": + elif sensor_type == "miners_revenue_btc": self._attr_state = f"{stats.miners_revenue_btc * 0.00000001:.1f}" - elif self.type == "market_price_usd": + elif sensor_type == "market_price_usd": self._attr_state = f"{stats.market_price_usd:.2f}" From 4459d8674abc07d5c0f8c84d3af23dc9d1eabd74 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 9 Aug 2021 23:56:33 +0200 Subject: [PATCH 267/903] Add `binary_sensor` platform for Xiaomi Miio integration (#54096) --- .coveragerc | 1 + .../components/xiaomi_miio/__init__.py | 9 +- .../components/xiaomi_miio/binary_sensor.py | 87 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/xiaomi_miio/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 839bd34f6e5..5d9c5e9c5c8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1202,6 +1202,7 @@ omit = homeassistant/components/xiaomi_miio/__init__.py homeassistant/components/xiaomi_miio/air_quality.py homeassistant/components/xiaomi_miio/alarm_control_panel.py + homeassistant/components/xiaomi_miio/binary_sensor.py homeassistant/components/xiaomi_miio/device.py homeassistant/components/xiaomi_miio/device_tracker.py homeassistant/components/xiaomi_miio/fan.py diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 89355ae309e..bd9e69bd12d 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -37,7 +37,14 @@ _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] SWITCH_PLATFORMS = ["switch"] FAN_PLATFORMS = ["fan"] -HUMIDIFIER_PLATFORMS = ["humidifier", "number", "select", "sensor", "switch"] +HUMIDIFIER_PLATFORMS = [ + "binary_sensor", + "humidifier", + "number", + "select", + "sensor", + "switch", +] LIGHT_PLATFORMS = ["light"] VACUUM_PLATFORMS = ["vacuum"] AIR_MONITOR_PLATFORMS = ["air_quality", "sensor"] diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py new file mode 100644 index 00000000000..c2f14b17d22 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -0,0 +1,87 @@ +"""Support for Xiaomi Miio binary sensors.""" +from enum import Enum + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) + +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MODEL, + DOMAIN, + KEY_COORDINATOR, + KEY_DEVICE, + MODELS_HUMIDIFIER_MJJSQ, +) +from .device import XiaomiCoordinatedMiioEntity + +ATTR_NO_WATER = "no_water" +ATTR_WATER_TANK_DETACHED = "water_tank_detached" + +BINARY_SENSOR_TYPES = ( + BinarySensorEntityDescription( + key=ATTR_NO_WATER, + name="Water Tank Empty", + icon="mdi:water-off-outline", + ), + BinarySensorEntityDescription( + key=ATTR_WATER_TANK_DETACHED, + name="Water Tank Detached", + icon="mdi:flask-empty-off-outline", + ), +) + +HUMIDIFIER_MJJSQ_BINARY_SENSORS = (ATTR_NO_WATER, ATTR_WATER_TANK_DETACHED) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Xiaomi sensor from a config entry.""" + entities = [] + + if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + model = config_entry.data[CONF_MODEL] + sensors = [] + if model in MODELS_HUMIDIFIER_MJJSQ: + sensors = HUMIDIFIER_MJJSQ_BINARY_SENSORS + for description in BINARY_SENSOR_TYPES: + if description.key not in sensors: + continue + entities.append( + XiaomiGenericBinarySensor( + f"{config_entry.title} {description.name}", + hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE], + config_entry, + f"{description.key}_{config_entry.unique_id}", + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, + ) + ) + + async_add_entities(entities) + + +class XiaomiGenericBinarySensor(XiaomiCoordinatedMiioEntity, BinarySensorEntity): + """Representation of a Xiaomi Humidifier binary sensor.""" + + def __init__(self, name, device, entry, unique_id, coordinator, description): + """Initialize the entity.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self.entity_description = description + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._extract_value_from_attribute( + self.coordinator.data, self.entity_description.key + ) + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value From d4a3d0462d08932c48fc0cd39f5c8f3689ca98d6 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 9 Aug 2021 15:11:21 -0700 Subject: [PATCH 268/903] Minor motionEye readability improvement (#54251) --- homeassistant/components/motioneye/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index acafdceeb05..3eebcd4ee53 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -237,8 +237,8 @@ def _add_camera( if entry.options.get(CONF_WEBHOOK_SET, DEFAULT_WEBHOOK_SET): url = async_generate_motioneye_webhook(hass, entry.data[CONF_WEBHOOK_ID]) - if url and ( - _set_webhook( + if url: + set_motion_event = _set_webhook( _build_url( device, url, @@ -250,7 +250,8 @@ def _add_camera( KEY_WEB_HOOK_NOTIFICATIONS_ENABLED, camera, ) - | _set_webhook( + + set_storage_event = _set_webhook( _build_url( device, url, @@ -262,8 +263,8 @@ def _add_camera( KEY_WEB_HOOK_STORAGE_ENABLED, camera, ) - ): - hass.async_create_task(client.async_set_camera(camera_id, camera)) + if set_motion_event or set_storage_event: + hass.async_create_task(client.async_set_camera(camera_id, camera)) async_dispatcher_send( hass, From 33c33d844eefc89adfcf96a3c46834b7b2f9012b Mon Sep 17 00:00:00 2001 From: Matthew LeMay Date: Mon, 9 Aug 2021 18:16:33 -0400 Subject: [PATCH 269/903] Update pyupgrade to 2.23.3 (#54179) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1e36ae652d6..b31a9cef116 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.23.0 + rev: v2.23.3 hooks: - id: pyupgrade args: [--py38-plus] diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 795a4c3bcd6..19d55b1255c 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -12,5 +12,5 @@ mccabe==0.6.1 pycodestyle==2.7.0 pydocstyle==6.0.0 pyflakes==2.3.1 -pyupgrade==2.23.0 +pyupgrade==2.23.3 yamllint==1.26.1 From 3184f0697f30b2e141c420f736fd66d127d32aef Mon Sep 17 00:00:00 2001 From: "Richard T. Schaefer" Date: Mon, 9 Aug 2021 17:38:56 -0500 Subject: [PATCH 270/903] Add Save Persistent States service (#53881) --- .../components/homeassistant/__init__.py | 11 +++- .../components/homeassistant/services.yaml | 6 ++ homeassistant/const.py | 1 + homeassistant/helpers/restore_state.py | 6 ++ tests/components/homeassistant/test_init.py | 16 ++++++ tests/helpers/test_restore_state.py | 56 +++++++++++++++++++ 6 files changed, 95 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index e798fda209b..d21cd1359f1 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -14,13 +14,14 @@ from homeassistant.const import ( RESTART_EXIT_CODE, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, + SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) import homeassistant.core as ha from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser -from homeassistant.helpers import config_validation as cv, recorder +from homeassistant.helpers import config_validation as cv, recorder, restore_state from homeassistant.helpers.service import ( async_extract_config_entry_ids, async_extract_referenced_entity_ids, @@ -53,6 +54,10 @@ SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) async def async_setup(hass: ha.HomeAssistant, config: dict) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" + async def async_save_persistent_states(service): + """Handle calls to homeassistant.save_persistent_states.""" + await restore_state.RestoreStateData.async_save_persistent_states(hass) + async def async_handle_turn_service(service): """Handle calls to homeassistant.turn_on/off.""" referenced = await async_extract_referenced_entity_ids(hass, service) @@ -114,6 +119,10 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> bool: # noqa: C9 if tasks: await asyncio.gather(*tasks) + hass.services.async_register( + ha.DOMAIN, SERVICE_SAVE_PERSISTENT_STATES, async_save_persistent_states + ) + service_schema = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}, extra=vol.ALLOW_EXTRA) hass.services.async_register( diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 251ee171b6a..da52ff50d2f 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -74,3 +74,9 @@ reload_config_entry: example: 8955375327824e14ba89e4b29cc3ec9a selector: text: + +save_persistent_states: + name: Save Persistent States + description: + Save the persistent states (for entities derived from RestoreEntity) immediately. + Maintain the normal periodic saving interval. diff --git a/homeassistant/const.py b/homeassistant/const.py index ccd42ca32bb..5f4f8cd084c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -612,6 +612,7 @@ SERVICE_CLOSE_COVER: Final = "close_cover" SERVICE_CLOSE_COVER_TILT: Final = "close_cover_tilt" SERVICE_OPEN_COVER: Final = "open_cover" SERVICE_OPEN_COVER_TILT: Final = "open_cover_tilt" +SERVICE_SAVE_PERSISTENT_STATES: Final = "save_persistent_states" SERVICE_SET_COVER_POSITION: Final = "set_cover_position" SERVICE_SET_COVER_TILT_POSITION: Final = "set_cover_tilt_position" SERVICE_STOP_COVER: Final = "stop_cover" diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 67b2d329af1..da4d2bacf15 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -100,6 +100,12 @@ class RestoreStateData: return cast(RestoreStateData, await load_instance(hass)) + @classmethod + async def async_save_persistent_states(cls, hass: HomeAssistant) -> None: + """Dump states now.""" + data = await cls.async_get_instance(hass) + await data.async_dump_states() + def __init__(self, hass: HomeAssistant) -> None: """Initialize the restore state data class.""" self.hass: HomeAssistant = hass diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index d12cc8d9a7b..fb4a0f4c1da 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -23,6 +23,7 @@ from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, + SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -543,3 +544,18 @@ async def test_stop_homeassistant(hass): assert not mock_check.called await hass.async_block_till_done() assert mock_restart.called + + +async def test_save_persistent_states(hass): + """Test we can call save_persistent_states.""" + await async_setup_component(hass, "homeassistant", {}) + with patch( + "homeassistant.helpers.restore_state.RestoreStateData.async_save_persistent_states", + return_value=None, + ) as mock_save: + await hass.services.async_call( + "homeassistant", + SERVICE_SAVE_PERSISTENT_STATES, + blocking=True, + ) + assert mock_save.called diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 1d3be2ca98d..d138a5381da 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -98,6 +98,62 @@ async def test_periodic_write(hass): assert not mock_write_data.called +async def test_save_persistent_states(hass): + """Test that we cancel the currently running job, save the data, and verify the perdiodic job continues.""" + data = await RestoreStateData.async_get_instance(hass) + await hass.async_block_till_done() + await data.store.async_save([]) + + # Emulate a fresh load + hass.data[DATA_RESTORE_STATE_TASK] = None + + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = "input_boolean.b1" + + with patch( + "homeassistant.helpers.restore_state.Store.async_save" + ) as mock_write_data: + await entity.async_get_last_state() + await hass.async_block_till_done() + + # Startup Save + assert mock_write_data.called + + with patch( + "homeassistant.helpers.restore_state.Store.async_save" + ) as mock_write_data: + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + # Not quite the first interval + assert not mock_write_data.called + + with patch( + "homeassistant.helpers.restore_state.Store.async_save" + ) as mock_write_data: + await RestoreStateData.async_save_persistent_states(hass) + await hass.async_block_till_done() + + assert mock_write_data.called + + with patch( + "homeassistant.helpers.restore_state.Store.async_save" + ) as mock_write_data: + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=20)) + await hass.async_block_till_done() + # Verify still saving + assert mock_write_data.called + + with patch( + "homeassistant.helpers.restore_state.Store.async_save" + ) as mock_write_data: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + # Verify normal shutdown + assert mock_write_data.called + + async def test_hass_starting(hass): """Test that we cache data.""" hass.state = CoreState.starting From 25f3cdde50dad6f5c536b02a42b8c3470475e7c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Aug 2021 18:03:22 -0500 Subject: [PATCH 271/903] Add powerwall import and export sensors (#54018) Co-authored-by: Bram Kragten --- homeassistant/components/powerwall/const.py | 2 - homeassistant/components/powerwall/sensor.py | 64 ++++++++++++++++++-- tests/components/powerwall/test_sensor.py | 23 ++++--- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py index c86333cb9f8..b2cd48df276 100644 --- a/homeassistant/components/powerwall/const.py +++ b/homeassistant/components/powerwall/const.py @@ -9,8 +9,6 @@ POWERWALL_API_CHANGED = "api_changed" UPDATE_INTERVAL = 30 ATTR_FREQUENCY = "frequency" -ATTR_ENERGY_EXPORTED = "energy_exported_(in_kW)" -ATTR_ENERGY_IMPORTED = "energy_imported_(in_kW)" ATTR_INSTANT_AVERAGE_VOLTAGE = "instant_average_voltage" ATTR_INSTANT_TOTAL_CURRENT = "instant_total_current" ATTR_IS_ACTIVE = "is_active" diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index d536c776bf0..b2281c515ae 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -6,14 +6,15 @@ from tesla_powerwall import MeterType from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_KILO_WATT, ) +import homeassistant.util.dt as dt_util from .const import ( - ATTR_ENERGY_EXPORTED, - ATTR_ENERGY_IMPORTED, ATTR_FREQUENCY, ATTR_INSTANT_AVERAGE_VOLTAGE, ATTR_INSTANT_TOTAL_CURRENT, @@ -29,6 +30,11 @@ from .const import ( ) from .entity import PowerWallEntity +_METER_DIRECTION_EXPORT = "export" +_METER_DIRECTION_IMPORT = "import" +_METER_DIRECTIONS = [_METER_DIRECTION_EXPORT, _METER_DIRECTION_IMPORT] + + _LOGGER = logging.getLogger(__name__) @@ -55,6 +61,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): powerwalls_serial_numbers, ) ) + for meter_direction in _METER_DIRECTIONS: + entities.append( + PowerWallEnergyDirectionSensor( + meter, + coordinator, + site_info, + status, + device_type, + powerwalls_serial_numbers, + meter_direction, + ) + ) entities.append( PowerWallChargeSensor( @@ -124,9 +142,47 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): meter = self.coordinator.data[POWERWALL_API_METERS].get_meter(self._meter) return { ATTR_FREQUENCY: round(meter.frequency, 1), - ATTR_ENERGY_EXPORTED: meter.get_energy_exported(), - ATTR_ENERGY_IMPORTED: meter.get_energy_imported(), ATTR_INSTANT_AVERAGE_VOLTAGE: round(meter.average_voltage, 1), ATTR_INSTANT_TOTAL_CURRENT: meter.get_instant_total_current(), ATTR_IS_ACTIVE: meter.is_active(), } + + +class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): + """Representation of an Powerwall Direction Energy sensor.""" + + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_last_reset = dt_util.utc_from_timestamp(0) + + def __init__( + self, + meter: MeterType, + coordinator, + site_info, + status, + device_type, + powerwalls_serial_numbers, + meter_direction, + ): + """Initialize the sensor.""" + super().__init__( + coordinator, site_info, status, device_type, powerwalls_serial_numbers + ) + self._meter = meter + self._meter_direction = meter_direction + self._attr_name = ( + f"Powerwall {self._meter.value.title()} {self._meter_direction.title()}" + ) + self._attr_unique_id = ( + f"{self.base_unique_id}_{self._meter.value}_{self._meter_direction}" + ) + + @property + def state(self): + """Get the current value in kWh.""" + meter = self.coordinator.data[POWERWALL_API_METERS].get_meter(self._meter) + if self._meter_direction == _METER_DIRECTION_EXPORT: + return meter.get_energy_exported() + return meter.get_energy_imported() diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index 32c7da9c78e..33c186e922c 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -39,8 +39,6 @@ async def test_sensors(hass): assert state.state == "0.032" expected_attributes = { "frequency": 60, - "energy_exported_(in_kW)": 10429.5, - "energy_imported_(in_kW)": 4824.2, "instant_average_voltage": 120.7, "unit_of_measurement": "kW", "friendly_name": "Powerwall Site Now", @@ -52,12 +50,16 @@ async def test_sensors(hass): for key, value in expected_attributes.items(): assert state.attributes[key] == value + assert float(hass.states.get("sensor.powerwall_site_export").state) == 10429.5 + assert float(hass.states.get("sensor.powerwall_site_import").state) == 4824.2 + + export_attributes = hass.states.get("sensor.powerwall_site_export").attributes + assert export_attributes["unit_of_measurement"] == "kWh" + state = hass.states.get("sensor.powerwall_load_now") assert state.state == "1.971" expected_attributes = { "frequency": 60, - "energy_exported_(in_kW)": 1056.8, - "energy_imported_(in_kW)": 4693.0, "instant_average_voltage": 120.7, "unit_of_measurement": "kW", "friendly_name": "Powerwall Load Now", @@ -69,12 +71,13 @@ async def test_sensors(hass): for key, value in expected_attributes.items(): assert state.attributes[key] == value + assert float(hass.states.get("sensor.powerwall_load_export").state) == 1056.8 + assert float(hass.states.get("sensor.powerwall_load_import").state) == 4693.0 + state = hass.states.get("sensor.powerwall_battery_now") assert state.state == "-8.55" expected_attributes = { "frequency": 60.0, - "energy_exported_(in_kW)": 3620.0, - "energy_imported_(in_kW)": 4216.2, "instant_average_voltage": 240.6, "unit_of_measurement": "kW", "friendly_name": "Powerwall Battery Now", @@ -86,12 +89,13 @@ async def test_sensors(hass): for key, value in expected_attributes.items(): assert state.attributes[key] == value + assert float(hass.states.get("sensor.powerwall_battery_export").state) == 3620.0 + assert float(hass.states.get("sensor.powerwall_battery_import").state) == 4216.2 + state = hass.states.get("sensor.powerwall_solar_now") assert state.state == "10.49" expected_attributes = { "frequency": 60, - "energy_exported_(in_kW)": 9864.2, - "energy_imported_(in_kW)": 28.2, "instant_average_voltage": 120.7, "unit_of_measurement": "kW", "friendly_name": "Powerwall Solar Now", @@ -103,6 +107,9 @@ async def test_sensors(hass): for key, value in expected_attributes.items(): assert state.attributes[key] == value + assert float(hass.states.get("sensor.powerwall_solar_export").state) == 9864.2 + assert float(hass.states.get("sensor.powerwall_solar_import").state) == 28.2 + state = hass.states.get("sensor.powerwall_charge") assert state.state == "47" expected_attributes = { From d80da944a315a2b84ad6c31aeda03c2980c912fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 10 Aug 2021 01:24:18 +0200 Subject: [PATCH 272/903] Version sensor entity cleanup (#53915) Co-authored-by: Franck Nijhof --- homeassistant/components/version/sensor.py | 117 ++++++++++----------- tests/components/version/test_sensor.py | 56 +++++++--- 2 files changed, 98 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index 04165ec9db1..f20f2682986 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -2,11 +2,19 @@ from datetime import timedelta import logging -from pyhaversion import HaVersion, HaVersionChannel, HaVersionSource -from pyhaversion.exceptions import HaVersionFetchException, HaVersionParseException +from pyhaversion import ( + HaVersion, + HaVersionChannel, + HaVersionSource, + exceptions as pyhaversionexceptions, +) import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_NAME, CONF_SOURCE from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -30,12 +38,10 @@ ALL_IMAGES = [ "raspberrypi4", "tinker", ] -ALL_SOURCES = [ - "container", - "haio", - "local", - "pypi", - "supervisor", + +HA_VERSION_SOURCES = [source.value for source in HaVersionSource] + +ALL_SOURCES = HA_VERSION_SOURCES + [ "hassio", # Kept to not break existing configurations "docker", # Kept to not break existing configurations ] @@ -48,8 +54,6 @@ DEFAULT_NAME_LATEST = "Latest Version" DEFAULT_NAME_LOCAL = "Current Version" DEFAULT_SOURCE = "local" -ICON = "mdi:package-up" - TIME_BETWEEN_UPDATES = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -72,40 +76,42 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= name = config.get(CONF_NAME) source = config.get(CONF_SOURCE) + channel = HaVersionChannel.BETA if beta else HaVersionChannel.STABLE session = async_get_clientsession(hass) - channel = HaVersionChannel.BETA if beta else HaVersionChannel.STABLE + if source in HA_VERSION_SOURCES: + source = HaVersionSource(source) + elif source == "hassio": + source = HaVersionSource.SUPERVISOR + elif source == "docker": + source = HaVersionSource.CONTAINER - if source == "pypi": - haversion = VersionData( - HaVersion(session, source=HaVersionSource.PYPI, channel=channel) - ) - elif source in ["hassio", "supervisor"]: - haversion = VersionData( - HaVersion( - session, source=HaVersionSource.SUPERVISOR, channel=channel, image=image - ) - ) - elif source in ["docker", "container"]: - if image is not None and image != DEFAULT_IMAGE: - image = f"{image}-homeassistant" - haversion = VersionData( - HaVersion( - session, source=HaVersionSource.CONTAINER, channel=channel, image=image - ) - ) - elif source == "haio": - haversion = VersionData(HaVersion(session, source=HaVersionSource.HAIO)) - else: - haversion = VersionData(HaVersion(session, source=HaVersionSource.LOCAL)) + if ( + source in (HaVersionSource.SUPERVISOR, HaVersionSource.CONTAINER) + and image is not None + and image != DEFAULT_IMAGE + ): + image = f"{image}-homeassistant" - if not name: - if source == DEFAULT_SOURCE: + if not (name := config.get(CONF_NAME)): + if source == HaVersionSource.LOCAL: name = DEFAULT_NAME_LOCAL else: name = DEFAULT_NAME_LATEST - async_add_entities([VersionSensor(haversion, name)], True) + async_add_entities( + [ + VersionSensor( + VersionData( + HaVersion( + session=session, source=source, image=image, channel=channel + ) + ), + SensorEntityDescription(key=source, name=name), + ) + ], + True, + ) class VersionData: @@ -120,9 +126,9 @@ class VersionData: """Get the latest version information.""" try: await self.api.get_version() - except HaVersionFetchException as exception: + except pyhaversionexceptions.HaVersionFetchException as exception: _LOGGER.warning(exception) - except HaVersionParseException as exception: + except pyhaversionexceptions.HaVersionParseException as exception: _LOGGER.warning( "Could not parse data received for %s - %s", self.api.source, exception ) @@ -131,32 +137,19 @@ class VersionData: class VersionSensor(SensorEntity): """Representation of a Home Assistant version sensor.""" - def __init__(self, data: VersionData, name: str) -> None: + _attr_icon = "mdi:package-up" + + def __init__( + self, + data: VersionData, + description: SensorEntityDescription, + ) -> None: """Initialize the Version sensor.""" self.data = data - self._name = name - self._state = None + self.entity_description = description async def async_update(self): """Get the latest version information.""" await self.data.async_update() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self.data.api.version - - @property - def extra_state_attributes(self): - """Return attributes for the sensor.""" - return self.data.api.version_data - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON + self._attr_state = self.data.api.version + self._attr_extra_state_attributes = self.data.api.version_data diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index 164b4090e5f..1f64fe23039 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -1,26 +1,56 @@ """The test for the version sensor platform.""" from unittest.mock import patch +from pyhaversion import HaVersionSource, exceptions as pyhaversionexceptions +import pytest + +from homeassistant.components.version.sensor import ALL_SOURCES from homeassistant.setup import async_setup_component MOCK_VERSION = "10.0" -async def test_version_sensor(hass): - """Test the Version sensor.""" - config = {"sensor": {"platform": "version"}} +@pytest.mark.parametrize( + "source", + ALL_SOURCES, +) +async def test_version_source(hass, source): + """Test the Version sensor with different sources.""" + config = { + "sensor": {"platform": "version", "source": source, "image": "qemux86-64"} + } - assert await async_setup_component(hass, "sensor", config) - - -async def test_version(hass): - """Test the Version sensor.""" - config = {"sensor": {"platform": "version", "name": "test"}} - - with patch("homeassistant.const.__version__", MOCK_VERSION): + with patch("pyhaversion.version.HaVersion.version", MOCK_VERSION): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() - state = hass.states.get("sensor.test") + name = "current_version" if source == HaVersionSource.LOCAL else "latest_version" + state = hass.states.get(f"sensor.{name}") - assert state.state == "10.0" + assert state.state == MOCK_VERSION + + +async def test_version_fetch_exception(hass, caplog): + """Test fetch exception thrown during updates.""" + config = {"sensor": {"platform": "version"}} + with patch( + "pyhaversion.version.HaVersion.get_version", + side_effect=pyhaversionexceptions.HaVersionFetchException( + "Fetch exception from pyhaversion" + ), + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + assert "Fetch exception from pyhaversion" in caplog.text + + +async def test_version_parse_exception(hass, caplog): + """Test parse exception thrown during updates.""" + config = {"sensor": {"platform": "version"}} + with patch( + "pyhaversion.version.HaVersion.get_version", + side_effect=pyhaversionexceptions.HaVersionParseException, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + assert "Could not parse data received for HaVersionSource.LOCAL" in caplog.text From a40deac714c6531bac7557f679502b3db209617a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Aug 2021 16:45:39 -0700 Subject: [PATCH 273/903] Revert "Use entity class attributes for Bluesound (#53033)" (#54365) --- .../components/bluesound/media_player.py | 185 +++++++++++------- 1 file changed, 115 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index a565a0f560c..86d0be72bdc 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -203,29 +203,33 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BluesoundPlayer(MediaPlayerEntity): """Representation of a Bluesound Player.""" - _attr_media_content_type = MEDIA_TYPE_MUSIC - - def __init__(self, hass, host, port=DEFAULT_PORT, name=None, init_callback=None): + def __init__(self, hass, host, port=None, name=None, init_callback=None): """Initialize the media player.""" self.host = host self._hass = hass self.port = port self._polling_session = async_get_clientsession(hass) self._polling_task = None # The actual polling task. - self._attr_name = name + self._name = name + self._icon = None self._capture_items = [] self._services_items = [] self._preset_items = [] self._sync_status = {} self._status = None - self._is_online = None + self._last_status_update = None + self._is_online = False self._retry_remove = None + self._muted = False self._master = None - self._group_name = None - self._bluesound_device_name = None self._is_master = False + self._group_name = None self._group_list = [] + self._bluesound_device_name = None + self._init_callback = init_callback + if self.port is None: + self.port = DEFAULT_PORT class _TimeoutException(Exception): pass @@ -248,12 +252,12 @@ class BluesoundPlayer(MediaPlayerEntity): return None self._sync_status = resp["SyncStatus"].copy() - if not self.name: - self._attr_name = self._sync_status.get("@name", self.host) + if not self._name: + self._name = self._sync_status.get("@name", self.host) if not self._bluesound_device_name: self._bluesound_device_name = self._sync_status.get("@name", self.host) - if not self.icon: - self._attr_icon = self._sync_status.get("@icon", self.host) + if not self._icon: + self._icon = self._sync_status.get("@icon", self.host) master = self._sync_status.get("master") if master is not None: @@ -287,14 +291,14 @@ class BluesoundPlayer(MediaPlayerEntity): await self.async_update_status() except (asyncio.TimeoutError, ClientError, BluesoundPlayer._TimeoutException): - _LOGGER.info("Node %s is offline, retrying later", self.name) + _LOGGER.info("Node %s is offline, retrying later", self._name) await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) self.start_polling() except CancelledError: - _LOGGER.debug("Stopping the polling of node %s", self.name) + _LOGGER.debug("Stopping the polling of node %s", self._name) except Exception: - _LOGGER.exception("Unexpected error in %s", self.name) + _LOGGER.exception("Unexpected error in %s", self._name) raise def start_polling(self): @@ -398,7 +402,7 @@ class BluesoundPlayer(MediaPlayerEntity): if response.status == HTTP_OK: result = await response.text() self._is_online = True - self._attr_media_position_updated_at = dt_util.utcnow() + self._last_status_update = dt_util.utcnow() self._status = xmltodict.parse(result)["status"].copy() group_name = self._status.get("groupName") @@ -434,58 +438,11 @@ class BluesoundPlayer(MediaPlayerEntity): except (asyncio.TimeoutError, ClientError): self._is_online = False - self._attr_media_position_updated_at = None + self._last_status_update = None self._status = None self.async_write_ha_state() - _LOGGER.info("Client connection error, marking %s as offline", self.name) + _LOGGER.info("Client connection error, marking %s as offline", self._name) raise - self.update_state_attr() - - def update_state_attr(self): - """Update state attributes.""" - if self._status is None: - self._attr_state = STATE_OFF - self._attr_supported_features = 0 - elif self.is_grouped and not self.is_master: - self._attr_state = STATE_GROUPED - self._attr_supported_features = ( - SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE - ) - else: - status = self._status.get("state") - self._attr_state = STATE_IDLE - if status in ("pause", "stop"): - self._attr_state = STATE_PAUSED - elif status in ("stream", "play"): - self._attr_state = STATE_PLAYING - supported = SUPPORT_CLEAR_PLAYLIST - if self._status.get("indexing", "0") == "0": - supported = ( - supported - | SUPPORT_PAUSE - | SUPPORT_PREVIOUS_TRACK - | SUPPORT_NEXT_TRACK - | SUPPORT_PLAY_MEDIA - | SUPPORT_STOP - | SUPPORT_PLAY - | SUPPORT_SELECT_SOURCE - | SUPPORT_SHUFFLE_SET - ) - if self.volume_level is not None and self.volume_level >= 0: - supported = ( - supported - | SUPPORT_VOLUME_STEP - | SUPPORT_VOLUME_SET - | SUPPORT_VOLUME_MUTE - ) - if self._status.get("canSeek", "") == "1": - supported = supported | SUPPORT_SEEK - self._attr_supported_features = supported - self._attr_extra_state_attributes = {} - if self._group_list: - self._attr_extra_state_attributes = {ATTR_BLUESOUND_GROUP: self._group_list} - self._attr_extra_state_attributes[ATTR_MASTER] = self._is_master - self._attr_shuffle = self._status.get("shuffle", "0") == "1" async def async_trigger_sync_on_all(self): """Trigger sync status update on all devices.""" @@ -585,6 +542,27 @@ class BluesoundPlayer(MediaPlayerEntity): return self._services_items + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def state(self): + """Return the state of the device.""" + if self._status is None: + return STATE_OFF + + if self.is_grouped and not self.is_master: + return STATE_GROUPED + + status = self._status.get("state") + if status in ("pause", "stop"): + return STATE_PAUSED + if status in ("stream", "play"): + return STATE_PLAYING + return STATE_IDLE + @property def media_title(self): """Title of current playing media.""" @@ -639,7 +617,7 @@ class BluesoundPlayer(MediaPlayerEntity): return None mediastate = self.state - if self.media_position_updated_at is None or mediastate == STATE_IDLE: + if self._last_status_update is None or mediastate == STATE_IDLE: return None position = self._status.get("secs") @@ -648,9 +626,7 @@ class BluesoundPlayer(MediaPlayerEntity): position = float(position) if mediastate == STATE_PLAYING: - position += ( - dt_util.utcnow() - self.media_position_updated_at - ).total_seconds() + position += (dt_util.utcnow() - self._last_status_update).total_seconds() return position @@ -665,6 +641,11 @@ class BluesoundPlayer(MediaPlayerEntity): return None return float(duration) + @property + def media_position_updated_at(self): + """Last time status was updated.""" + return self._last_status_update + @property def volume_level(self): """Volume level of the media player (0..1).""" @@ -687,11 +668,21 @@ class BluesoundPlayer(MediaPlayerEntity): mute = bool(int(mute)) return mute + @property + def name(self): + """Return the name of the device.""" + return self._name + @property def bluesound_device_name(self): """Return the device name as returned by the device.""" return self._bluesound_device_name + @property + def icon(self): + """Return the icon of the device.""" + return self._icon + @property def source_list(self): """List of available input sources.""" @@ -787,15 +778,58 @@ class BluesoundPlayer(MediaPlayerEntity): return None @property - def is_master(self) -> bool: + def supported_features(self): + """Flag of media commands that are supported.""" + if self._status is None: + return 0 + + if self.is_grouped and not self.is_master: + return SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE + + supported = SUPPORT_CLEAR_PLAYLIST + + if self._status.get("indexing", "0") == "0": + supported = ( + supported + | SUPPORT_PAUSE + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | SUPPORT_PLAY + | SUPPORT_SELECT_SOURCE + | SUPPORT_SHUFFLE_SET + ) + + current_vol = self.volume_level + if current_vol is not None and current_vol >= 0: + supported = ( + supported + | SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + ) + + if self._status.get("canSeek", "") == "1": + supported = supported | SUPPORT_SEEK + + return supported + + @property + def is_master(self): """Return true if player is a coordinator.""" return self._is_master @property - def is_grouped(self) -> bool: + def is_grouped(self): """Return true if player is a coordinator.""" return self._master is not None or self._is_master + @property + def shuffle(self): + """Return true if shuffle is active.""" + return self._status.get("shuffle", "0") == "1" + async def async_join(self, master): """Join the player to a group.""" master_device = [ @@ -815,6 +849,17 @@ class BluesoundPlayer(MediaPlayerEntity): else: _LOGGER.error("Master not found %s", master_device) + @property + def extra_state_attributes(self): + """List members in group.""" + attributes = {} + if self._group_list: + attributes = {ATTR_BLUESOUND_GROUP: self._group_list} + + attributes[ATTR_MASTER] = self._is_master + + return attributes + def rebuild_bluesound_group(self): """Rebuild the list of entities in speaker group.""" if self._group_name is None: From 38a7bdbcf35327de54461daf59778bc0c665a207 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Aug 2021 16:45:56 -0700 Subject: [PATCH 274/903] Do not process forwarded for headers for cloud requests (#54364) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/http/forwarded.py | 11 ++++++++++- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/http/test_forwarded.py | 20 ++++++++++++++++++++ 6 files changed, 34 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7516f32c3e1..abf73c1d54b 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.44.0"], + "requirements": ["hass-nabucasa==0.45.1"], "dependencies": ["http", "webhook"], "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], diff --git a/homeassistant/components/http/forwarded.py b/homeassistant/components/http/forwarded.py index 18bc51af1d1..9a76866ba21 100644 --- a/homeassistant/components/http/forwarded.py +++ b/homeassistant/components/http/forwarded.py @@ -63,12 +63,19 @@ def async_setup_forwarded( an HTTP 400 status code is thrown. """ + try: + from hass_nabucasa import remote # pylint: disable=import-outside-toplevel + except ImportError: + remote = None + @middleware async def forwarded_middleware( request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process forwarded data by a reverse proxy.""" - overrides: dict[str, str] = {} + # Skip requests from Remote UI + if remote is not None and remote.is_cloud_request.get(): + return await handler(request) # Handle X-Forwarded-For forwarded_for_headers: list[str] = request.headers.getall(X_FORWARDED_FOR, []) @@ -120,6 +127,8 @@ def async_setup_forwarded( ) raise HTTPBadRequest from err + overrides: dict[str, str] = {} + # Find the last trusted index in the X-Forwarded-For list forwarded_for_index = 0 for forwarded_ip in forwarded_for: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2e07d0adc18..d03c4b7c4f7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==3.3.2 defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 -hass-nabucasa==0.44.0 +hass-nabucasa==0.45.1 home-assistant-frontend==20210809.0 httpx==0.18.2 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index b71b7fcc2c0..397594de771 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -753,7 +753,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.44.0 +hass-nabucasa==0.45.1 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 774721010f8..5004aa26dd7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -431,7 +431,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.44.0 +hass-nabucasa==0.45.1 # homeassistant.components.tasmota hatasmota==0.2.20 diff --git a/tests/components/http/test_forwarded.py b/tests/components/http/test_forwarded.py index 400a1f32729..42e67416044 100644 --- a/tests/components/http/test_forwarded.py +++ b/tests/components/http/test_forwarded.py @@ -1,5 +1,6 @@ """Test real forwarded middleware.""" from ipaddress import ip_network +from unittest.mock import Mock, patch from aiohttp import web from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO @@ -441,3 +442,22 @@ async def test_x_forwarded_host_with_empty_header(aiohttp_client, caplog): assert resp.status == 400 assert "Empty value received in X-Forward-Host header" in caplog.text + + +async def test_x_forwarded_cloud(aiohttp_client, caplog): + """Test that cloud requests are not processed.""" + app = web.Application() + app.router.add_get("/", mock_handler) + async_setup_forwarded(app, True, [ip_network("127.0.0.1")]) + + mock_api_client = await aiohttp_client(app) + + with patch( + "hass_nabucasa.remote.is_cloud_request", Mock(get=Mock(return_value=True)) + ): + resp = await mock_api_client.get( + "/", headers={X_FORWARDED_FOR: "222.222.222.222", X_FORWARDED_HOST: ""} + ) + + # This request would normally fail because it's invalid, now it works. + assert resp.status == 200 From 1948d11d84ae3f758571881eb9aa6245e24df7fe Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Tue, 10 Aug 2021 02:10:53 +0200 Subject: [PATCH 275/903] AsusWRT remove default EntityDescription property (#54367) --- homeassistant/components/asuswrt/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 6c0671b53cb..ef186a80085 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -49,7 +49,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( name="Devices Connected", icon="mdi:router-network", unit_of_measurement=UNIT_DEVICES, - entity_registry_enabled_default=True, ), AsusWrtSensorEntityDescription( key=SENSORS_RATES[0], From f92f0bb87bc6686c2686c57b11669f1c0c105656 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 10 Aug 2021 04:25:22 +0200 Subject: [PATCH 276/903] Use EntityDescription - juicenet (#54362) * Use EntityDescription - juicenet * Move part of icon to EntityDescription * Remove default values * Remove name override to use the _attr_name Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- homeassistant/components/juicenet/entity.py | 5 - homeassistant/components/juicenet/sensor.py | 137 +++++++++++--------- 2 files changed, 77 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py index 759979c5f11..9b1def3b678 100644 --- a/homeassistant/components/juicenet/entity.py +++ b/homeassistant/components/juicenet/entity.py @@ -14,11 +14,6 @@ class JuiceNetDevice(CoordinatorEntity): self.device = device self.type = sensor_type - @property - def name(self): - """Return the name of the device.""" - return self.device.name - @property def unique_id(self): """Return a unique ID.""" diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 51792daf38c..2b8bd61e1fb 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -1,5 +1,11 @@ """Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -17,61 +23,86 @@ from homeassistant.const import ( from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR from .entity import JuiceNetDevice -SENSOR_TYPES = { - "status": ["Charging Status", None, None, None], - "temperature": [ - "Temperature", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, - ], - "voltage": ["Voltage", ELECTRIC_POTENTIAL_VOLT, DEVICE_CLASS_VOLTAGE, None], - "amps": [ - "Amps", - ELECTRIC_CURRENT_AMPERE, - DEVICE_CLASS_CURRENT, - STATE_CLASS_MEASUREMENT, - ], - "watts": ["Watts", POWER_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT], - "charge_time": ["Charge time", TIME_SECONDS, None, None], - "energy_added": ["Energy added", ENERGY_WATT_HOUR, DEVICE_CLASS_ENERGY, None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="status", + name="Charging Status", + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage", + name="Voltage", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + device_class=DEVICE_CLASS_VOLTAGE, + ), + SensorEntityDescription( + key="amps", + name="Amps", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="watts", + name="Watts", + unit_of_measurement=POWER_WATT, + icon="mdi:flash", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="charge_time", + name="Charge time", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="energy_added", + name="Energy added", + unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:flash", + device_class=DEVICE_CLASS_ENERGY, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the JuiceNet Sensors.""" - entities = [] juicenet_data = hass.data[DOMAIN][config_entry.entry_id] api = juicenet_data[JUICENET_API] coordinator = juicenet_data[JUICENET_COORDINATOR] - for device in api.devices: - for sensor in SENSOR_TYPES: - entities.append(JuiceNetSensorDevice(device, sensor, coordinator)) + entities = [ + JuiceNetSensorDevice(device, coordinator, description) + for device in api.devices + for description in SENSOR_TYPES + ] async_add_entities(entities) class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): """Implementation of a JuiceNet sensor.""" - def __init__(self, device, sensor_type, coordinator): + def __init__(self, device, coordinator, description: SensorEntityDescription): """Initialise the sensor.""" - super().__init__(device, sensor_type, coordinator) - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] - self._attr_state_class = SENSOR_TYPES[sensor_type][3] - - @property - def name(self): - """Return the name of the device.""" - return f"{self.device.name} {self._name}" + super().__init__(device, description.key, coordinator) + self.entity_description = description + self._attr_name = f"{self.device.name} {description.name}" @property def icon(self): """Return the icon of the sensor.""" icon = None - if self.type == "status": + if self.entity_description.key == "status": status = self.device.status if status == "standby": icon = "mdi:power-plug-off" @@ -79,42 +110,28 @@ class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): icon = "mdi:power-plug" elif status == "charging": icon = "mdi:battery-positive" - elif self.type == "temperature": - icon = "mdi:thermometer" - elif self.type == "voltage": - icon = "mdi:flash" - elif self.type == "amps": - icon = "mdi:flash" - elif self.type == "watts": - icon = "mdi:flash" - elif self.type == "charge_time": - icon = "mdi:timer-outline" - elif self.type == "energy_added": - icon = "mdi:flash" + else: + icon = self.entity_description.icon return icon - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - @property def state(self): """Return the state.""" state = None - if self.type == "status": + sensor_type = self.entity_description.key + if sensor_type == "status": state = self.device.status - elif self.type == "temperature": + elif sensor_type == "temperature": state = self.device.temperature - elif self.type == "voltage": + elif sensor_type == "voltage": state = self.device.voltage - elif self.type == "amps": + elif sensor_type == "amps": state = self.device.amps - elif self.type == "watts": + elif sensor_type == "watts": state = self.device.watts - elif self.type == "charge_time": + elif sensor_type == "charge_time": state = self.device.charge_time - elif self.type == "energy_added": + elif sensor_type == "energy_added": state = self.device.energy_added else: state = "Unknown" From f60fbf719773245c80ac2214912e54572715b73b Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 9 Aug 2021 23:16:18 -0400 Subject: [PATCH 277/903] Update Climacell rate limit (#54373) --- homeassistant/components/climacell/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/climacell/const.py b/homeassistant/components/climacell/const.py index 9e80c769abf..162fbb01545 100644 --- a/homeassistant/components/climacell/const.py +++ b/homeassistant/components/climacell/const.py @@ -66,7 +66,7 @@ DEFAULT_FORECAST_TYPE = DAILY DOMAIN = "climacell" ATTRIBUTION = "Powered by ClimaCell" -MAX_REQUESTS_PER_DAY = 500 +MAX_REQUESTS_PER_DAY = 100 CLEAR_CONDITIONS = {"night": ATTR_CONDITION_CLEAR_NIGHT, "day": ATTR_CONDITION_SUNNY} From b76899f546975f75fa56cdde57e344560ae73025 Mon Sep 17 00:00:00 2001 From: Brett Date: Tue, 10 Aug 2021 13:21:24 +1000 Subject: [PATCH 278/903] Fix race condition in Advantage Air (#53439) --- .../components/advantage_air/climate.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 1d377abc065..1e6027b8db6 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -15,7 +15,6 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS -from homeassistant.core import callback from homeassistant.helpers import entity_platform from .const import ( @@ -166,19 +165,22 @@ class AdvantageAirZone(AdvantageAirClimateEntity): f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}' ) - async def async_added_to_hass(self): - """When entity is added to hass.""" - self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) - - @callback - def _update_callback(self) -> None: - """Load data from integration.""" - self._attr_current_temperature = self._zone["measuredTemp"] - self._attr_target_temperature = self._zone["setTemp"] - self._attr_hvac_mode = HVAC_MODE_OFF + @property + def hvac_mode(self): + """Return the current state as HVAC mode.""" if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: - self._attr_hvac_mode = HVAC_MODE_FAN_ONLY - self.async_write_ha_state() + return HVAC_MODE_FAN_ONLY + return HVAC_MODE_OFF + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._zone["measuredTemp"] + + @property + def target_temperature(self): + """Return the target temperature.""" + return self._zone["setTemp"] async def async_set_hvac_mode(self, hvac_mode): """Set the HVAC Mode and State.""" From 3d31bd5c684b6eb4495ac97c0e7db8fb19f00080 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 10 Aug 2021 05:56:04 +0200 Subject: [PATCH 279/903] Upgrade codecov to 2.1.12 (#54370) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index aceec3229a9..acfe29db593 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -4,7 +4,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -codecov==2.1.11 +codecov==2.1.12 coverage==5.5 jsonpickle==1.4.1 mock-open==1.4.0 From 3202d4882a2ea043b8bdfa16c643a31ad3c224a3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 10 Aug 2021 05:56:19 +0200 Subject: [PATCH 280/903] Upgrade debugpy to 1.4.1 (#54369) --- homeassistant/components/debugpy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index a67d7181a90..3ff5d087e14 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -2,7 +2,7 @@ "domain": "debugpy", "name": "Remote Python Debugger", "documentation": "https://www.home-assistant.io/integrations/debugpy", - "requirements": ["debugpy==1.4.0"], + "requirements": ["debugpy==1.4.1"], "codeowners": ["@frenck"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index 397594de771..6be000417be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -486,7 +486,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.4.0 +debugpy==1.4.1 # homeassistant.components.decora # decora==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5004aa26dd7..69e3bd85d32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -282,7 +282,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.4.0 +debugpy==1.4.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 934662cd54610a024bf9da9f7929e43fb18e8980 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Aug 2021 23:17:47 -0700 Subject: [PATCH 281/903] Handle CO2Signal response value being None (#54377) --- homeassistant/components/co2signal/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index bd8d94355fd..ea1cd1f6169 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -118,7 +118,8 @@ class CO2Sensor(update_coordinator.CoordinatorEntity[CO2SignalResponse], SensorE def available(self) -> bool: """Return True if entity is available.""" return ( - super().available and self._description.key in self.coordinator.data["data"] + super().available + and self.coordinator.data["data"].get(self._description.key) is not None ) @property From 8cb3a485e07374c538c593aa94388549dd149e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 10 Aug 2021 09:19:28 +0200 Subject: [PATCH 282/903] Fix Canary sensor state (#54380) --- homeassistant/components/canary/sensor.py | 6 +++++- tests/components/canary/test_sensor.py | 21 ++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 5c92f0089f2..acb885055a3 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -113,7 +113,6 @@ class CanarySensor(CoordinatorEntity, SensorEntity): canary_sensor_type = SensorType.BATTERY self._canary_type = canary_sensor_type - self._attr_state = self.reading self._attr_unique_id = f"{device.device_id}_{sensor_type[0]}" self._attr_device_info = { "identifiers": {(DOMAIN, str(device.device_id))}, @@ -144,6 +143,11 @@ class CanarySensor(CoordinatorEntity, SensorEntity): return None + @property + def state(self) -> float | None: + """Return the state of the sensor.""" + return self.reading + @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes.""" diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index 6419f81a62e..67d4a724584 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -118,9 +118,10 @@ async def test_sensors_attributes_pro(hass, canary) -> None: await hass.async_block_till_done() entity_id = "sensor.home_dining_room_air_quality" - state = hass.states.get(entity_id) - assert state - assert state.attributes[ATTR_AIR_QUALITY] == STATE_AIR_QUALITY_ABNORMAL + state1 = hass.states.get(entity_id) + assert state1 + assert state1.state == "0.59" + assert state1.attributes[ATTR_AIR_QUALITY] == STATE_AIR_QUALITY_ABNORMAL instance.get_latest_readings.return_value = [ mock_reading("temperature", "21.12"), @@ -133,9 +134,10 @@ async def test_sensors_attributes_pro(hass, canary) -> None: await hass.helpers.entity_component.async_update_entity(entity_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state - assert state.attributes[ATTR_AIR_QUALITY] == STATE_AIR_QUALITY_VERY_ABNORMAL + state2 = hass.states.get(entity_id) + assert state2 + assert state2.state == "0.4" + assert state2.attributes[ATTR_AIR_QUALITY] == STATE_AIR_QUALITY_VERY_ABNORMAL instance.get_latest_readings.return_value = [ mock_reading("temperature", "21.12"), @@ -148,9 +150,10 @@ async def test_sensors_attributes_pro(hass, canary) -> None: await hass.helpers.entity_component.async_update_entity(entity_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state - assert state.attributes[ATTR_AIR_QUALITY] == STATE_AIR_QUALITY_NORMAL + state3 = hass.states.get(entity_id) + assert state3 + assert state3.state == "1.0" + assert state3.attributes[ATTR_AIR_QUALITY] == STATE_AIR_QUALITY_NORMAL async def test_sensors_flex(hass, canary) -> None: From 54538bb72bc589e2a415d5e5f0dee4c466e438c0 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 10 Aug 2021 10:16:38 +0200 Subject: [PATCH 283/903] Bump pymodbus version to 2.5.3rc1 (#54318) --- homeassistant/components/modbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 9f2208de175..549ad2c2351 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -2,7 +2,7 @@ "domain": "modbus", "name": "Modbus", "documentation": "https://www.home-assistant.io/integrations/modbus", - "requirements": ["pymodbus==2.5.2"], + "requirements": ["pymodbus==2.5.3rc1"], "codeowners": ["@adamchengtkc", "@janiversen", "@vzahradnik"], "quality_scale": "silver", "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 6be000417be..7c4001c4c5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1601,7 +1601,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==2.5.2 +pymodbus==2.5.3rc1 # homeassistant.components.monoprice pymonoprice==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69e3bd85d32..4f0b541ad31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -914,7 +914,7 @@ pymfy==0.11.0 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==2.5.2 +pymodbus==2.5.3rc1 # homeassistant.components.monoprice pymonoprice==0.3 From fc1babfc92ec975888b62a3509263e31a0e23f46 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Tue, 10 Aug 2021 10:45:56 +0200 Subject: [PATCH 284/903] Activate mypy for Filter (#54044) --- homeassistant/components/filter/sensor.py | 6 +++--- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 97412823b30..c40c703b846 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -405,7 +405,7 @@ class Filter: :param entity: used for debugging only """ if isinstance(window_size, int): - self.states = deque(maxlen=window_size) + self.states: deque = deque(maxlen=window_size) self.window_unit = WINDOW_SIZE_UNIT_NUMBER_EVENTS else: self.states = deque(maxlen=0) @@ -476,7 +476,7 @@ class RangeFilter(Filter, SensorEntity): super().__init__(FILTER_NAME_RANGE, precision=precision, entity=entity) self._lower_bound = lower_bound self._upper_bound = upper_bound - self._stats_internal = Counter() + self._stats_internal: Counter = Counter() def _filter_state(self, new_state): """Implement the range filter.""" @@ -522,7 +522,7 @@ class OutlierFilter(Filter, SensorEntity): """ super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) self._radius = radius - self._stats_internal = Counter() + self._stats_internal: Counter = Counter() self._store_raw = True def _filter_state(self, new_state): diff --git a/mypy.ini b/mypy.ini index 9c54f7a043f..4ad0dc9235f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1349,9 +1349,6 @@ ignore_errors = true [mypy-homeassistant.components.evohome.*] ignore_errors = true -[mypy-homeassistant.components.filter.*] -ignore_errors = true - [mypy-homeassistant.components.fireservicerota.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index e3b76747be2..38409ef8457 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -42,7 +42,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.enphase_envoy.*", "homeassistant.components.entur_public_transport.*", "homeassistant.components.evohome.*", - "homeassistant.components.filter.*", "homeassistant.components.fireservicerota.*", "homeassistant.components.firmata.*", "homeassistant.components.flo.*", From 020759d01d86507ee0d66859ba03218905f402bc Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Tue, 10 Aug 2021 10:46:33 +0200 Subject: [PATCH 285/903] Activate mypy for Alexa (#54042) --- homeassistant/components/alexa/capabilities.py | 2 +- homeassistant/components/alexa/errors.py | 6 ++++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index db1fa990c54..fcd6ebf6ae2 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -99,7 +99,7 @@ class AlexaCapability: return False @staticmethod - def properties_non_controllable() -> bool: + def properties_non_controllable() -> bool | None: """Return True if non controllable.""" return None diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 29643bacc53..a6adc488f75 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -1,4 +1,6 @@ """Alexa related errors.""" +from __future__ import annotations + from homeassistant.exceptions import HomeAssistantError from .const import API_TEMP_UNITS @@ -22,8 +24,8 @@ class AlexaError(Exception): A handler can raise subclasses of this to return an error to the request. """ - namespace = None - error_type = None + namespace: str | None = None + error_type: str | None = None def __init__(self, error_message, payload=None): """Initialize an alexa error.""" diff --git a/mypy.ini b/mypy.ini index 4ad0dc9235f..d7b01ea939b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1271,9 +1271,6 @@ ignore_errors = true [mypy-homeassistant.components.aemet.*] ignore_errors = true -[mypy-homeassistant.components.alexa.*] -ignore_errors = true - [mypy-homeassistant.components.almond.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 38409ef8457..4ed672f8e01 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -16,7 +16,6 @@ from .model import Config, Integration IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.adguard.*", "homeassistant.components.aemet.*", - "homeassistant.components.alexa.*", "homeassistant.components.almond.*", "homeassistant.components.amcrest.*", "homeassistant.components.analytics.*", From 7e2c6ae332b6f3679bb3b20e3960763040d8aed0 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Tue, 10 Aug 2021 10:47:17 +0200 Subject: [PATCH 286/903] Activate mypy for Pilight (#53956) --- homeassistant/components/pilight/__init__.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 02d56c890fe..5dbad2838bc 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -136,7 +136,7 @@ class CallRateDelayThrottle: def __init__(self, hass, delay_seconds: float) -> None: """Initialize the delay handler.""" self._delay = timedelta(seconds=max(0.0, delay_seconds)) - self._queue = [] + self._queue: list = [] self._active = False self._lock = threading.Lock() self._next_ts = dt_util.utcnow() diff --git a/mypy.ini b/mypy.ini index d7b01ea939b..22cd0c478d5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1574,9 +1574,6 @@ ignore_errors = true [mypy-homeassistant.components.philips_js.*] ignore_errors = true -[mypy-homeassistant.components.pilight.*] -ignore_errors = true - [mypy-homeassistant.components.ping.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 4ed672f8e01..31e364d6062 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -117,7 +117,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.ozw.*", "homeassistant.components.panasonic_viera.*", "homeassistant.components.philips_js.*", - "homeassistant.components.pilight.*", "homeassistant.components.ping.*", "homeassistant.components.pioneer.*", "homeassistant.components.plaato.*", From d8c679809faebcdcfa5bf200c04ad2b797c9e07c Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Tue, 10 Aug 2021 10:47:57 +0200 Subject: [PATCH 287/903] Activate mypy for SiteSage Emonitor (#54040) --- homeassistant/components/emonitor/__init__.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/emonitor/__init__.py b/homeassistant/components/emonitor/__init__.py index 69c8b907b72..91263db5127 100644 --- a/homeassistant/components/emonitor/__init__.py +++ b/homeassistant/components/emonitor/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = aiohttp_client.async_get_clientsession(hass) emonitor = Emonitor(entry.data[CONF_HOST], session) - coordinator = DataUpdateCoordinator( + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( hass, _LOGGER, name=entry.title, diff --git a/mypy.ini b/mypy.ini index 22cd0c478d5..01bf959491e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1334,9 +1334,6 @@ ignore_errors = true [mypy-homeassistant.components.elkm1.*] ignore_errors = true -[mypy-homeassistant.components.emonitor.*] -ignore_errors = true - [mypy-homeassistant.components.enphase_envoy.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 31e364d6062..c3b135b4de6 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -37,7 +37,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.directv.*", "homeassistant.components.doorbird.*", "homeassistant.components.elkm1.*", - "homeassistant.components.emonitor.*", "homeassistant.components.enphase_envoy.*", "homeassistant.components.entur_public_transport.*", "homeassistant.components.evohome.*", From 355a067d842effa7f67dc5044be88ffdd891fef9 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Tue, 10 Aug 2021 10:55:38 +0200 Subject: [PATCH 288/903] Activate mypy for Smart Meter Texas (#53954) --- homeassistant/components/smart_meter_texas/__init__.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index 3e88221851b..7b500ed58e7 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -94,7 +94,7 @@ class SmartMeterTexasData: self.account = account websession = aiohttp_client.async_get_clientsession(hass) self.client = Client(websession, account) - self.meters = [] + self.meters: list = [] async def setup(self): """Fetch all of the user's meters.""" diff --git a/mypy.ini b/mypy.ini index 01bf959491e..dcedc8d57ef 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1631,9 +1631,6 @@ ignore_errors = true [mypy-homeassistant.components.sma.*] ignore_errors = true -[mypy-homeassistant.components.smart_meter_texas.*] -ignore_errors = true - [mypy-homeassistant.components.smartthings.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index c3b135b4de6..3e669998eab 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -136,7 +136,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.sesame.*", "homeassistant.components.sharkiq.*", "homeassistant.components.sma.*", - "homeassistant.components.smart_meter_texas.*", "homeassistant.components.smartthings.*", "homeassistant.components.smarttub.*", "homeassistant.components.smarty.*", From 814411dc1d7d14d5ff0499fd7e8f9804b817c490 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Tue, 10 Aug 2021 10:56:34 +0200 Subject: [PATCH 289/903] Activate mypy for Solar-Log (#53952) --- homeassistant/components/solarlog/config_flow.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py index cced913222a..4267502e3ca 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -31,7 +31,7 @@ class SolarLogConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._errors = {} + self._errors: dict = {} def _host_in_configuration_exists(self, host) -> bool: """Return True if host exists in configuration.""" diff --git a/mypy.ini b/mypy.ini index dcedc8d57ef..3b6f040368d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1643,9 +1643,6 @@ ignore_errors = true [mypy-homeassistant.components.solaredge.*] ignore_errors = true -[mypy-homeassistant.components.solarlog.*] -ignore_errors = true - [mypy-homeassistant.components.somfy.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 3e669998eab..b507378db43 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -140,7 +140,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.smarttub.*", "homeassistant.components.smarty.*", "homeassistant.components.solaredge.*", - "homeassistant.components.solarlog.*", "homeassistant.components.somfy.*", "homeassistant.components.somfy_mylink.*", "homeassistant.components.sonarr.*", From a2a484045571ef248689848369d97b83794a52e7 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 10 Aug 2021 11:02:31 +0200 Subject: [PATCH 290/903] Using VCN install as action (#54383) --- .github/workflows/builder.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index abe0cfcb63e..25d4d0ca8a0 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -248,11 +248,12 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Install VCN tools + uses: home-assistant/actions/helpers/vcn@master + - name: Build Meta Image shell: bash run: | - bash <(curl https://getvcn.codenotary.com -L) - export DOCKER_CLI_EXPERIMENTAL=enabled function create_manifest() { From e5f884efd1042cbd0a391416957ddfe40a49d563 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 10 Aug 2021 11:48:16 +0200 Subject: [PATCH 291/903] Activate mypy for google_maps (#53725) --- .../components/google_maps/device_tracker.py | 13 ++++++++++--- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index b6bd6f71bf4..1a0396a69ac 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -1,4 +1,6 @@ """Support for Google Maps location sharing.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,7 +8,10 @@ from locationsharinglib import Service from locationsharinglib.locationsharinglibexceptions import InvalidCookies import voluptuous as vol -from homeassistant.components.device_tracker import PLATFORM_SCHEMA, SOURCE_TYPE_GPS +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA as PLATFORM_SCHEMA_BASE, + SOURCE_TYPE_GPS, +) from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, @@ -30,7 +35,9 @@ CONF_MAX_GPS_ACCURACY = "max_gps_accuracy" CREDENTIALS_FILE = ".google_maps_location_sharing.cookies" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +# the parent "device_tracker" have marked the schemas as legacy, so this +# need to be refactored as part of a bigger rewrite. +PLATFORM_SCHEMA = PLATFORM_SCHEMA_BASE.extend( { vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_MAX_GPS_ACCURACY, default=100000): vol.Coerce(float), @@ -53,7 +60,7 @@ class GoogleMapsScanner: self.username = config[CONF_USERNAME] self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY] self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=60) - self._prev_seen = {} + self._prev_seen: dict[str, str] = {} credfile = f"{hass.config.path(CREDENTIALS_FILE)}.{slugify(self.username)}" try: diff --git a/mypy.ini b/mypy.ini index 3b6f040368d..3e6c14fb6a8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1370,9 +1370,6 @@ ignore_errors = true [mypy-homeassistant.components.google_assistant.*] ignore_errors = true -[mypy-homeassistant.components.google_maps.*] -ignore_errors = true - [mypy-homeassistant.components.google_pubsub.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index b507378db43..88c54b4c91e 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -49,7 +49,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.geniushub.*", "homeassistant.components.glances.*", "homeassistant.components.google_assistant.*", - "homeassistant.components.google_maps.*", "homeassistant.components.google_pubsub.*", "homeassistant.components.gpmdp.*", "homeassistant.components.gree.*", From 9c29d9f8eb6db0284a0e45447a3400ed0c8157ed Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Tue, 10 Aug 2021 12:36:20 +0200 Subject: [PATCH 292/903] Activate mypy for Proxmox VE (#53955) --- homeassistant/components/proxmoxve/__init__.py | 5 ++++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 1b0d07c69a3..9c650363aad 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -1,4 +1,6 @@ """Support for Proxmox VE.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -132,7 +134,8 @@ async def async_setup(hass: HomeAssistant, config: dict): await hass.async_add_executor_job(build_client) - coordinators = hass.data[DOMAIN][COORDINATORS] = {} + coordinators: dict[str, dict[str, dict[int, DataUpdateCoordinator]]] = {} + hass.data[DOMAIN][COORDINATORS] = coordinators # Create a coordinator for each vm/container for host_config in config[DOMAIN]: diff --git a/mypy.ini b/mypy.ini index 3e6c14fb6a8..a97ba87f16b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1592,9 +1592,6 @@ ignore_errors = true [mypy-homeassistant.components.profiler.*] ignore_errors = true -[mypy-homeassistant.components.proxmoxve.*] -ignore_errors = true - [mypy-homeassistant.components.rachio.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 88c54b4c91e..e100ffcea52 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -123,7 +123,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.plum_lightpad.*", "homeassistant.components.point.*", "homeassistant.components.profiler.*", - "homeassistant.components.proxmoxve.*", "homeassistant.components.rachio.*", "homeassistant.components.ring.*", "homeassistant.components.rpi_power.*", From 39d7bb4f1a3711fb9ccc5c3f74efa021e0c10adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 10 Aug 2021 13:11:12 +0200 Subject: [PATCH 293/903] Use `_attr_*` for Launch Library (#54388) --- .../components/launch_library/sensor.py | 58 +++++++------------ 1 file changed, 21 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 831e44dca8f..1d2f8ef0577 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -42,48 +42,32 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class LaunchLibrarySensor(SensorEntity): """Representation of a launch_library Sensor.""" - def __init__(self, launches: PyLaunches, name: str) -> None: + _attr_icon = "mdi:rocket" + + def __init__(self, api: PyLaunches, name: str) -> None: """Initialize the sensor.""" - self.launches = launches - self.next_launch = None - self._name = name + self.api = api + self._attr_name = name async def async_update(self) -> None: """Get the latest data.""" try: - launches = await self.launches.upcoming_launches() + launches = await self.api.upcoming_launches() except PyLaunchesException as exception: _LOGGER.error("Error getting data, %s", exception) + self._attr_available = False else: - if launches: - self.next_launch = launches[0] - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def state(self) -> str | None: - """Return the state of the sensor.""" - if self.next_launch: - return self.next_launch.name - return None - - @property - def icon(self) -> str: - """Return the icon of the sensor.""" - return "mdi:rocket" - - @property - def extra_state_attributes(self) -> dict | None: - """Return attributes for the sensor.""" - if self.next_launch: - return { - ATTR_LAUNCH_TIME: self.next_launch.net, - ATTR_AGENCY: self.next_launch.launch_service_provider.name, - ATTR_AGENCY_COUNTRY_CODE: self.next_launch.pad.location.country_code, - ATTR_STREAM: self.next_launch.webcast_live, - ATTR_ATTRIBUTION: ATTRIBUTION, - } - return None + if launches and ( + next_launch := next((launch for launch in launches), None) + ): + self._attr_available = True + self._attr_state = next_launch.name + self._attr_extra_state_attributes.update( + { + ATTR_LAUNCH_TIME: next_launch.net, + ATTR_AGENCY: next_launch.launch_service_provider.name, + ATTR_AGENCY_COUNTRY_CODE: next_launch.pad.location.country_code, + ATTR_STREAM: next_launch.webcast_live, + ATTR_ATTRIBUTION: ATTRIBUTION, + } + ) From a7c08fff813bbffe3bb17b152ca47cd230c604a6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 11 Aug 2021 00:06:27 +1200 Subject: [PATCH 294/903] Apply suggested changes to tidy juicenet sensor code (#54390) --- homeassistant/components/juicenet/sensor.py | 25 +-------------------- 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 2b8bd61e1fb..435508f823d 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -32,7 +32,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="temperature", name="Temperature", unit_of_measurement=TEMP_CELSIUS, - icon="mdi:thermometer", device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), @@ -40,14 +39,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="voltage", name="Voltage", unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon="mdi:flash", device_class=DEVICE_CLASS_VOLTAGE, ), SensorEntityDescription( key="amps", name="Amps", unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - icon="mdi:flash", device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, ), @@ -55,7 +52,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="watts", name="Watts", unit_of_measurement=POWER_WATT, - icon="mdi:flash", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), @@ -69,7 +65,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="energy_added", name="Energy added", unit_of_measurement=ENERGY_WATT_HOUR, - icon="mdi:flash", device_class=DEVICE_CLASS_ENERGY, ), ) @@ -117,22 +112,4 @@ class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): @property def state(self): """Return the state.""" - state = None - sensor_type = self.entity_description.key - if sensor_type == "status": - state = self.device.status - elif sensor_type == "temperature": - state = self.device.temperature - elif sensor_type == "voltage": - state = self.device.voltage - elif sensor_type == "amps": - state = self.device.amps - elif sensor_type == "watts": - state = self.device.watts - elif sensor_type == "charge_time": - state = self.device.charge_time - elif sensor_type == "energy_added": - state = self.device.energy_added - else: - state = "Unknown" - return state + return getattr(self.device, self.entity_description.key, None) From 5de1adacf79036938c90863e81e3fbb3363b55f9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 10 Aug 2021 14:55:11 +0200 Subject: [PATCH 295/903] Xiaomi miio add coordinator to fan platform (#54366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init coordinator for airpurifiers and airfresh * Update fan entities with coordinator * cache mode and fan_level at user update * pylint define attributes in _init * Update homeassistant/components/xiaomi_miio/fan.py Co-authored-by: Maciej Bieniek * Update homeassistant/components/xiaomi_miio/fan.py Co-authored-by: Maciej Bieniek * Update homeassistant/components/xiaomi_miio/fan.py Co-authored-by: Maciej Bieniek * cleanup code * Set hass.data[DATA_KEY] to enable * rename to filtered_entities in service handler * Update homeassistant/components/xiaomi_miio/fan.py Co-authored-by: Joakim Sørensen * flake Co-authored-by: Maciej Bieniek Co-authored-by: Joakim Sørensen --- .../components/xiaomi_miio/__init__.py | 58 +++- homeassistant/components/xiaomi_miio/fan.py | 316 +++++++----------- 2 files changed, 176 insertions(+), 198 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index bd9e69bd12d..faff2194948 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -3,7 +3,15 @@ from datetime import timedelta import logging import async_timeout -from miio import AirHumidifier, AirHumidifierMiot, AirHumidifierMjjsq, DeviceException +from miio import ( + AirFresh, + AirHumidifier, + AirHumidifierMiot, + AirHumidifierMjjsq, + AirPurifier, + AirPurifierMiot, + DeviceException, +) from miio.gateway.gateway import GatewayException from homeassistant import config_entries, core @@ -23,10 +31,13 @@ from .const import ( KEY_DEVICE, MODELS_AIR_MONITOR, MODELS_FAN, + MODELS_FAN_MIIO, MODELS_HUMIDIFIER, + MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, MODELS_LIGHT, + MODELS_PURIFIER_MIOT, MODELS_SWITCH, MODELS_VACUUM, ) @@ -107,27 +118,52 @@ async def async_create_miio_device_and_coordinator( token = entry.data[CONF_TOKEN] name = entry.title device = None + migrate = False - if model not in MODELS_HUMIDIFIER: + if ( + model not in MODELS_HUMIDIFIER + and model not in MODELS_PURIFIER_MIOT + and model not in MODELS_FAN_MIIO + ): return _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + # Humidifiers if model in MODELS_HUMIDIFIER_MIOT: device = AirHumidifierMiot(host, token) + migrate = True elif model in MODELS_HUMIDIFIER_MJJSQ: device = AirHumidifierMjjsq(host, token, model=model) - else: + migrate = True + elif model in MODELS_HUMIDIFIER_MIIO: device = AirHumidifier(host, token, model=model) + migrate = True + # Airpurifiers and Airfresh + elif model in MODELS_PURIFIER_MIOT: + device = AirPurifierMiot(host, token) + elif model.startswith("zhimi.airpurifier."): + device = AirPurifier(host, token) + elif model.startswith("zhimi.airfresh."): + device = AirFresh(host, token) + else: + _LOGGER.error( + "Unsupported device found! Please create an issue at " + "https://github.com/syssi/xiaomi_airpurifier/issues " + "and provide the following data: %s", + model, + ) + return - # Removing fan platform entity for humidifiers and migrate the name to the config entry for migration - entity_registry = er.async_get(hass) - entity_id = entity_registry.async_get_entity_id("fan", DOMAIN, entry.unique_id) - if entity_id: - # This check is entities that have a platform migration only and should be removed in the future - if migrate_entity_name := entity_registry.async_get(entity_id).name: - hass.config_entries.async_update_entry(entry, title=migrate_entity_name) - entity_registry.async_remove(entity_id) + if migrate: + # Removing fan platform entity for humidifiers and migrate the name to the config entry for migration + entity_registry = er.async_get(hass) + entity_id = entity_registry.async_get_entity_id("fan", DOMAIN, entry.unique_id) + if entity_id: + # This check is entities that have a platform migration only and should be removed in the future + if migrate_entity_name := entity_registry.async_get(entity_id).name: + hass.config_entries.async_update_entry(entry, title=migrate_entity_name) + entity_registry.async_remove(entity_id) async def async_update_data(): """Fetch data from the device using async_add_executor_job.""" diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index feeadf2bccc..fe4df2cd6d3 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,11 +1,9 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" import asyncio from enum import Enum -from functools import partial import logging import math -from miio import AirFresh, AirPurifier, AirPurifierMiot, DeviceException from miio.airfresh import ( LedBrightness as AirfreshLedBrightness, OperationMode as AirfreshOperationMode, @@ -35,6 +33,7 @@ from homeassistant.const import ( CONF_NAME, CONF_TOKEN, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -56,6 +55,8 @@ from .const import ( FEATURE_SET_LED, FEATURE_SET_LED_BRIGHTNESS, FEATURE_SET_VOLUME, + KEY_COORDINATOR, + KEY_DEVICE, MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_PRO, @@ -79,9 +80,8 @@ from .const import ( SERVICE_SET_LEARN_MODE_ON, SERVICE_SET_LED_BRIGHTNESS, SERVICE_SET_VOLUME, - SUCCESS, ) -from .device import XiaomiMiioEntity +from .device import XiaomiCoordinatedMiioEntity _LOGGER = logging.getLogger(__name__) @@ -430,94 +430,89 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Fan from a config entry.""" entities = [] - if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: - if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = {} + if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + return - host = config_entry.data[CONF_HOST] - token = config_entry.data[CONF_TOKEN] - name = config_entry.title - model = config_entry.data[CONF_MODEL] - unique_id = config_entry.unique_id + hass.data.setdefault(DATA_KEY, {}) - _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + name = config_entry.title + model = config_entry.data[CONF_MODEL] + unique_id = config_entry.unique_id + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - if model in MODELS_PURIFIER_MIOT: - air_purifier = AirPurifierMiot(host, token) - entity = XiaomiAirPurifierMiot( - name, air_purifier, config_entry, unique_id, allowed_failures=2 - ) - elif model.startswith("zhimi.airpurifier."): - air_purifier = AirPurifier(host, token) - entity = XiaomiAirPurifier(name, air_purifier, config_entry, unique_id) - elif model.startswith("zhimi.airfresh."): - air_fresh = AirFresh(host, token) - entity = XiaomiAirFresh(name, air_fresh, config_entry, unique_id) + if model in MODELS_PURIFIER_MIOT: + entity = XiaomiAirPurifierMiot( + name, + device, + config_entry, + unique_id, + coordinator, + ) + elif model.startswith("zhimi.airpurifier."): + entity = XiaomiAirPurifier(name, device, config_entry, unique_id, coordinator) + elif model.startswith("zhimi.airfresh."): + entity = XiaomiAirFresh(name, device, config_entry, unique_id, coordinator) + else: + return + + hass.data[DATA_KEY][unique_id] = entity + + entities.append(entity) + + async def async_service_handler(service): + """Map services to methods on XiaomiAirPurifier.""" + method = SERVICE_TO_METHOD[service.service] + params = { + key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID + } + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + filtered_entities = [ + entity + for entity in hass.data[DATA_KEY].values() + if entity.entity_id in entity_ids + ] else: - _LOGGER.error( - "Unsupported device found! Please create an issue at " - "https://github.com/syssi/xiaomi_airpurifier/issues " - "and provide the following data: %s", - model, - ) - return + filtered_entities = hass.data[DATA_KEY].values() - hass.data[DATA_KEY][host] = entity - entities.append(entity) + update_tasks = [] - async def async_service_handler(service): - """Map services to methods on XiaomiAirPurifier.""" - method = SERVICE_TO_METHOD[service.service] - params = { - key: value - for key, value in service.data.items() - if key != ATTR_ENTITY_ID - } - entity_ids = service.data.get(ATTR_ENTITY_ID) - if entity_ids: - entities = [ - entity - for entity in hass.data[DATA_KEY].values() - if entity.entity_id in entity_ids - ] - else: - entities = hass.data[DATA_KEY].values() - - update_tasks = [] - - for entity in entities: - entity_method = getattr(entity, method["method"], None) - if not entity_method: - continue - await entity_method(**params) - update_tasks.append( - hass.async_create_task(entity.async_update_ha_state(True)) - ) - - if update_tasks: - await asyncio.wait(update_tasks) - - for air_purifier_service, method in SERVICE_TO_METHOD.items(): - schema = method.get("schema", AIRPURIFIER_SERVICE_SCHEMA) - hass.services.async_register( - DOMAIN, air_purifier_service, async_service_handler, schema=schema + for entity in filtered_entities: + entity_method = getattr(entity, method["method"], None) + if not entity_method: + continue + await entity_method(**params) + update_tasks.append( + hass.async_create_task(entity.async_update_ha_state(True)) ) - async_add_entities(entities, update_before_add=True) + if update_tasks: + await asyncio.wait(update_tasks) + + for air_purifier_service, method in SERVICE_TO_METHOD.items(): + schema = method.get("schema", AIRPURIFIER_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, air_purifier_service, async_service_handler, schema=schema + ) + + async_add_entities(entities) -class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): +class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Representation of a generic Xiaomi device.""" - def __init__(self, name, device, entry, unique_id): + def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the generic Xiaomi device.""" - super().__init__(name, device, entry, unique_id) + super().__init__(name, device, entry, unique_id, coordinator) self._available = False + self._available_attributes = {} self._state = None + self._mode = None + self._fan_level = None self._state_attrs = {ATTR_MODEL: self._model} self._device_features = FEATURE_SET_CHILD_LOCK - self._skip_update = False self._supported_features = 0 self._speed_count = 100 self._preset_modes = [] @@ -583,22 +578,20 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): return value - async def _try_command(self, mask_error, func, *args, **kwargs): - """Call a miio device command handling error messages.""" - try: - result = await self.hass.async_add_executor_job( - partial(func, *args, **kwargs) - ) - - _LOGGER.debug("Response received from miio device: %s", result) - - return result == SUCCESS - except DeviceException as exc: - if self._available: - _LOGGER.error(mask_error, exc) - self._available = False - - return False + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._available = True + self._state = self.coordinator.data.is_on + self._state_attrs.update( + { + key: self._extract_value_from_attribute(self.coordinator.data, value) + for key, value in self._available_attributes.items() + } + ) + self._mode = self._state_attrs.get(ATTR_MODE) + self._fan_level = self._state_attrs.get(ATTR_FAN_LEVEL) + self.async_write_ha_state() # # The fan entity model has changed to use percentages and preset_modes @@ -630,7 +623,7 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): if result: self._state = True - self._skip_update = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs) -> None: """Turn the device off.""" @@ -640,7 +633,7 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): if result: self._state = False - self._skip_update = True + self.async_write_ha_state() async def async_set_buzzer_on(self): """Turn the buzzer on.""" @@ -706,11 +699,9 @@ class XiaomiAirPurifier(XiaomiGenericDevice): REVERSE_SPEED_MODE_MAPPING = {v: k for k, v in SPEED_MODE_MAPPING.items()} - def __init__(self, name, device, entry, unique_id, allowed_failures=0): + def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the plug switch.""" - super().__init__(name, device, entry, unique_id) - self._allowed_failures = allowed_failures - self._failure = 0 + super().__init__(name, device, entry, unique_id, coordinator) if self._model == MODEL_AIRPURIFIER_PRO: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO @@ -774,45 +765,8 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._state_attrs.update( {attribute: None for attribute in self._available_attributes} ) - - async def async_update(self): - """Fetch state from the device.""" - # On state change the device doesn't provide the new state immediately. - if self._skip_update: - self._skip_update = False - return - - try: - state = await self.hass.async_add_executor_job(self._device.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._state_attrs.update( - { - key: self._extract_value_from_attribute(state, value) - for key, value in self._available_attributes.items() - } - ) - - self._failure = 0 - - except DeviceException as ex: - self._failure += 1 - if self._failure < self._allowed_failures: - _LOGGER.info( - "Got exception while fetching the state: %s, failure: %d", - ex, - self._failure, - ) - else: - if self._available: - self._available = False - _LOGGER.error( - "Got exception while fetching the state: %s, failure: %d", - ex, - self._failure, - ) + self._mode = self._state_attrs.get(ATTR_MODE) + self._fan_level = self._state_attrs.get(ATTR_FAN_LEVEL) @property def preset_mode(self): @@ -1032,8 +986,7 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): def percentage(self): """Return the current percentage based speed.""" if self._state: - fan_level = self._state_attrs[ATTR_FAN_LEVEL] - return ranged_value_to_percentage((1, 3), fan_level) + return ranged_value_to_percentage((1, 3), self._fan_level) return None @@ -1041,9 +994,7 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): def preset_mode(self): """Get the active preset mode.""" if self._state: - preset_mode = AirpurifierMiotOperationMode( - self._state_attrs[ATTR_MODE] - ).name + preset_mode = AirpurifierMiotOperationMode(self._mode).name return preset_mode if preset_mode in self._preset_modes else None return None @@ -1053,7 +1004,7 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): def speed(self): """Return the current speed.""" if self._state: - return AirpurifierMiotOperationMode(self._state_attrs[ATTR_MODE]).name + return AirpurifierMiotOperationMode(self._mode).name return None @@ -1063,12 +1014,15 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): This method is a coroutine. """ fan_level = math.ceil(percentage_to_ranged_value((1, 3), percentage)) - if fan_level: - await self._try_command( - "Setting fan level of the miio device failed.", - self._device.set_fan_level, - fan_level, - ) + if not fan_level: + return + if await self._try_command( + "Setting fan level of the miio device failed.", + self._device.set_fan_level, + fan_level, + ): + self._fan_level = fan_level + self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan. @@ -1078,11 +1032,13 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): if preset_mode not in self.preset_modes: _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) return - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, self.PRESET_MODE_MAPPING[preset_mode], - ) + ): + self._mode = self.PRESET_MODE_MAPPING[preset_mode].value + self.async_write_ha_state() # the async_set_speed function is deprecated, support will end with release 2021.7 # it is added here only for compatibility with legacy speeds @@ -1093,11 +1049,13 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): _LOGGER.debug("Setting the operation mode to: %s", speed) - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, AirpurifierMiotOperationMode[speed.title()], - ) + ): + self._mode = AirpurifierMiotOperationMode[speed.title()].value + self.async_write_ha_state() async def async_set_led_brightness(self, brightness: int = 2): """Set the led brightness.""" @@ -1128,9 +1086,9 @@ class XiaomiAirFresh(XiaomiGenericDevice): "Interval": AirfreshOperationMode.Interval, } - def __init__(self, name, device, entry, unique_id): + def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the miio device.""" - super().__init__(name, device, entry, unique_id) + super().__init__(name, device, entry, unique_id, coordinator) self._device_features = FEATURE_FLAGS_AIRFRESH self._available_attributes = AVAILABLE_ATTRIBUTES_AIRFRESH @@ -1142,37 +1100,13 @@ class XiaomiAirFresh(XiaomiGenericDevice): self._state_attrs.update( {attribute: None for attribute in self._available_attributes} ) - - async def async_update(self): - """Fetch state from the device.""" - # On state change the device doesn't provide the new state immediately. - if self._skip_update: - self._skip_update = False - return - - try: - state = await self.hass.async_add_executor_job(self._device.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._state_attrs.update( - { - key: self._extract_value_from_attribute(state, value) - for key, value in self._available_attributes.items() - } - ) - - except DeviceException as ex: - if self._available: - self._available = False - _LOGGER.error("Got exception while fetching the state: %s", ex) + self._mode = self._state_attrs.get(ATTR_MODE) @property def preset_mode(self): """Get the active preset mode.""" if self._state: - preset_mode = AirfreshOperationMode(self._state_attrs[ATTR_MODE]).name + preset_mode = AirfreshOperationMode(self._mode).name return preset_mode if preset_mode in self._preset_modes else None return None @@ -1181,7 +1115,7 @@ class XiaomiAirFresh(XiaomiGenericDevice): def percentage(self): """Return the current percentage based speed.""" if self._state: - mode = AirfreshOperationMode(self._state_attrs[ATTR_MODE]) + mode = AirfreshOperationMode(self._mode) if mode in self.REVERSE_SPEED_MODE_MAPPING: return ranged_value_to_percentage( (1, self._speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] @@ -1194,7 +1128,7 @@ class XiaomiAirFresh(XiaomiGenericDevice): def speed(self): """Return the current speed.""" if self._state: - return AirfreshOperationMode(self._state_attrs[ATTR_MODE]).name + return AirfreshOperationMode(self._mode).name return None @@ -1207,11 +1141,15 @@ class XiaomiAirFresh(XiaomiGenericDevice): percentage_to_ranged_value((1, self._speed_count), percentage) ) if speed_mode: - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, AirfreshOperationMode(self.SPEED_MODE_MAPPING[speed_mode]), - ) + ): + self._mode = AirfreshOperationMode( + self.SPEED_MODE_MAPPING[speed_mode] + ).value + self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan. @@ -1221,11 +1159,13 @@ class XiaomiAirFresh(XiaomiGenericDevice): if preset_mode not in self.preset_modes: _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) return - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, self.PRESET_MODE_MAPPING[preset_mode], - ) + ): + self._mode = self.PRESET_MODE_MAPPING[preset_mode].value + self.async_write_ha_state() # the async_set_speed function is deprecated, support will end with release 2021.7 # it is added here only for compatibility with legacy speeds @@ -1236,11 +1176,13 @@ class XiaomiAirFresh(XiaomiGenericDevice): _LOGGER.debug("Setting the operation mode to: %s", speed) - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, AirfreshOperationMode[speed.title()], - ) + ): + self._mode = AirfreshOperationMode[speed.title()].value + self.async_write_ha_state() async def async_set_led_on(self): """Turn the led on.""" From 1d40a6e40717943420e545f1a8f639f573bd322d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 10 Aug 2021 14:57:57 +0200 Subject: [PATCH 296/903] Activate mypy from amcrest and make the needed changes (#54392) --- homeassistant/components/amcrest/binary_sensor.py | 4 ++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index 0add382b81f..98e0be73ef4 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -53,7 +53,7 @@ _CROSSLINE_DETECTED_PARAMS = ( DEVICE_CLASS_MOTION, "CrossLineDetection", ) -BINARY_SENSORS = { +RAW_BINARY_SENSORS = { BINARY_SENSOR_AUDIO_DETECTED: _AUDIO_DETECTED_PARAMS, BINARY_SENSOR_AUDIO_DETECTED_POLLED: _AUDIO_DETECTED_PARAMS, BINARY_SENSOR_MOTION_DETECTED: _MOTION_DETECTED_PARAMS, @@ -64,7 +64,7 @@ BINARY_SENSORS = { } BINARY_SENSORS = { k: dict(zip((SENSOR_NAME, SENSOR_DEVICE_CLASS, SENSOR_EVENT_CODE), v)) - for k, v in BINARY_SENSORS.items() + for k, v in RAW_BINARY_SENSORS.items() } _EXCLUSIVE_OPTIONS = [ {BINARY_SENSOR_MOTION_DETECTED, BINARY_SENSOR_MOTION_DETECTED_POLLED}, diff --git a/mypy.ini b/mypy.ini index a97ba87f16b..6fffe2bc3c1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1274,9 +1274,6 @@ ignore_errors = true [mypy-homeassistant.components.almond.*] ignore_errors = true -[mypy-homeassistant.components.amcrest.*] -ignore_errors = true - [mypy-homeassistant.components.analytics.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index e100ffcea52..9747a5ee8c0 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -17,7 +17,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.adguard.*", "homeassistant.components.aemet.*", "homeassistant.components.almond.*", - "homeassistant.components.amcrest.*", "homeassistant.components.analytics.*", "homeassistant.components.asuswrt.*", "homeassistant.components.atag.*", From 8ea5a0dbc194d5a7e7f09f7df2eee5c5871e434c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 10 Aug 2021 15:03:12 +0200 Subject: [PATCH 297/903] Remove useless check in launch_library (#54393) --- .../components/launch_library/sensor.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 1d2f8ef0577..68d2a024bca 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -57,17 +57,13 @@ class LaunchLibrarySensor(SensorEntity): _LOGGER.error("Error getting data, %s", exception) self._attr_available = False else: - if launches and ( - next_launch := next((launch for launch in launches), None) - ): + if next_launch := next((launch for launch in launches), None): self._attr_available = True self._attr_state = next_launch.name - self._attr_extra_state_attributes.update( - { - ATTR_LAUNCH_TIME: next_launch.net, - ATTR_AGENCY: next_launch.launch_service_provider.name, - ATTR_AGENCY_COUNTRY_CODE: next_launch.pad.location.country_code, - ATTR_STREAM: next_launch.webcast_live, - ATTR_ATTRIBUTION: ATTRIBUTION, - } - ) + self._attr_extra_state_attributes = { + ATTR_LAUNCH_TIME: next_launch.net, + ATTR_AGENCY: next_launch.launch_service_provider.name, + ATTR_AGENCY_COUNTRY_CODE: next_launch.pad.location.country_code, + ATTR_STREAM: next_launch.webcast_live, + ATTR_ATTRIBUTION: ATTRIBUTION, + } From cf8f27bb44420f4cf45054998872ffd0265bcb61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 10 Aug 2021 15:03:34 +0200 Subject: [PATCH 298/903] Adjust version tests (#54391) * Adjust version tests * patch local import --- tests/components/version/test_sensor.py | 60 +++++++++++++++++++++---- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index 1f64fe23039..c8883e72389 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -1,31 +1,49 @@ """The test for the version sensor platform.""" +from datetime import timedelta from unittest.mock import patch from pyhaversion import HaVersionSource, exceptions as pyhaversionexceptions import pytest -from homeassistant.components.version.sensor import ALL_SOURCES +from homeassistant.components.version.sensor import HA_VERSION_SOURCES from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from tests.common import async_fire_time_changed MOCK_VERSION = "10.0" @pytest.mark.parametrize( - "source", - ALL_SOURCES, + "source,target_source,name", + ( + ( + ("local", HaVersionSource.LOCAL, "current_version"), + ("docker", HaVersionSource.CONTAINER, "latest_version"), + ("hassio", HaVersionSource.SUPERVISOR, "latest_version"), + ) + + tuple( + (source, HaVersionSource(source), "latest_version") + for source in HA_VERSION_SOURCES + if source != HaVersionSource.LOCAL + ) + ), ) -async def test_version_source(hass, source): +async def test_version_source(hass, source, target_source, name): """Test the Version sensor with different sources.""" config = { "sensor": {"platform": "version", "source": source, "image": "qemux86-64"} } - with patch("pyhaversion.version.HaVersion.version", MOCK_VERSION): + with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( + "homeassistant.components.version.sensor.HaVersion.version", MOCK_VERSION + ): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() - name = "current_version" if source == HaVersionSource.LOCAL else "latest_version" state = hass.states.get(f"sensor.{name}") + assert state + assert state.attributes["source"] == target_source assert state.state == MOCK_VERSION @@ -34,7 +52,7 @@ async def test_version_fetch_exception(hass, caplog): """Test fetch exception thrown during updates.""" config = {"sensor": {"platform": "version"}} with patch( - "pyhaversion.version.HaVersion.get_version", + "homeassistant.components.version.sensor.HaVersion.get_version", side_effect=pyhaversionexceptions.HaVersionFetchException( "Fetch exception from pyhaversion" ), @@ -48,9 +66,35 @@ async def test_version_parse_exception(hass, caplog): """Test parse exception thrown during updates.""" config = {"sensor": {"platform": "version"}} with patch( - "pyhaversion.version.HaVersion.get_version", + "homeassistant.components.version.sensor.HaVersion.get_version", side_effect=pyhaversionexceptions.HaVersionParseException, ): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() assert "Could not parse data received for HaVersionSource.LOCAL" in caplog.text + + +async def test_update(hass): + """Test updates.""" + config = {"sensor": {"platform": "version"}} + + with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( + "homeassistant.components.version.sensor.HaVersion.version", MOCK_VERSION + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.current_version") + assert state + assert state.state == MOCK_VERSION + + with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( + "homeassistant.components.version.sensor.HaVersion.version", "1234" + ): + + async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.current_version") + assert state + assert state.state == "1234" From f03b160c4637507691480de717b3f2d78e6ab844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 10 Aug 2021 16:28:39 +0200 Subject: [PATCH 299/903] Mill cleanup (#54396) --- homeassistant/components/mill/sensor.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 8b68d0ebe38..bdd1a90fb38 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -5,7 +5,7 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, SensorEntity, ) -from homeassistant.const import ENERGY_KILO_WATT_HOUR, STATE_UNKNOWN +from homeassistant.const import ENERGY_KILO_WATT_HOUR from homeassistant.util import dt as dt_util from .const import CONSUMPTION_TODAY, CONSUMPTION_YEAR, DOMAIN, MANUFACTURER @@ -16,13 +16,12 @@ async def async_setup_entry(hass, entry, async_add_entities): mill_data_connection = hass.data[DOMAIN] - dev = [] - for heater in mill_data_connection.heaters.values(): - for sensor_type in (CONSUMPTION_TODAY, CONSUMPTION_YEAR): - dev.append( - MillHeaterEnergySensor(heater, mill_data_connection, sensor_type) - ) - async_add_entities(dev) + entities = [ + MillHeaterEnergySensor(heater, mill_data_connection, sensor_type) + for sensor_type in (CONSUMPTION_TODAY, CONSUMPTION_YEAR) + for heater in mill_data_connection.heaters.values() + ] + async_add_entities(entities) class MillHeaterEnergySensor(SensorEntity): @@ -71,7 +70,7 @@ class MillHeaterEnergySensor(SensorEntity): self._attr_state = _state return - if self.state not in [STATE_UNKNOWN, None] and _state < self.state: + if self.state is not None and _state < self.state: if self._sensor_type == CONSUMPTION_TODAY: self._attr_last_reset = dt_util.as_utc( dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) From d1ea38e8f0018a727c2a4da86c2f22faeb0a14d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 10 Aug 2021 16:29:51 +0200 Subject: [PATCH 300/903] Add 100% test coverage for Uptime Robot (#54314) * Add 100% test coverage for Uptime Robot * Update tests/components/uptimerobot/test_binary_sensor.py Co-authored-by: Martin Hjelmare * Add more typehints Co-authored-by: Martin Hjelmare --- .coveragerc | 4 - .../components/uptimerobot/binary_sensor.py | 2 +- .../components/uptimerobot/entity.py | 14 +- tests/components/uptimerobot/common.py | 95 ++++++++ .../uptimerobot/test_binary_sensor.py | 82 +++++++ .../uptimerobot/test_config_flow.py | 218 ++++++------------ tests/components/uptimerobot/test_init.py | 157 +++++++++---- 7 files changed, 381 insertions(+), 191 deletions(-) create mode 100644 tests/components/uptimerobot/common.py create mode 100644 tests/components/uptimerobot/test_binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 5d9c5e9c5c8..8088bbece78 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1115,10 +1115,6 @@ omit = homeassistant/components/upcloud/switch.py homeassistant/components/upnp/* homeassistant/components/upc_connect/* - homeassistant/components/uptimerobot/__init__.py - homeassistant/components/uptimerobot/binary_sensor.py - homeassistant/components/uptimerobot/const.py - homeassistant/components/uptimerobot/entity.py homeassistant/components/uscis/sensor.py homeassistant/components/vallox/* homeassistant/components/vasttrafik/sensor.py diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index f99689f2507..ac0dc0c1186 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -53,7 +53,7 @@ async def async_setup_entry( name=monitor.friendly_name, device_class=DEVICE_CLASS_CONNECTIVITY, ), - target=monitor.url, + monitor=monitor, ) for monitor in coordinator.data ], diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index b9783c88b9c..8ef60b3848b 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -20,11 +20,12 @@ class UptimeRobotEntity(CoordinatorEntity): self, coordinator: DataUpdateCoordinator, description: EntityDescription, - target: str, + monitor: UptimeRobotMonitor, ) -> None: """Initialize Uptime Robot entities.""" super().__init__(coordinator) self.entity_description = description + self._monitor = monitor self._attr_device_info = { "identifiers": {(DOMAIN, str(self.monitor.id))}, "name": "Uptime Robot", @@ -34,7 +35,7 @@ class UptimeRobotEntity(CoordinatorEntity): } self._attr_extra_state_attributes = { ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_TARGET: target, + ATTR_TARGET: self.monitor.url, } self._attr_unique_id = str(self.monitor.id) @@ -47,9 +48,12 @@ class UptimeRobotEntity(CoordinatorEntity): def monitor(self) -> UptimeRobotMonitor: """Return the monitor for this entity.""" return next( - monitor - for monitor in self._monitors - if str(monitor.id) == self.entity_description.key + ( + monitor + for monitor in self._monitors + if str(monitor.id) == self.entity_description.key + ), + self._monitor, ) @property diff --git a/tests/components/uptimerobot/common.py b/tests/components/uptimerobot/common.py new file mode 100644 index 00000000000..aa241ce5a92 --- /dev/null +++ b/tests/components/uptimerobot/common.py @@ -0,0 +1,95 @@ +"""Common constants and functions for Uptime Robot tests.""" +from __future__ import annotations + +from enum import Enum +from typing import Any +from unittest.mock import patch + +from pyuptimerobot import ( + APIStatus, + UptimeRobotAccount, + UptimeRobotApiError, + UptimeRobotApiResponse, + UptimeRobotMonitor, +) + +from homeassistant import config_entries +from homeassistant.components.uptimerobot.const import DOMAIN +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_UPTIMEROBOT_API_KEY = "1234" +MOCK_UPTIMEROBOT_UNIQUE_ID = "1234567890" + +MOCK_UPTIMEROBOT_ACCOUNT = {"email": "test@test.test", "user_id": 1234567890} +MOCK_UPTIMEROBOT_ERROR = {"message": "test error from API."} +MOCK_UPTIMEROBOT_MONITOR = { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", +} + +MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA = { + "domain": DOMAIN, + "title": "test@test.test", + "data": {"platform": DOMAIN, "api_key": MOCK_UPTIMEROBOT_API_KEY}, + "unique_id": MOCK_UPTIMEROBOT_UNIQUE_ID, + "source": config_entries.SOURCE_USER, +} + +UPTIMEROBOT_TEST_ENTITY = "binary_sensor.test_monitor" + + +class MockApiResponseKey(str, Enum): + """Mock API response key.""" + + ACCOUNT = "account" + ERROR = "error" + MONITORS = "monitors" + + +def mock_uptimerobot_api_response( + data: dict[str, Any] + | None + | list[UptimeRobotMonitor] + | UptimeRobotAccount + | UptimeRobotApiError = None, + status: APIStatus = APIStatus.OK, + key: MockApiResponseKey = MockApiResponseKey.MONITORS, +) -> UptimeRobotApiResponse: + """Mock API response for Uptime Robot.""" + return UptimeRobotApiResponse.from_dict( + { + "stat": {"error": APIStatus.FAIL}.get(key, status), + key: data + if data is not None + else { + "account": MOCK_UPTIMEROBOT_ACCOUNT, + "error": MOCK_UPTIMEROBOT_ERROR, + "monitors": [MOCK_UPTIMEROBOT_MONITOR], + }.get(key, {}), + } + ) + + +async def setup_uptimerobot_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Uptime Robot integration.""" + mock_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) + mock_entry.add_to_hass(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(data=[MOCK_UPTIMEROBOT_MONITOR]), + ): + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert mock_entry.state == config_entries.ConfigEntryState.LOADED + + return mock_entry diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py new file mode 100644 index 00000000000..13bb3b342e9 --- /dev/null +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -0,0 +1,82 @@ +"""Test Uptime Robot binary_sensor.""" + +from unittest.mock import patch + +from pyuptimerobot import UptimeRobotAuthenticationException + +from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY +from homeassistant.components.uptimerobot.const import ( + ATTRIBUTION, + COORDINATOR_UPDATE_INTERVAL, + DOMAIN, +) +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from .common import ( + MOCK_UPTIMEROBOT_API_KEY, + MOCK_UPTIMEROBOT_MONITOR, + UPTIMEROBOT_TEST_ENTITY, + MockApiResponseKey, + mock_uptimerobot_api_response, + setup_uptimerobot_integration, +) + +from tests.common import async_fire_time_changed + + +async def test_config_import(hass: HomeAssistant) -> None: + """Test importing YAML configuration.""" + config = { + "binary_sensor": { + "platform": DOMAIN, + "api_key": MOCK_UPTIMEROBOT_API_KEY, + } + } + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(), + ): + assert await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + + assert len(config_entries) == 1 + config_entry = config_entries[0] + assert config_entry.source == "import" + + +async def test_presentation(hass: HomeAssistant) -> None: + """Test the presenstation of Uptime Robot binary_sensors.""" + await setup_uptimerobot_integration(hass) + + entity = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + + assert entity.state == STATE_ON + assert entity.attributes["device_class"] == DEVICE_CLASS_CONNECTIVITY + assert entity.attributes["attribution"] == ATTRIBUTION + assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] + + +async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: + """Test entity unaviable on update failure.""" + await setup_uptimerobot_integration(hass) + + entity = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + assert entity.state == STATE_ON + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotAuthenticationException, + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + entity = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + assert entity.state == STATE_UNAVAILABLE diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 967e1b499f5..966483970d0 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -1,15 +1,13 @@ """Test the Uptime Robot config flow.""" from unittest.mock import patch +import pytest from pytest import LogCaptureFixture -from pyuptimerobot import UptimeRobotApiResponse -from pyuptimerobot.exceptions import ( - UptimeRobotAuthenticationException, - UptimeRobotException, -) +from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException from homeassistant import config_entries, setup from homeassistant.components.uptimerobot.const import DOMAIN +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -17,6 +15,15 @@ from homeassistant.data_entry_flow import ( RESULT_TYPE_FORM, ) +from .common import ( + MOCK_UPTIMEROBOT_ACCOUNT, + MOCK_UPTIMEROBOT_API_KEY, + MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, + MOCK_UPTIMEROBOT_UNIQUE_ID, + MockApiResponseKey, + mock_uptimerobot_api_response, +) + from tests.common import MockConfigEntry @@ -31,82 +38,49 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict( - { - "stat": "ok", - "account": {"email": "test@test.test", "user_id": 1234567890}, - } - ), + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"api_key": "1234"}, + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) await hass.async_block_till_done() - assert result2["result"].unique_id == "1234567890" + assert result2["result"].unique_id == MOCK_UPTIMEROBOT_UNIQUE_ID assert result2["type"] == RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "test@test.test" - assert result2["data"] == {"api_key": "1234"} + assert result2["title"] == MOCK_UPTIMEROBOT_ACCOUNT["email"] + assert result2["data"] == {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY} assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" +@pytest.mark.parametrize( + "exception,error_key", + [ + (Exception, "unknown"), + (UptimeRobotException, "cannot_connect"), + (UptimeRobotAuthenticationException, "invalid_api_key"), + ], +) +async def test_form_exception_thrown(hass: HomeAssistant, exception, error_key) -> None: + """Test that we handle exceptions.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - side_effect=UptimeRobotException, + side_effect=exception, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"api_key": "1234"}, + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) assert result2["type"] == RESULT_TYPE_FORM - assert result2["errors"]["base"] == "cannot_connect" - - -async def test_form_unexpected_error(hass: HomeAssistant) -> None: - """Test we handle unexpected error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"api_key": "1234"}, - ) - - assert result2["errors"]["base"] == "unknown" - - -async def test_form_api_key_error(hass: HomeAssistant) -> None: - """Test we handle unexpected error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", - side_effect=UptimeRobotAuthenticationException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"api_key": "1234"}, - ) - - assert result2["errors"]["base"] == "invalid_api_key" + assert result2["errors"]["base"] == error_key async def test_form_api_error(hass: HomeAssistant, caplog: LogCaptureFixture) -> None: @@ -117,32 +91,24 @@ async def test_form_api_error(hass: HomeAssistant, caplog: LogCaptureFixture) -> with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict( - { - "stat": "fail", - "error": {"message": "test error from API."}, - } - ), + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"api_key": "1234"}, + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) assert result2["errors"]["base"] == "unknown" assert "test error from API." in caplog.text -async def test_flow_import(hass): +async def test_flow_import( + hass: HomeAssistant, +) -> None: """Test an import flow.""" with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict( - { - "stat": "ok", - "account": {"email": "test@test.test", "user_id": 1234567890}, - } - ), + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, @@ -150,22 +116,17 @@ async def test_flow_import(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={"platform": DOMAIN, "api_key": "1234"}, + data={"platform": DOMAIN, CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["data"] == {"api_key": "1234"} + assert result["data"] == {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY} with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict( - { - "stat": "ok", - "account": {"email": "test@test.test", "user_id": 1234567890}, - } - ), + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, @@ -173,7 +134,7 @@ async def test_flow_import(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={"platform": DOMAIN, "api_key": "1234"}, + data={"platform": DOMAIN, CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) await hass.async_block_till_done() @@ -183,7 +144,9 @@ async def test_flow_import(hass): with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict({"stat": "ok"}), + return_value=mock_uptimerobot_api_response( + key=MockApiResponseKey.ACCOUNT, data={} + ), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, @@ -191,7 +154,7 @@ async def test_flow_import(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={"platform": DOMAIN, "api_key": "12345"}, + data={"platform": DOMAIN, CONF_API_KEY: "12345"}, ) await hass.async_block_till_done() @@ -199,13 +162,11 @@ async def test_flow_import(hass): assert result["reason"] == "unknown" -async def test_user_unique_id_already_exists(hass): +async def test_user_unique_id_already_exists( + hass: HomeAssistant, +) -> None: """Test creating an entry where the unique_id already exists.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={"platform": DOMAIN, "api_key": "1234"}, - unique_id="1234567890", - ) + entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -216,19 +177,14 @@ async def test_user_unique_id_already_exists(hass): with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict( - { - "stat": "ok", - "account": {"email": "test@test.test", "user_id": 1234567890}, - } - ), + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"api_key": "12345"}, + {CONF_API_KEY: "12345"}, ) await hass.async_block_till_done() @@ -237,13 +193,11 @@ async def test_user_unique_id_already_exists(hass): assert result2["reason"] == "already_configured" -async def test_reauthentication(hass): +async def test_reauthentication( + hass: HomeAssistant, +) -> None: """Test Uptime Robot reauthentication.""" - old_entry = MockConfigEntry( - domain=DOMAIN, - data={"platform": DOMAIN, "api_key": "1234"}, - unique_id="1234567890", - ) + old_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) old_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -262,12 +216,7 @@ async def test_reauthentication(hass): with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict( - { - "stat": "ok", - "account": {"email": "test@test.test", "user_id": 1234567890}, - } - ), + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, @@ -275,7 +224,7 @@ async def test_reauthentication(hass): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"api_key": "1234"}, + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) await hass.async_block_till_done() @@ -283,13 +232,11 @@ async def test_reauthentication(hass): assert result2["reason"] == "reauth_successful" -async def test_reauthentication_failure(hass): +async def test_reauthentication_failure( + hass: HomeAssistant, +) -> None: """Test Uptime Robot reauthentication failure.""" - old_entry = MockConfigEntry( - domain=DOMAIN, - data={"platform": DOMAIN, "api_key": "1234"}, - unique_id="1234567890", - ) + old_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) old_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -308,12 +255,7 @@ async def test_reauthentication_failure(hass): with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict( - { - "stat": "fail", - "error": {"message": "test error from API."}, - } - ), + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, @@ -321,7 +263,7 @@ async def test_reauthentication_failure(hass): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"api_key": "1234"}, + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) await hass.async_block_till_done() @@ -330,11 +272,12 @@ async def test_reauthentication_failure(hass): assert result2["errors"]["base"] == "unknown" -async def test_reauthentication_failure_no_existing_entry(hass): +async def test_reauthentication_failure_no_existing_entry( + hass: HomeAssistant, +) -> None: """Test Uptime Robot reauthentication with no existing entry.""" old_entry = MockConfigEntry( - domain=DOMAIN, - data={"platform": DOMAIN, "api_key": "1234"}, + **{**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, "unique_id": None} ) old_entry.add_to_hass(hass) @@ -354,12 +297,7 @@ async def test_reauthentication_failure_no_existing_entry(hass): with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict( - { - "stat": "ok", - "account": {"email": "test@test.test", "user_id": 1234567890}, - } - ), + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, @@ -367,7 +305,7 @@ async def test_reauthentication_failure_no_existing_entry(hass): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"api_key": "1234"}, + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) await hass.async_block_till_done() @@ -375,13 +313,11 @@ async def test_reauthentication_failure_no_existing_entry(hass): assert result2["reason"] == "reauth_failed_existing" -async def test_reauthentication_failure_account_not_matching(hass): +async def test_reauthentication_failure_account_not_matching( + hass: HomeAssistant, +) -> None: """Test Uptime Robot reauthentication failure when using another account.""" - old_entry = MockConfigEntry( - domain=DOMAIN, - data={"platform": DOMAIN, "api_key": "1234"}, - unique_id="1234567890", - ) + old_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) old_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -400,11 +336,9 @@ async def test_reauthentication_failure_account_not_matching(hass): with patch( "pyuptimerobot.UptimeRobot.async_get_account_details", - return_value=UptimeRobotApiResponse.from_dict( - { - "stat": "ok", - "account": {"email": "test@test.test", "user_id": 1234567891}, - } + return_value=mock_uptimerobot_api_response( + key=MockApiResponseKey.ACCOUNT, + data={**MOCK_UPTIMEROBOT_ACCOUNT, "user_id": 1234567891}, ), ), patch( "homeassistant.components.uptimerobot.async_setup_entry", @@ -413,7 +347,7 @@ async def test_reauthentication_failure_account_not_matching(hass): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"api_key": "1234"}, + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) await hass.async_block_till_done() diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index b4534af763a..756831e7615 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -1,16 +1,31 @@ """Test the Uptime Robot init.""" -import datetime from unittest.mock import patch from pytest import LogCaptureFixture -from pyuptimerobot import UptimeRobotApiResponse -from pyuptimerobot.exceptions import UptimeRobotAuthenticationException +from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException from homeassistant import config_entries -from homeassistant.components.uptimerobot.const import DOMAIN +from homeassistant.components.uptimerobot.const import ( + COORDINATOR_UPDATE_INTERVAL, + DOMAIN, +) +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + async_get_registry, +) from homeassistant.util import dt +from .common import ( + MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, + MOCK_UPTIMEROBOT_MONITOR, + UPTIMEROBOT_TEST_ENTITY, + MockApiResponseKey, + mock_uptimerobot_api_response, + setup_uptimerobot_integration, +) + from tests.common import MockConfigEntry, async_fire_time_changed @@ -18,13 +33,7 @@ async def test_reauthentication_trigger_in_setup( hass: HomeAssistant, caplog: LogCaptureFixture ): """Test reauthentication trigger.""" - mock_config_entry = MockConfigEntry( - domain=DOMAIN, - title="test@test.test", - data={"platform": DOMAIN, "api_key": "1234"}, - unique_id="1234567890", - source=config_entries.SOURCE_USER, - ) + mock_config_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) mock_config_entry.add_to_hass(hass) with patch( @@ -57,46 +66,23 @@ async def test_reauthentication_trigger_after_setup( hass: HomeAssistant, caplog: LogCaptureFixture ): """Test reauthentication trigger.""" - mock_config_entry = MockConfigEntry( - domain=DOMAIN, - title="test@test.test", - data={"platform": DOMAIN, "api_key": "1234"}, - unique_id="1234567890", - source=config_entries.SOURCE_USER, - ) - mock_config_entry.add_to_hass(hass) + mock_config_entry = await setup_uptimerobot_integration(hass) - with patch( - "pyuptimerobot.UptimeRobot.async_get_monitors", - return_value=UptimeRobotApiResponse.from_dict( - { - "stat": "ok", - "monitors": [ - {"id": 1234, "friendly_name": "Test monitor", "status": 2} - ], - } - ), - ): - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - binary_sensor = hass.states.get("binary_sensor.test_monitor") + binary_sensor = hass.states.get(UPTIMEROBOT_TEST_ENTITY) assert mock_config_entry.state == config_entries.ConfigEntryState.LOADED - assert binary_sensor.state == "on" + assert binary_sensor.state == STATE_ON with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - async_fire_time_changed(hass, dt.utcnow() + datetime.timedelta(seconds=10)) + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() - binary_sensor = hass.states.get("binary_sensor.test_monitor") + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_UNAVAILABLE - assert binary_sensor.state == "unavailable" assert "Authentication failed while fetching uptimerobot data" in caplog.text assert len(flows) == 1 @@ -105,3 +91,96 @@ async def test_reauthentication_trigger_after_setup( assert flow["handler"] == DOMAIN assert flow["context"]["source"] == config_entries.SOURCE_REAUTH assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + +async def test_integration_reload(hass: HomeAssistant): + """Test integration reload.""" + mock_entry = await setup_uptimerobot_integration(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(), + ): + assert await hass.config_entries.async_reload(mock_entry.entry_id) + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert entry.state == config_entries.ConfigEntryState.LOADED + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + + +async def test_update_errors(hass: HomeAssistant, caplog: LogCaptureFixture): + """Test errors during updates.""" + await setup_uptimerobot_integration(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotException, + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_UNAVAILABLE + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(), + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_UNAVAILABLE + + assert "Error fetching uptimerobot data: test error from API" in caplog.text + + +async def test_device_management(hass: HomeAssistant): + """Test that we are adding and removing devices for monitors returned from the API.""" + mock_entry = await setup_uptimerobot_integration(hass) + dev_reg = await async_get_registry(hass) + + devices = async_entries_for_config_entry(dev_reg, mock_entry.entry_id) + assert len(devices) == 1 + + assert devices[0].identifiers == {(DOMAIN, "1234")} + + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert hass.states.get(f"{UPTIMEROBOT_TEST_ENTITY}_2") is None + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[MOCK_UPTIMEROBOT_MONITOR, {**MOCK_UPTIMEROBOT_MONITOR, "id": 12345}] + ), + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + devices = async_entries_for_config_entry(dev_reg, mock_entry.entry_id) + assert len(devices) == 2 + assert devices[0].identifiers == {(DOMAIN, "1234")} + assert devices[1].identifiers == {(DOMAIN, "12345")} + + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert hass.states.get(f"{UPTIMEROBOT_TEST_ENTITY}_2").state == STATE_ON + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(), + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + devices = async_entries_for_config_entry(dev_reg, mock_entry.entry_id) + assert len(devices) == 1 + assert devices[0].identifiers == {(DOMAIN, "1234")} + + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert hass.states.get(f"{UPTIMEROBOT_TEST_ENTITY}_2") is None From 7e211965e4b789cf71951b6ff1958c43b18a0f29 Mon Sep 17 00:00:00 2001 From: Dror Eiger <45061021+deiger@users.noreply.github.com> Date: Tue, 10 Aug 2021 17:31:55 +0300 Subject: [PATCH 301/903] Update the Qubino Flush Shutter fixture (#54387) --- tests/components/zwave_js/test_cover.py | 2 +- .../zwave_js/cover_qubino_shutter_state.json | 961 ++++++++++-------- 2 files changed, 549 insertions(+), 414 deletions(-) diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index 70ce2337abf..1afe7a114da 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -23,7 +23,7 @@ from homeassistant.const import ( WINDOW_COVER_ENTITY = "cover.zws_12" GDC_COVER_ENTITY = "cover.aeon_labs_garage_door_controller_gen5" BLIND_COVER_ENTITY = "cover.window_blind_controller" -SHUTTER_COVER_ENTITY = "cover.flush_shutter_dc" +SHUTTER_COVER_ENTITY = "cover.flush_shutter" AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" diff --git a/tests/fixtures/zwave_js/cover_qubino_shutter_state.json b/tests/fixtures/zwave_js/cover_qubino_shutter_state.json index 65725606e1c..bde7c90e1e4 100644 --- a/tests/fixtures/zwave_js/cover_qubino_shutter_state.json +++ b/tests/fixtures/zwave_js/cover_qubino_shutter_state.json @@ -1,48 +1,104 @@ { - "nodeId": 5, + "nodeId": 20, "index": 0, "installerIcon": 6656, "userIcon": 6656, "status": 4, "ready": true, - "deviceClass": { - "basic": { "key": 4, "label": "Routing Slave" }, - "generic": { "key": 17, "label": "Routing Slave" }, - "specific": { "key": 7, "label": "Routing Slave" }, - "mandatorySupportedCCs": [], - "mandatoryControlCCs": [] - }, "isListening": true, - "isFrequentListening": false, "isRouting": true, - "maxBaudRate": 40000, "isSecure": false, - "version": 4, - "isBeaming": true, "manufacturerId": 345, - "productId": 83, + "productId": 82, "productType": 3, - "firmwareVersion": "7.2", + "firmwareVersion": "71.0", "zwavePlusVersion": 1, - "nodeType": 0, - "roleType": 5, "deviceConfig": { - "manufacturerId": 345, + "filename": "/data/db/devices/0x0159/zmnhcd_4.1.json", + "isEmbedded": true, "manufacturer": "Qubino", - "label": "ZMNHOD", - "description": "Flush Shutter DC", - "devices": [{ "productType": "0x0003", "productId": "0x0053" }], - "firmwareVersion": { "min": "0.0", "max": "255.255" }, - "paramInformation": { "_map": {} } + "manufacturerId": 345, + "label": "ZMNHCD", + "description": "Flush Shutter", + "devices": [ + { + "productType": 3, + "productId": 82 + } + ], + "firmwareVersion": { + "min": "4.1", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + } }, - "label": "ZMNHOD", - "neighbors": [1, 2], - "interviewAttempts": 1, + "label": "ZMNHCD", + "interviewAttempts": 0, "endpoints": [ - { "nodeId": 5, "index": 0, "installerIcon": 6656, "userIcon": 6656 } + { + "nodeId": 20, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [ + 32, + 38, + 37, + 114, + 134 + ], + "mandatoryControlledCCs": [] + } + } ], - "commandClasses": [], "values": [ + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value" + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ] + } + }, { "endpoint": 0, "commandClass": 38, @@ -54,10 +110,14 @@ "type": "number", "readable": true, "writeable": true, + "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, - "max": 99, - "label": "Target value" - } + "max": 99 + }, + "value": 99 }, { "endpoint": 0, @@ -84,11 +144,11 @@ "type": "number", "readable": true, "writeable": false, + "label": "Current value", "min": 0, - "max": 99, - "label": "Current value" + "max": 99 }, - "value": "unknown" + "value": 0 }, { "endpoint": 0, @@ -102,7 +162,9 @@ "readable": true, "writeable": true, "label": "Perform a level change (Up)", - "ccSpecific": { "switchType": 2 } + "ccSpecific": { + "switchType": 2 + } } }, { @@ -117,146 +179,9 @@ "readable": true, "writeable": true, "label": "Perform a level change (Down)", - "ccSpecific": { "switchType": 2 } - } - }, - { - "endpoint": 0, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value" - }, - "value": "unknown" - }, - { - "endpoint": 0, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value" - } - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "manufacturerId", - "propertyName": "manufacturerId", - "ccVersion": 2, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 65535, - "label": "Manufacturer ID" - }, - "value": 345 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productType", - "propertyName": "productType", - "ccVersion": 2, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 65535, - "label": "Product type" - }, - "value": 3 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productId", - "propertyName": "productId", - "ccVersion": 2, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 65535, - "label": "Product ID" - }, - "value": 83 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "libraryType", - "propertyName": "libraryType", - "ccVersion": 2, - "metadata": { - "type": "any", - "readable": true, - "writeable": false, - "label": "Library type" - }, - "value": 3 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "protocolVersion", - "propertyName": "protocolVersion", - "ccVersion": 2, - "metadata": { - "type": "any", - "readable": true, - "writeable": false, - "label": "Z-Wave protocol version" - }, - "value": "4.38" - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "firmwareVersions", - "propertyName": "firmwareVersions", - "ccVersion": 2, - "metadata": { - "type": "any", - "readable": true, - "writeable": false, - "label": "Z-Wave chip firmware versions" - }, - "value": ["7.2"] - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "hardwareVersion", - "propertyName": "hardwareVersion", - "ccVersion": 2, - "metadata": { - "type": "any", - "readable": true, - "writeable": false, - "label": "Z-Wave chip hardware version" + "ccSpecific": { + "switchType": 2 + } } }, { @@ -273,29 +198,14 @@ "readable": true, "writeable": false, "label": "Electric Consumed [kWh]", - "unit": "kWh", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 0 } + "ccSpecific": { + "meterType": 1, + "rateType": 1, + "scale": 0 + }, + "unit": "kWh" }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 50, - "commandClassName": "Meter", - "property": "deltaTime", - "propertyKey": 65537, - "propertyName": "deltaTime", - "propertyKeyName": "Electric_kWh_Consumed", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Electric Consumed [kWh] (prev. time delta)", - "unit": "s", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 0 } - }, - "value": 0 + "value": 7.9 }, { "endpoint": 0, @@ -311,27 +221,12 @@ "readable": true, "writeable": false, "label": "Electric Consumed [W]", - "unit": "W", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 2 } - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 50, - "commandClassName": "Meter", - "property": "deltaTime", - "propertyKey": 66049, - "propertyName": "deltaTime", - "propertyKeyName": "Electric_W_Consumed", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Electric Consumed [W] (prev. time delta)", - "unit": "s", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 2 } + "ccSpecific": { + "meterType": 1, + "rateType": 1, + "scale": 2 + }, + "unit": "W" }, "value": 0 }, @@ -349,119 +244,31 @@ "label": "Reset accumulated values" } }, - { - "endpoint": 0, - "commandClass": 50, - "commandClassName": "Meter", - "property": "previousValue", - "propertyKey": 65537, - "propertyName": "previousValue", - "propertyKeyName": "Electric_kWh_Consumed", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Electric Consumed [kWh] (prev. value)", - "unit": "kWh", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 0 } - } - }, - { - "endpoint": 0, - "commandClass": 50, - "commandClassName": "Meter", - "property": "previousValue", - "propertyKey": 66049, - "propertyName": "previousValue", - "propertyKeyName": "Electric_W_Consumed", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Electric Consumed [W] (prev. value)", - "unit": "W", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 2 } - } - }, - { - "endpoint": 0, - "commandClass": 113, - "commandClassName": "Notification", - "property": "alarmType", - "propertyName": "alarmType", - "ccVersion": 5, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 255, - "label": "Alarm Type" - } - }, - { - "endpoint": 0, - "commandClass": 113, - "commandClassName": "Notification", - "property": "alarmLevel", - "propertyName": "alarmLevel", - "ccVersion": 5, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 255, - "label": "Alarm Level" - } - }, - { - "endpoint": 0, - "commandClass": 113, - "commandClassName": "Notification", - "property": "Power Management", - "propertyKey": "Over-load status", - "propertyName": "Power Management", - "propertyKeyName": "Over-load status", - "ccVersion": 5, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 255, - "label": "Over-load status", - "states": { "0": "idle", "8": "Over-load detected" }, - "ccSpecific": { "notificationType": 8 } - }, - "value": 0 - }, { "endpoint": 0, "commandClass": 112, "commandClassName": "Configuration", "property": 10, - "propertyName": "Activate/deactivate functions ALL ON / ALL OFF", + "propertyName": "ALL ON/ALL OFF", "ccVersion": 1, "metadata": { "type": "number", "readable": true, "writeable": true, - "valueSize": 2, - "min": 0, - "max": 65535, + "description": "Responds to commands ALL ON / ALL OFF from Main Controller", + "label": "ALL ON/ALL OFF", "default": 255, - "format": 1, - "allowManualEntry": false, + "min": 0, + "max": 255, "states": { - "0": "ALL ON is not active, ALL OFF is not active", + "0": "ALL ON is not active ALL OFF is not active", "1": "ALL ON is not active ALL OFF active", "2": "ALL ON is not active ALL OFF is not active", "255": "ALL ON active, ALL OFF active" }, - "label": "Activate/deactivate functions ALL ON / ALL OFF", + "valueSize": 2, + "format": 0, + "allowManualEntry": false, "isFromConfig": true }, "value": 255 @@ -471,19 +278,20 @@ "commandClass": 112, "commandClassName": "Configuration", "property": 40, - "propertyName": "Power report (Watts) on power change for Q1 or Q2", + "propertyName": "Power reporting in watts on power change", "ccVersion": 1, "metadata": { "type": "number", "readable": true, "writeable": true, - "valueSize": 1, + "description": "Power consumption change threshold for sending updates", + "label": "Power reporting in watts on power change", + "default": 1, "min": 0, "max": 100, - "default": 1, + "valueSize": 1, "format": 0, "allowManualEntry": true, - "label": "Power report (Watts) on power change for Q1 or Q2", "isFromConfig": true }, "value": 10 @@ -493,19 +301,20 @@ "commandClass": 112, "commandClassName": "Configuration", "property": 42, - "propertyName": "Power report (Watts) by time interval for Q1 or Q2", + "propertyName": "Power reporting in Watts by time interval", "ccVersion": 1, "metadata": { "type": "number", "readable": true, "writeable": true, - "valueSize": 2, + "label": "Power reporting in Watts by time interval", + "default": 300, "min": 0, "max": 32767, - "default": 300, + "unit": "seconds", + "valueSize": 2, "format": 0, "allowManualEntry": true, - "label": "Power report (Watts) by time interval for Q1 or Q2", "isFromConfig": true }, "value": 0 @@ -521,17 +330,18 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 1, + "description": "Operation Mode (Shutter or Venetian)", + "label": "Operating modes", + "default": 0, "min": 0, "max": 255, - "default": 0, - "format": 1, - "allowManualEntry": false, "states": { - "0": "Shutter mode.", + "0": "Shutter mode", "1": "Venetian mode (up/down and slate rotation)" }, - "label": "Operating modes", + "valueSize": 1, + "format": 1, + "allowManualEntry": false, "isFromConfig": true }, "value": 0 @@ -547,16 +357,18 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 2, + "description": "Slat full turn time in tenths of a second.", + "label": "Slats tilting full turn time", + "default": 150, "min": 0, "max": 32767, - "default": 150, + "unit": "tenths of a second", + "valueSize": 2, "format": 0, "allowManualEntry": true, - "label": "Slats tilting full turn time", "isFromConfig": true }, - "value": 630 + "value": 150 }, { "endpoint": 0, @@ -569,43 +381,22 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 1, - "min": 0, - "max": 255, - "default": 1, - "format": 1, - "allowManualEntry": false, - "states": { - "0": "Return to previous position only with Z-wave", - "1": "Return to previous position with Z-wave or button" - }, + "description": "Slats position after up/down movement.", "label": "Slats position", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Previous position for Z-wave control only", + "1": "Return to previous position in all cases" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, "isFromConfig": true }, "value": 1 }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 74, - "propertyName": "Motor moving up/down time", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "valueSize": 2, - "min": 0, - "max": 32767, - "default": 0, - "format": 0, - "allowManualEntry": true, - "label": "Motor moving up/down time", - "isFromConfig": true - }, - "value": 0 - }, { "endpoint": 0, "commandClass": 112, @@ -617,36 +408,41 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 1, + "description": "Power threshold to be interpreted when motor reach the limit switch", + "label": "Motor operation detection", + "default": 10, "min": 0, - "max": 100, - "default": 6, + "max": 127, + "valueSize": 1, "format": 0, "allowManualEntry": true, - "label": "Motor operation detection", "isFromConfig": true }, - "value": 10 + "value": 30 }, { "endpoint": 0, "commandClass": 112, "commandClassName": "Configuration", "property": 78, - "propertyName": "Forced Shutter DC calibration", + "propertyName": "Forced Shutter calibration", "ccVersion": 1, "metadata": { "type": "number", "readable": true, "writeable": true, - "valueSize": 1, - "min": 0, - "max": 255, + "description": "Enters calibration mode if set to 1", + "label": "Forced Shutter calibration", "default": 0, - "format": 1, + "min": 0, + "max": 1, + "states": { + "0": "Default", + "1": "Start Calibration Process" + }, + "valueSize": 1, + "format": 0, "allowManualEntry": false, - "states": { "0": "Default", "1": "Start calibration process." }, - "label": "Forced Shutter DC calibration", "isFromConfig": true }, "value": 0 @@ -662,57 +458,38 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 1, - "min": 3, - "max": 50, - "default": 8, - "format": 0, - "allowManualEntry": true, + "description": "Time delay for detecting motor errors", "label": "Power consumption max delay time", - "isFromConfig": true - }, - "value": 8 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 86, - "propertyName": "Power consumption at limit switch delay time", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "valueSize": 1, - "min": 3, - "max": 50, "default": 8, + "min": 0, + "max": 50, + "valueSize": 1, "format": 0, "allowManualEntry": true, - "label": "Power consumption at limit switch delay time", "isFromConfig": true }, - "value": 8 + "value": 30 }, { "endpoint": 0, "commandClass": 112, "commandClassName": "Configuration", "property": 90, - "propertyName": "Time delay for next motor movement", + "propertyName": "Relay delay time", "ccVersion": 1, "metadata": { "type": "number", "readable": true, "writeable": true, - "valueSize": 1, + "description": "Defines the minimum time delay between next motor movement", + "label": "Relay delay time", + "default": 5, "min": 1, "max": 30, - "default": 5, + "unit": "milliseconds", + "valueSize": 1, "format": 0, "allowManualEntry": true, - "label": "Time delay for next motor movement", "isFromConfig": true }, "value": 5 @@ -728,13 +505,14 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 2, + "description": "Adds or removes an offset from the measured temperature.", + "label": "Temperature sensor offset settings", + "default": 32536, "min": 1, "max": 32536, - "default": 32536, + "valueSize": 2, "format": 0, "allowManualEntry": true, - "label": "Temperature sensor offset settings", "isFromConfig": true }, "value": 32536 @@ -750,16 +528,373 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 1, + "description": "Threshold for sending temperature change reports", + "label": "Digital temperature sensor reporting", + "default": 5, "min": 0, "max": 127, - "default": 5, + "valueSize": 1, "format": 0, "allowManualEntry": true, - "label": "Digital temperature sensor reporting", "isFromConfig": true }, "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 74, + "propertyName": "Motor moving up/down time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Shutter motor moving time of complete opening or complete closing", + "label": "Motor moving up/down time", + "default": 0, + "min": 0, + "max": 32767, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 80, + "propertyName": "Reporting to Controller", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Defines if reporting regarding power level, etc is reported to controller.", + "label": "Reporting to Controller", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Reporting to Controller Disabled", + "1": "Reporting to Controller Enabled" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 86, + "propertyName": "Power consumption at limit switch delay time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the time delay for detecting limit switches", + "label": "Power consumption at limit switch delay time", + "default": 8, + "min": 3, + "max": 50, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "unknown", + "propertyName": "Power Management", + "propertyKeyName": "unknown", + "ccVersion": 5, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + }, + "value": 254 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-load status", + "propertyName": "Power Management", + "propertyKeyName": "Over-load status", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-load status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "8": "Over-load detected" + } + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 345 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 82 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.38" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "71.0", + "71.0" + ] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 2 } - ] + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [ + 40000, + 100000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [ + 32, + 38, + 37, + 114, + 134 + ], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 3, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 4, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 5, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0159:0x0003:0x0052:71.0", + "statistics": { + "commandsTX": 17, + "commandsRX": 57, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + } } From f5901265dc9518dabb4800f80c7b675c34da82fb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 10 Aug 2021 16:47:52 +0200 Subject: [PATCH 302/903] Use EntityDescription - ios (#54359) * Use EntityDescription - ios * Make attribute static * Update homeassistant/components/ios/sensor.py Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/ios/sensor.py | 79 +++++++++++--------------- 1 file changed, 33 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index c1442f0de9f..d3b006f9078 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -1,6 +1,8 @@ """Support for Home Assistant iOS app sensors.""" +from __future__ import annotations + from homeassistant.components import ios -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -8,10 +10,17 @@ from homeassistant.helpers.icon import icon_for_battery_level from .const import DOMAIN -SENSOR_TYPES = { - "level": ["Battery Level", PERCENTAGE], - "state": ["Battery State", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="level", + name="Battery Level", + unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="state", + name="Battery State", + ), +) DEFAULT_ICON_LEVEL = "mdi:battery" DEFAULT_ICON_STATE = "mdi:power-plug" @@ -24,25 +33,30 @@ def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities): """Set up iOS from a config entry.""" - dev = [] - for device_name, device in ios.devices(hass).items(): - for sensor_type in ("level", "state"): - dev.append(IOSSensor(sensor_type, device_name, device)) + entities = [ + IOSSensor(device_name, device, description) + for device_name, device in ios.devices(hass).items() + for description in SENSOR_TYPES + ] - async_add_entities(dev, True) + async_add_entities(entities, True) class IOSSensor(SensorEntity): """Representation of an iOS sensor.""" - def __init__(self, sensor_type, device_name, device): + _attr_should_poll = False + + def __init__(self, device_name, device, description: SensorEntityDescription): """Initialize the sensor.""" - self._device_name = device_name - self._name = f"{device_name} {SENSOR_TYPES[sensor_type][0]}" + self.entity_description = description self._device = device - self.type = sensor_type - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + device_name = device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME] + self._attr_name = f"{device_name} {description.key}" + + device_id = device[ios.ATTR_DEVICE_ID] + self._attr_unique_id = f"{description.key}_{device_id}" @property def device_info(self): @@ -60,33 +74,6 @@ class IOSSensor(SensorEntity): "sw_version": self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_SYSTEM_VERSION], } - @property - def name(self): - """Return the name of the iOS sensor.""" - device_name = self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME] - return f"{device_name} {SENSOR_TYPES[self.type][0]}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - device_id = self._device[ios.ATTR_DEVICE_ID] - return f"{self.type}_{device_id}" - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def unit_of_measurement(self): - """Return the unit of measurement this sensor expresses itself in.""" - return self._unit_of_measurement - @property def extra_state_attributes(self): """Return the device state attributes.""" @@ -119,7 +106,7 @@ class IOSSensor(SensorEntity): charging = False icon_state = f"{DEFAULT_ICON_LEVEL}-unknown" - if self.type == "state": + if self.entity_description.key == "state": return icon_state return icon_for_battery_level(battery_level=battery_level, charging=charging) @@ -127,12 +114,12 @@ class IOSSensor(SensorEntity): def _update(self, device): """Get the latest state of the sensor.""" self._device = device - self._state = self._device[ios.ATTR_BATTERY][self.type] + self._attr_state = self._device[ios.ATTR_BATTERY][self.entity_description.key] self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Added to hass so need to register to dispatch.""" - self._state = self._device[ios.ATTR_BATTERY][self.type] + self._attr_state = self._device[ios.ATTR_BATTERY][self.entity_description.key] device_id = self._device[ios.ATTR_DEVICE_ID] self.async_on_remove( async_dispatcher_connect(self.hass, f"{DOMAIN}.{device_id}", self._update) From c0a7fca6281267326d3d8d4db416e53601be697c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 10 Aug 2021 18:57:25 +0200 Subject: [PATCH 303/903] Fix pi_hole sensor icon (#54403) --- homeassistant/components/pi_hole/__init__.py | 7 ++--- homeassistant/components/pi_hole/const.py | 28 +++++++++++++------- homeassistant/components/pi_hole/sensor.py | 8 ++++-- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index ab9191b0f4a..ddd4f77fa36 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -161,6 +161,8 @@ def _async_platforms(entry: ConfigEntry) -> list[str]: class PiHoleEntity(CoordinatorEntity): """Representation of a Pi-hole entity.""" + _attr_icon: str = "mdi:pi-hole" + def __init__( self, api: Hole, @@ -174,11 +176,6 @@ class PiHoleEntity(CoordinatorEntity): self._name = name self._server_unique_id = server_unique_id - @property - def icon(self) -> str: - """Icon to use in the frontend, if any.""" - return "mdi:pi-hole" - @property def device_info(self) -> DeviceInfo: """Return the device information of the entity.""" diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index 40a3a16de3a..52c638864a5 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -1,6 +1,7 @@ """Constants for the pi_hole integration.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from homeassistant.components.sensor import SensorEntityDescription @@ -29,56 +30,63 @@ DATA_KEY_API = "api" DATA_KEY_COORDINATOR = "coordinator" -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +@dataclass +class PiHoleSensorEntityDescription(SensorEntityDescription): + """Describes PiHole sensor entity.""" + + icon: str = "mdi:pi-hole" + + +SENSOR_TYPES: tuple[PiHoleSensorEntityDescription, ...] = ( + PiHoleSensorEntityDescription( key="ads_blocked_today", name="Ads Blocked Today", unit_of_measurement="ads", icon="mdi:close-octagon-outline", ), - SensorEntityDescription( + PiHoleSensorEntityDescription( key="ads_percentage_today", name="Ads Percentage Blocked Today", unit_of_measurement=PERCENTAGE, icon="mdi:close-octagon-outline", ), - SensorEntityDescription( + PiHoleSensorEntityDescription( key="clients_ever_seen", name="Seen Clients", unit_of_measurement="clients", icon="mdi:account-outline", ), - SensorEntityDescription( + PiHoleSensorEntityDescription( key="dns_queries_today", name="DNS Queries Today", unit_of_measurement="queries", icon="mdi:comment-question-outline", ), - SensorEntityDescription( + PiHoleSensorEntityDescription( key="domains_being_blocked", name="Domains Blocked", unit_of_measurement="domains", icon="mdi:block-helper", ), - SensorEntityDescription( + PiHoleSensorEntityDescription( key="queries_cached", name="DNS Queries Cached", unit_of_measurement="queries", icon="mdi:comment-question-outline", ), - SensorEntityDescription( + PiHoleSensorEntityDescription( key="queries_forwarded", name="DNS Queries Forwarded", unit_of_measurement="queries", icon="mdi:comment-question-outline", ), - SensorEntityDescription( + PiHoleSensorEntityDescription( key="unique_clients", name="DNS Unique Clients", unit_of_measurement="clients", icon="mdi:account-outline", ), - SensorEntityDescription( + PiHoleSensorEntityDescription( key="unique_domains", name="DNS Unique Domains", unit_of_measurement="domains", diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 38b0b192e14..242f7a3a742 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -5,7 +5,7 @@ from typing import Any from hole import Hole -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant @@ -19,6 +19,7 @@ from .const import ( DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN, SENSOR_TYPES, + PiHoleSensorEntityDescription, ) @@ -44,13 +45,15 @@ async def async_setup_entry( class PiHoleSensor(PiHoleEntity, SensorEntity): """Representation of a Pi-hole sensor.""" + entity_description: PiHoleSensorEntityDescription + def __init__( self, api: Hole, coordinator: DataUpdateCoordinator, name: str, server_unique_id: str, - description: SensorEntityDescription, + description: PiHoleSensorEntityDescription, ) -> None: """Initialize a Pi-hole sensor.""" super().__init__(api, coordinator, name, server_unique_id) @@ -58,6 +61,7 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): self._attr_name = f"{name} {description.name}" self._attr_unique_id = f"{self._server_unique_id}/{description.name}" + self._attr_icon = description.icon # Necessary to overwrite inherited value @property def state(self) -> Any: From 1eeb12ba1c9b24ca6fe489dc0ded37a6c4f73351 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 10 Aug 2021 12:57:39 -0500 Subject: [PATCH 304/903] Support unloading/reloading Sonos (#54418) --- homeassistant/components/sonos/__init__.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index f0219ea8cf0..45f5cf9276c 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -138,6 +138,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a Sonos config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown() + hass.data.pop(DATA_SONOS) + hass.data.pop(DATA_SONOS_DISCOVERY_MANAGER) + return unload_ok + + class SonosDiscoveryManager: """Manage sonos discovery.""" @@ -151,6 +160,11 @@ class SonosDiscoveryManager: self.hosts = hosts self.discovery_lock = asyncio.Lock() + async def async_shutdown(self): + """Stop all running tasks.""" + await self._async_stop_event_listener() + self._stop_manual_heartbeat() + def _create_soco(self, ip_address: str, source: SoCoCreationSource) -> SoCo | None: """Create a soco instance and return if successful.""" if ip_address in self.data.discovery_ignored: @@ -171,7 +185,7 @@ class SonosDiscoveryManager: ) return None - async def _async_stop_event_listener(self, event: Event) -> None: + async def _async_stop_event_listener(self, event: Event | None = None) -> None: await asyncio.gather( *(speaker.async_unsubscribe() for speaker in self.data.discovered.values()), return_exceptions=True, @@ -179,7 +193,7 @@ class SonosDiscoveryManager: if events_asyncio.event_listener: await events_asyncio.event_listener.async_stop() - def _stop_manual_heartbeat(self, event: Event) -> None: + def _stop_manual_heartbeat(self, event: Event | None = None) -> None: if self.data.hosts_heartbeat: self.data.hosts_heartbeat() self.data.hosts_heartbeat = None From ba6bdff04e6799e065da453fce33a06f24d40074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 10 Aug 2021 20:14:10 +0200 Subject: [PATCH 305/903] Re-add Tibber notify service name (#54401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/tibber/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index a18bb855f8f..da94df55c88 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -5,7 +5,7 @@ import logging import aiohttp import tibber -from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -62,7 +62,7 @@ async def async_setup_entry(hass, entry): # have to use discovery to load platform. hass.async_create_task( discovery.async_load_platform( - hass, "notify", DOMAIN, {}, hass.data[DATA_HASS_CONFIG] + hass, "notify", DOMAIN, {CONF_NAME: DOMAIN}, hass.data[DATA_HASS_CONFIG] ) ) return True From 2265fd1f81efa089b2db772ebad4a7fdc7108f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 10 Aug 2021 20:26:48 +0200 Subject: [PATCH 306/903] Mark Uptime Robot as a platinum quality integration (#54408) --- homeassistant/components/uptimerobot/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 22d9a6d9477..279bf6eb43e 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -8,6 +8,7 @@ "codeowners": [ "@ludeeus" ], + "quality_scale": "platinum", "iot_class": "cloud_polling", "config_flow": true } \ No newline at end of file From ed0fd0074670ed56d056f139a9f934b3f89d9487 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 10 Aug 2021 11:30:02 -0700 Subject: [PATCH 307/903] Bump hass_nabucasa to 0.46.0 (#54421) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index abf73c1d54b..129b9f83819 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.45.1"], + "requirements": ["hass-nabucasa==0.46.0"], "dependencies": ["http", "webhook"], "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d03c4b7c4f7..323b1c86034 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==3.3.2 defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 -hass-nabucasa==0.45.1 +hass-nabucasa==0.46.0 home-assistant-frontend==20210809.0 httpx==0.18.2 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 7c4001c4c5a..813f40acf54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -753,7 +753,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.45.1 +hass-nabucasa==0.46.0 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f0b541ad31..4bc6cb76018 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -431,7 +431,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.45.1 +hass-nabucasa==0.46.0 # homeassistant.components.tasmota hatasmota==0.2.20 From 08a30ed5100579dea913ece3ff6ad6c96bee4bbe Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 10 Aug 2021 21:14:09 +0200 Subject: [PATCH 308/903] Add myself as codeowner to tradfri (IKEA stuff) (#54415) --- CODEOWNERS | 1 + homeassistant/components/tradfri/manifest.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index d2e756c0d0d..a7aea24c4f0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -527,6 +527,7 @@ homeassistant/components/tplink/* @rytilahti @thegardenmonkey homeassistant/components/traccar/* @ludeeus homeassistant/components/trace/* @home-assistant/core homeassistant/components/tractive/* @Danielhiversen @zhulik +homeassistant/components/tradfri/* @janiversen homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json index 3e13cdc015a..7ffad04074d 100644 --- a/homeassistant/components/tradfri/manifest.json +++ b/homeassistant/components/tradfri/manifest.json @@ -7,6 +7,6 @@ "homekit": { "models": ["TRADFRI"] }, - "codeowners": [], + "codeowners": ["@janiversen"], "iot_class": "local_polling" } From ac29571db331dbd08204398c44182e236f769926 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 10 Aug 2021 21:14:31 +0200 Subject: [PATCH 309/903] Refactor pi_hole icon usage (#54420) --- homeassistant/components/pi_hole/__init__.py | 2 -- homeassistant/components/pi_hole/binary_sensor.py | 2 ++ homeassistant/components/pi_hole/sensor.py | 1 - homeassistant/components/pi_hole/switch.py | 7 ++----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index ddd4f77fa36..5c679a4839a 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -161,8 +161,6 @@ def _async_platforms(entry: ConfigEntry) -> list[str]: class PiHoleEntity(CoordinatorEntity): """Representation of a Pi-hole entity.""" - _attr_icon: str = "mdi:pi-hole" - def __init__( self, api: Hole, diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 3c322d324d3..5758c0e4145 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -29,6 +29,8 @@ async def async_setup_entry( class PiHoleBinarySensor(PiHoleEntity, BinarySensorEntity): """Representation of a Pi-hole binary sensor.""" + _attr_icon = "mdi:pi-hole" + @property def name(self) -> str: """Return the name of the sensor.""" diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 242f7a3a742..14aed86a479 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -61,7 +61,6 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): self._attr_name = f"{name} {description.name}" self._attr_unique_id = f"{self._server_unique_id}/{description.name}" - self._attr_icon = description.icon # Necessary to overwrite inherited value @property def state(self) -> Any: diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py index b0c4b09c2e7..dc699beb26b 100644 --- a/homeassistant/components/pi_hole/switch.py +++ b/homeassistant/components/pi_hole/switch.py @@ -58,6 +58,8 @@ async def async_setup_entry( class PiHoleSwitch(PiHoleEntity, SwitchEntity): """Representation of a Pi-hole switch.""" + _attr_icon = "mdi:pi-hole" + @property def name(self) -> str: """Return the name of the switch.""" @@ -68,11 +70,6 @@ class PiHoleSwitch(PiHoleEntity, SwitchEntity): """Return the unique id of the switch.""" return f"{self._server_unique_id}/Switch" - @property - def icon(self) -> str: - """Icon to use in the frontend, if any.""" - return "mdi:pi-hole" - @property def is_on(self) -> bool: """Return if the service is on.""" From 4bde4504ec087625335d0023114fbfe7356cb008 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Aug 2021 14:21:34 -0500 Subject: [PATCH 310/903] Add api to device_automation to return all matching devices (#53361) --- .../components/device_automation/__init__.py | 110 ++++++++++++------ tests/common.py | 13 ++- .../components/device_automation/test_init.py | 71 +++++++++++ tests/components/remote/test_device_action.py | 4 +- tests/components/switch/test_device_action.py | 4 +- tests/components/zha/test_device_action.py | 5 +- 6 files changed, 159 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 93b0b9a4a9d..945774da0b4 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import MutableMapping +from collections.abc import Iterable, Mapping from functools import wraps from types import ModuleType from typing import Any @@ -13,9 +13,12 @@ import voluptuous_serialize from homeassistant.components import websocket_api from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_registry import async_entries_for_device -from homeassistant.loader import IntegrationNotFound +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.loader import IntegrationNotFound, bind_hass from homeassistant.requirements import async_get_integration_with_requirements from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig @@ -49,6 +52,16 @@ TYPES = { } +@bind_hass +async def async_get_device_automations( + hass: HomeAssistant, + automation_type: str, + device_ids: Iterable[str] | None = None, +) -> Mapping[str, Any]: + """Return all the device automations for a type optionally limited to specific device ids.""" + return await _async_get_device_automations(hass, automation_type, device_ids) + + async def async_setup(hass, config): """Set up device automation.""" hass.components.websocket_api.async_register_command( @@ -96,7 +109,7 @@ async def async_get_device_automation_platform( async def _async_get_device_automations_from_domain( - hass, domain, automation_type, device_id + hass, domain, automation_type, device_ids, return_exceptions ): """List device automations.""" try: @@ -104,48 +117,67 @@ async def _async_get_device_automations_from_domain( hass, domain, automation_type ) except InvalidDeviceAutomationConfig: - return None + return {} function_name = TYPES[automation_type][1] - return await getattr(platform, function_name)(hass, device_id) - - -async def _async_get_device_automations(hass, automation_type, device_id): - """List device automations.""" - device_registry, entity_registry = await asyncio.gather( - hass.helpers.device_registry.async_get_registry(), - hass.helpers.entity_registry.async_get_registry(), + return await asyncio.gather( + *( + getattr(platform, function_name)(hass, device_id) + for device_id in device_ids + ), + return_exceptions=return_exceptions, ) - domains = set() - automations: list[MutableMapping[str, Any]] = [] - device = device_registry.async_get(device_id) - if device is None: - raise DeviceNotFound +async def _async_get_device_automations( + hass: HomeAssistant, automation_type: str, device_ids: Iterable[str] | None +) -> Mapping[str, list[dict[str, Any]]]: + """List device automations.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + domain_devices: dict[str, set[str]] = {} + device_entities_domains: dict[str, set[str]] = {} + match_device_ids = set(device_ids or device_registry.devices) + combined_results: dict[str, list[dict[str, Any]]] = {} - for entry_id in device.config_entries: - config_entry = hass.config_entries.async_get_entry(entry_id) - domains.add(config_entry.domain) + for entry in entity_registry.entities.values(): + if not entry.disabled_by and entry.device_id in match_device_ids: + device_entities_domains.setdefault(entry.device_id, set()).add(entry.domain) - entity_entries = async_entries_for_device(entity_registry, device_id) - for entity_entry in entity_entries: - domains.add(entity_entry.domain) + for device_id in match_device_ids: + combined_results[device_id] = [] + device = device_registry.async_get(device_id) + if device is None: + raise DeviceNotFound + for entry_id in device.config_entries: + if config_entry := hass.config_entries.async_get_entry(entry_id): + domain_devices.setdefault(config_entry.domain, set()).add(device_id) + for domain in device_entities_domains.get(device_id, []): + domain_devices.setdefault(domain, set()).add(device_id) - device_automations = await asyncio.gather( + # If specific device ids were requested, we allow + # InvalidDeviceAutomationConfig to be thrown, otherwise we skip + # devices that do not have valid triggers + return_exceptions = not bool(device_ids) + + for domain_results in await asyncio.gather( *( _async_get_device_automations_from_domain( - hass, domain, automation_type, device_id + hass, domain, automation_type, domain_device_ids, return_exceptions ) - for domain in domains + for domain, domain_device_ids in domain_devices.items() ) - ) - for device_automation in device_automations: - if device_automation is not None: - automations.extend(device_automation) + ): + for device_results in domain_results: + if device_results is None or isinstance( + device_results, InvalidDeviceAutomationConfig + ): + continue + for automation in device_results: + combined_results[automation["device_id"]].append(automation) - return automations + return combined_results async def _async_get_device_automation_capabilities(hass, automation_type, automation): @@ -207,7 +239,9 @@ def handle_device_errors(func): async def websocket_device_automation_list_actions(hass, connection, msg): """Handle request for device actions.""" device_id = msg["device_id"] - actions = await _async_get_device_automations(hass, "action", device_id) + actions = (await _async_get_device_automations(hass, "action", [device_id])).get( + device_id + ) connection.send_result(msg["id"], actions) @@ -222,7 +256,9 @@ async def websocket_device_automation_list_actions(hass, connection, msg): async def websocket_device_automation_list_conditions(hass, connection, msg): """Handle request for device conditions.""" device_id = msg["device_id"] - conditions = await _async_get_device_automations(hass, "condition", device_id) + conditions = ( + await _async_get_device_automations(hass, "condition", [device_id]) + ).get(device_id) connection.send_result(msg["id"], conditions) @@ -237,7 +273,9 @@ async def websocket_device_automation_list_conditions(hass, connection, msg): async def websocket_device_automation_list_triggers(hass, connection, msg): """Handle request for device triggers.""" device_id = msg["device_id"] - triggers = await _async_get_device_automations(hass, "trigger", device_id) + triggers = (await _async_get_device_automations(hass, "trigger", [device_id])).get( + device_id + ) connection.send_result(msg["id"], triggers) diff --git a/tests/common.py b/tests/common.py index 5de58a08472..3d5e28be514 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,10 +29,9 @@ from homeassistant.auth import ( providers as auth_providers, ) from homeassistant.auth.permissions import system_policies -from homeassistant.components import recorder +from homeassistant.components import device_automation, recorder from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, - _async_get_device_automations as async_get_device_automations, ) from homeassistant.components.mqtt.models import ReceiveMessage from homeassistant.config import async_process_component_config @@ -69,6 +68,16 @@ CLIENT_ID = "https://example.com/app" CLIENT_REDIRECT_URI = "https://example.com/app/callback" +async def async_get_device_automations( + hass: HomeAssistant, automation_type: str, device_id: str +) -> Any: + """Get a device automation for a single device id.""" + automations = await device_automation.async_get_device_automations( + hass, automation_type, [device_id] + ) + return automations.get(device_id) + + def threadsafe_callback_factory(func): """Create threadsafe functions out of callbacks. diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 160e6354b8b..13190ed4b32 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,6 +1,7 @@ """The test for light device automation.""" import pytest +from homeassistant.components import device_automation import homeassistant.components.automation as automation from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON @@ -372,6 +373,76 @@ async def test_websocket_get_no_condition_capabilities( assert capabilities == expected_capabilities +async def test_async_get_device_automations_single_device_trigger( + hass, device_reg, entity_reg +): + """Test we get can fetch the triggers for a device id.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + result = await device_automation.async_get_device_automations( + hass, "trigger", [device_entry.id] + ) + assert device_entry.id in result + assert len(result[device_entry.id]) == 2 + + +async def test_async_get_device_automations_all_devices_trigger( + hass, device_reg, entity_reg +): + """Test we get can fetch all the triggers when no device id is passed.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + result = await device_automation.async_get_device_automations(hass, "trigger") + assert device_entry.id in result + assert len(result[device_entry.id]) == 2 + + +async def test_async_get_device_automations_all_devices_condition( + hass, device_reg, entity_reg +): + """Test we get can fetch all the conditions when no device id is passed.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + result = await device_automation.async_get_device_automations(hass, "condition") + assert device_entry.id in result + assert len(result[device_entry.id]) == 2 + + +async def test_async_get_device_automations_all_devices_action( + hass, device_reg, entity_reg +): + """Test we get can fetch all the actions when no device id is passed.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + result = await device_automation.async_get_device_automations(hass, "action") + assert device_entry.id in result + assert len(result[device_entry.id]) == 3 + + async def test_websocket_get_trigger_capabilities( hass, hass_ws_client, device_reg, entity_reg ): diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 1193764da3a..48e741a12a4 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -2,9 +2,6 @@ import pytest import homeassistant.components.automation as automation -from homeassistant.components.device_automation import ( - _async_get_device_automations as async_get_device_automations, -) from homeassistant.components.remote import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -12,6 +9,7 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, + async_get_device_automations, async_mock_service, mock_device_registry, mock_registry, diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index 9f8d821e74b..2ccfb26d3ef 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -2,9 +2,6 @@ import pytest import homeassistant.components.automation as automation -from homeassistant.components.device_automation import ( - _async_get_device_automations as async_get_device_automations, -) from homeassistant.components.switch import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -12,6 +9,7 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, + async_get_device_automations, async_mock_service, mock_device_registry, mock_registry, diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 4a777fcebb6..49fa11de26c 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -8,14 +8,11 @@ import zigpy.zcl.clusters.security as security import zigpy.zcl.foundation as zcl_f import homeassistant.components.automation as automation -from homeassistant.components.device_automation import ( - _async_get_device_automations as async_get_device_automations, -) from homeassistant.components.zha import DOMAIN from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from tests.common import async_mock_service, mock_coro +from tests.common import async_get_device_automations, async_mock_service, mock_coro from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 SHORT_PRESS = "remote_button_short_press" From 4da451fcf7e25257722b3227f1d4d31411a38bed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Aug 2021 17:16:51 -0500 Subject: [PATCH 311/903] Improve HomeKit Color with Color Temp implementation (#54371) --- .../components/homekit/type_lights.py | 189 ++++----- tests/components/homekit/test_type_lights.py | 390 +++++++++++------- 2 files changed, 307 insertions(+), 272 deletions(-) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 169130a194a..aea760534fd 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -6,13 +6,11 @@ from pyhap.const import CATEGORY_LIGHTBULB from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, ATTR_SUPPORTED_COLOR_MODES, - COLOR_MODE_COLOR_TEMP, DOMAIN, brightness_supported, color_supported, @@ -25,13 +23,17 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback +from homeassistant.helpers.event import async_call_later +from homeassistant.util.color import ( + color_temperature_mired_to_kelvin, + color_temperature_to_hs, +) from .accessories import TYPES, HomeAccessory from .const import ( CHAR_BRIGHTNESS, CHAR_COLOR_TEMPERATURE, CHAR_HUE, - CHAR_NAME, CHAR_ON, CHAR_SATURATION, PROP_MAX_VALUE, @@ -43,6 +45,8 @@ _LOGGER = logging.getLogger(__name__) RGB_COLOR = "rgb_color" +CHANGE_COALESCE_TIME_WINDOW = 0.01 + @TYPES.register("Light") class Light(HomeAccessory): @@ -55,102 +59,78 @@ class Light(HomeAccessory): """Initialize a new Light accessory object.""" super().__init__(*args, category=CATEGORY_LIGHTBULB) - self.chars_primary = [] - self.chars_secondary = [] + self.chars = [] + self._event_timer = None + self._pending_events = {} state = self.hass.states.get(self.entity_id) attributes = state.attributes color_modes = attributes.get(ATTR_SUPPORTED_COLOR_MODES) - self.is_color_supported = color_supported(color_modes) - self.is_color_temp_supported = color_temp_supported(color_modes) - self.color_and_temp_supported = ( - self.is_color_supported and self.is_color_temp_supported - ) - self.is_brightness_supported = brightness_supported(color_modes) + self.color_supported = color_supported(color_modes) + self.color_temp_supported = color_temp_supported(color_modes) + self.brightness_supported = brightness_supported(color_modes) - if self.is_brightness_supported: - self.chars_primary.append(CHAR_BRIGHTNESS) + if self.brightness_supported: + self.chars.append(CHAR_BRIGHTNESS) - if self.is_color_supported: - self.chars_primary.append(CHAR_HUE) - self.chars_primary.append(CHAR_SATURATION) + if self.color_supported: + self.chars.extend([CHAR_HUE, CHAR_SATURATION]) - if self.is_color_temp_supported: - if self.color_and_temp_supported: - self.chars_primary.append(CHAR_NAME) - self.chars_secondary.append(CHAR_NAME) - self.chars_secondary.append(CHAR_COLOR_TEMPERATURE) - if self.is_brightness_supported: - self.chars_secondary.append(CHAR_BRIGHTNESS) - else: - self.chars_primary.append(CHAR_COLOR_TEMPERATURE) + if self.color_temp_supported: + self.chars.append(CHAR_COLOR_TEMPERATURE) - serv_light_primary = self.add_preload_service( - SERV_LIGHTBULB, self.chars_primary - ) - serv_light_secondary = None - self.char_on_primary = serv_light_primary.configure_char(CHAR_ON, value=0) + serv_light = self.add_preload_service(SERV_LIGHTBULB, self.chars) + self.char_on = serv_light.configure_char(CHAR_ON, value=0) - if self.color_and_temp_supported: - serv_light_secondary = self.add_preload_service( - SERV_LIGHTBULB, self.chars_secondary - ) - serv_light_primary.add_linked_service(serv_light_secondary) - serv_light_primary.configure_char(CHAR_NAME, value="RGB") - self.char_on_secondary = serv_light_secondary.configure_char( - CHAR_ON, value=0 - ) - serv_light_secondary.configure_char(CHAR_NAME, value="Temperature") - - if self.is_brightness_supported: + if self.brightness_supported: # Initial value is set to 100 because 0 is a special value (off). 100 is # an arbitrary non-zero value. It is updated immediately by async_update_state # to set to the correct initial value. - self.char_brightness_primary = serv_light_primary.configure_char( - CHAR_BRIGHTNESS, value=100 - ) - if self.chars_secondary: - self.char_brightness_secondary = serv_light_secondary.configure_char( - CHAR_BRIGHTNESS, value=100 - ) + self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) - if self.is_color_temp_supported: + if self.color_temp_supported: min_mireds = attributes.get(ATTR_MIN_MIREDS, 153) max_mireds = attributes.get(ATTR_MAX_MIREDS, 500) - serv_light = serv_light_secondary or serv_light_primary - self.char_color_temperature = serv_light.configure_char( + self.char_color_temp = serv_light.configure_char( CHAR_COLOR_TEMPERATURE, value=min_mireds, properties={PROP_MIN_VALUE: min_mireds, PROP_MAX_VALUE: max_mireds}, ) - if self.is_color_supported: - self.char_hue = serv_light_primary.configure_char(CHAR_HUE, value=0) - self.char_saturation = serv_light_primary.configure_char( - CHAR_SATURATION, value=75 - ) + if self.color_supported: + self.char_hue = serv_light.configure_char(CHAR_HUE, value=0) + self.char_saturation = serv_light.configure_char(CHAR_SATURATION, value=75) self.async_update_state(state) + serv_light.setter_callback = self._set_chars - if self.color_and_temp_supported: - serv_light_primary.setter_callback = self._set_chars_primary - serv_light_secondary.setter_callback = self._set_chars_secondary - else: - serv_light_primary.setter_callback = self._set_chars + def _set_chars(self, char_values): + _LOGGER.debug("Light _set_chars: %s", char_values) + # Newest change always wins + if CHAR_COLOR_TEMPERATURE in self._pending_events and ( + CHAR_SATURATION in char_values or CHAR_HUE in char_values + ): + del self._pending_events[CHAR_COLOR_TEMPERATURE] + for char in (CHAR_HUE, CHAR_SATURATION): + if char in self._pending_events and CHAR_COLOR_TEMPERATURE in char_values: + del self._pending_events[char] - def _set_chars_primary(self, char_values): - """Primary service is RGB or W if only color or color temp is supported.""" - self._set_chars(char_values, True) + self._pending_events.update(char_values) + if self._event_timer: + self._event_timer() + self._event_timer = async_call_later( + self.hass, CHANGE_COALESCE_TIME_WINDOW, self._send_events + ) - def _set_chars_secondary(self, char_values): - """Secondary service is W if both color or color temp are supported.""" - self._set_chars(char_values, False) - - def _set_chars(self, char_values, is_primary=None): - _LOGGER.debug("Light _set_chars: %s, is_primary: %s", char_values, is_primary) + def _send_events(self, *_): + """Process all changes at once.""" + _LOGGER.debug("Coalesced _set_chars: %s", self._pending_events) + char_values = self._pending_events + self._pending_events = {} events = [] service = SERVICE_TURN_ON params = {ATTR_ENTITY_ID: self.entity_id} + if CHAR_ON in char_values: if not char_values[CHAR_ON]: service = SERVICE_TURN_OFF @@ -170,24 +150,16 @@ class Light(HomeAccessory): ) return - if self.is_color_temp_supported and ( - is_primary is False or CHAR_COLOR_TEMPERATURE in char_values - ): - params[ATTR_COLOR_TEMP] = char_values.get( - CHAR_COLOR_TEMPERATURE, self.char_color_temperature.value - ) + if CHAR_COLOR_TEMPERATURE in char_values: + params[ATTR_COLOR_TEMP] = char_values[CHAR_COLOR_TEMPERATURE] events.append(f"color temperature at {params[ATTR_COLOR_TEMP]}") - if self.is_color_supported and ( - is_primary is True - or (CHAR_HUE in char_values and CHAR_SATURATION in char_values) - ): - color = ( + elif CHAR_HUE in char_values or CHAR_SATURATION in char_values: + color = params[ATTR_HS_COLOR] = ( char_values.get(CHAR_HUE, self.char_hue.value), char_values.get(CHAR_SATURATION, self.char_saturation.value), ) _LOGGER.debug("%s: Set hs_color to %s", self.entity_id, color) - params[ATTR_HS_COLOR] = color events.append(f"set color at {color}") self.async_call_service(DOMAIN, service, params, ", ".join(events)) @@ -198,20 +170,10 @@ class Light(HomeAccessory): # Handle State state = new_state.state attributes = new_state.attributes - char_on_value = int(state == STATE_ON) - - if self.color_and_temp_supported: - color_mode = attributes.get(ATTR_COLOR_MODE) - color_temp_mode = color_mode == COLOR_MODE_COLOR_TEMP - primary_on_value = char_on_value if not color_temp_mode else 0 - secondary_on_value = char_on_value if color_temp_mode else 0 - self.char_on_primary.set_value(primary_on_value) - self.char_on_secondary.set_value(secondary_on_value) - else: - self.char_on_primary.set_value(char_on_value) + self.char_on.set_value(int(state == STATE_ON)) # Handle Brightness - if self.is_brightness_supported: + if self.brightness_supported: brightness = attributes.get(ATTR_BRIGHTNESS) if isinstance(brightness, (int, float)): brightness = round(brightness / 255 * 100, 0) @@ -227,22 +189,25 @@ class Light(HomeAccessory): # order to avoid this incorrect behavior. if brightness == 0 and state == STATE_ON: brightness = 1 - self.char_brightness_primary.set_value(brightness) - if self.color_and_temp_supported: - self.char_brightness_secondary.set_value(brightness) + self.char_brightness.set_value(brightness) + + # Handle Color - color must always be set before color temperature + # or the iOS UI will not display it correctly. + if self.color_supported: + if ATTR_COLOR_TEMP in attributes: + hue, saturation = color_temperature_to_hs( + color_temperature_mired_to_kelvin( + new_state.attributes[ATTR_COLOR_TEMP] + ) + ) + else: + hue, saturation = attributes.get(ATTR_HS_COLOR, (None, None)) + if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)): + self.char_hue.set_value(round(hue, 0)) + self.char_saturation.set_value(round(saturation, 0)) # Handle color temperature - if self.is_color_temp_supported: - color_temperature = attributes.get(ATTR_COLOR_TEMP) - if isinstance(color_temperature, (int, float)): - color_temperature = round(color_temperature, 0) - self.char_color_temperature.set_value(color_temperature) - - # Handle Color - if self.is_color_supported: - hue, saturation = attributes.get(ATTR_HS_COLOR, (None, None)) - if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)): - hue = round(hue, 0) - saturation = round(saturation, 0) - self.char_hue.set_value(hue) - self.char_saturation.set_value(saturation) + if self.color_temp_supported: + color_temp = attributes.get(ATTR_COLOR_TEMP) + if isinstance(color_temp, (int, float)): + self.char_color_temp.set_value(round(color_temp, 0)) diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index f75e6bf19ac..90e3aa0cabe 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,21 +1,21 @@ """Test different accessory types: Lights.""" +from datetime import timedelta + from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest from homeassistant.components.homekit.const import ATTR_VALUE -from homeassistant.components.homekit.type_lights import Light +from homeassistant.components.homekit.type_lights import ( + CHANGE_COALESCE_TIME_WINDOW, + Light, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_SUPPORTED_COLOR_MODES, - COLOR_MODE_COLOR_TEMP, - COLOR_MODE_HS, - COLOR_MODE_RGB, - COLOR_MODE_XY, DOMAIN, ) from homeassistant.const import ( @@ -29,8 +29,16 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util -from tests.common import async_mock_service +from tests.common import async_fire_time_changed, async_mock_service + + +async def _wait_for_light_coalesce(hass): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=CHANGE_COALESCE_TIME_WINDOW) + ) + await hass.async_block_till_done() async def test_light_basic(hass, hk_driver, events): @@ -44,45 +52,41 @@ async def test_light_basic(hass, hk_driver, events): assert acc.aid == 1 assert acc.category == 5 # Lightbulb - assert acc.char_on_primary.value + assert acc.char_on.value await acc.run() await hass.async_block_till_done() - assert acc.char_on_primary.value == 1 + assert acc.char_on.value == 1 hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0}) await hass.async_block_till_done() - assert acc.char_on_primary.value == 0 + assert acc.char_on.value == 0 hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() - assert acc.char_on_primary.value == 0 + assert acc.char_on.value == 0 hass.states.async_remove(entity_id) await hass.async_block_till_done() - assert acc.char_on_primary.value == 0 + assert acc.char_on.value == 0 # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") - char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_on_primary_iid, - HAP_REPR_VALUE: 1, - } + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1} ] }, "mock_addr", ) - await hass.async_add_executor_job(acc.char_on_primary.client_update_value, 1) - await hass.async_block_till_done() + acc.char_on.client_update_value(1) + await _wait_for_light_coalesce(hass) assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 1 @@ -94,16 +98,12 @@ async def test_light_basic(hass, hk_driver, events): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_on_primary_iid, - HAP_REPR_VALUE: 0, - } + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 0} ] }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 2 @@ -128,17 +128,17 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # brightness to 100 when turning on a light on a freshly booted up server. - assert acc.char_brightness_primary.value != 0 - char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] - char_brightness_primary_iid = acc.char_brightness_primary.to_HAP()[HAP_REPR_IID] + assert acc.char_brightness.value != 0 + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] await acc.run() await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 100 + assert acc.char_brightness.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 40 + assert acc.char_brightness.value == 40 # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") @@ -147,21 +147,17 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): 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_on_primary_iid, - HAP_REPR_VALUE: 1, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_primary_iid, + HAP_REPR_IID: char_brightness_iid, HAP_REPR_VALUE: 20, }, ] }, "mock_addr", ) - await hass.async_block_till_done() + 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 @@ -173,21 +169,17 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): 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_on_primary_iid, - HAP_REPR_VALUE: 1, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_primary_iid, + HAP_REPR_IID: char_brightness_iid, HAP_REPR_VALUE: 40, }, ] }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_on[1] assert call_turn_on[1].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[1].data[ATTR_BRIGHTNESS_PCT] == 40 @@ -199,21 +191,17 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): 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_on_primary_iid, - HAP_REPR_VALUE: 1, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_primary_iid, + HAP_REPR_IID: char_brightness_iid, HAP_REPR_VALUE: 0, }, ] }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 3 @@ -223,24 +211,24 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): # in update_state hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 1 + assert acc.char_brightness.value == 1 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 255}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 100 + assert acc.char_brightness.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 1 + assert acc.char_brightness.value == 1 # Ensure floats are handled hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 55.66}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 22 + assert acc.char_brightness.value == 22 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 108.4}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 43 + assert acc.char_brightness.value == 43 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0.0}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 1 + assert acc.char_brightness.value == 1 async def test_light_color_temperature(hass, hk_driver, events): @@ -256,33 +244,30 @@ async def test_light_color_temperature(hass, hk_driver, events): acc = Light(hass, hk_driver, "Light", entity_id, 1, None) hk_driver.add_accessory(acc) - assert acc.char_color_temperature.value == 190 + assert acc.char_color_temp.value == 190 await acc.run() await hass.async_block_till_done() - assert acc.char_color_temperature.value == 190 + assert acc.char_color_temp.value == 190 # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") - char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] hk_driver.set_characteristics( { HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_color_temperature_iid, + HAP_REPR_IID: char_color_temp_iid, HAP_REPR_VALUE: 250, } ] }, "mock_addr", ) - await hass.async_add_executor_job( - acc.char_color_temperature.client_update_value, 250 - ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 @@ -292,11 +277,7 @@ async def test_light_color_temperature(hass, hk_driver, events): @pytest.mark.parametrize( "supported_color_modes", - [ - [COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS], - [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGB], - [COLOR_MODE_COLOR_TEMP, COLOR_MODE_XY], - ], + [["color_temp", "hs"], ["color_temp", "rgb"], ["color_temp", "xy"]], ) async def test_light_color_temperature_and_rgb_color( hass, hk_driver, events, supported_color_modes @@ -310,93 +291,190 @@ async def test_light_color_temperature_and_rgb_color( { ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_COLOR_TEMP: 190, - ATTR_BRIGHTNESS: 255, - ATTR_COLOR_MODE: COLOR_MODE_RGB, ATTR_HS_COLOR: (260, 90), }, ) await hass.async_block_till_done() acc = Light(hass, hk_driver, "Light", entity_id, 1, None) - assert acc.char_hue.value == 260 - assert acc.char_saturation.value == 90 - assert acc.char_on_primary.value == 1 - assert acc.char_on_secondary.value == 0 - assert acc.char_brightness_primary.value == 100 - assert acc.char_brightness_secondary.value == 100 - - assert hasattr(acc, "char_color_temperature") - - hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_COLOR_TEMP: 224, - ATTR_COLOR_MODE: COLOR_MODE_COLOR_TEMP, - ATTR_BRIGHTNESS: 127, - }, - ) - await hass.async_block_till_done() - await acc.run() - await hass.async_block_till_done() - assert acc.char_color_temperature.value == 224 - assert acc.char_on_primary.value == 0 - assert acc.char_on_secondary.value == 1 - assert acc.char_brightness_primary.value == 50 - assert acc.char_brightness_secondary.value == 50 - - hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_COLOR_TEMP: 352, - ATTR_COLOR_MODE: COLOR_MODE_COLOR_TEMP, - }, - ) - await hass.async_block_till_done() - await acc.run() - await hass.async_block_till_done() - assert acc.char_color_temperature.value == 352 - assert acc.char_on_primary.value == 0 - assert acc.char_on_secondary.value == 1 hk_driver.add_accessory(acc) + assert acc.char_color_temp.value == 190 + assert acc.char_hue.value == 27 + assert acc.char_saturation.value == 16 + + assert hasattr(acc, "char_color_temp") + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 224}) + await hass.async_block_till_done() + await 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: 352}) + await hass.async_block_till_done() + await 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_temperature_iid = acc.char_color_temperature.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, 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] == 250 + + 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: 145, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_saturation_iid, - HAP_REPR_VALUE: 75, - }, + HAP_REPR_VALUE: 30, + } ] }, "mock_addr", ) - assert acc.char_hue.value == 145 - assert acc.char_saturation.value == 75 + 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_color_temperature_iid, - HAP_REPR_VALUE: 200, - }, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 20, + } ] }, "mock_addr", ) - assert acc.char_color_temperature.value == 200 + 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] == 320 + 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() + await 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 @pytest.mark.parametrize("supported_color_modes", [["hs"], ["rgb"], ["xy"]]) @@ -444,7 +522,7 @@ async def test_light_rgb_color(hass, hk_driver, events, supported_color_modes): }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_HS_COLOR] == (145, 75) @@ -476,13 +554,13 @@ async def test_light_restore(hass, hk_driver, events): hk_driver.add_accessory(acc) assert acc.category == 5 # Lightbulb - assert acc.chars_primary == [] - assert acc.char_on_primary.value == 0 + assert acc.chars == [] + assert acc.char_on.value == 0 acc = Light(hass, hk_driver, "Light", "light.all_info_set", 2, None) assert acc.category == 5 # Lightbulb - assert acc.chars_primary == ["Brightness"] - assert acc.char_on_primary.value == 0 + assert acc.chars == ["Brightness"] + assert acc.char_on.value == 0 async def test_light_set_brightness_and_color(hass, hk_driver, events): @@ -503,19 +581,19 @@ async def test_light_set_brightness_and_color(hass, hk_driver, events): # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # brightness to 100 when turning on a light on a freshly booted up server. - assert acc.char_brightness_primary.value != 0 - char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] - char_brightness_primary_iid = acc.char_brightness_primary.to_HAP()[HAP_REPR_IID] + assert acc.char_brightness.value != 0 + 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] await acc.run() await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 100 + assert acc.char_brightness.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 40 + assert acc.char_brightness.value == 40 hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (4.5, 9.2)}) await hass.async_block_till_done() @@ -528,14 +606,10 @@ async def test_light_set_brightness_and_color(hass, hk_driver, events): 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_on_primary_iid, - HAP_REPR_VALUE: 1, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_primary_iid, + HAP_REPR_IID: char_brightness_iid, HAP_REPR_VALUE: 20, }, { @@ -552,7 +626,7 @@ async def test_light_set_brightness_and_color(hass, hk_driver, events): }, "mock_addr", ) - await hass.async_block_till_done() + 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 @@ -583,22 +657,22 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # brightness to 100 when turning on a light on a freshly booted up server. - assert acc.char_brightness_primary.value != 0 - char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] - char_brightness_primary_iid = acc.char_brightness_primary.to_HAP()[HAP_REPR_IID] - char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID] + assert acc.char_brightness.value != 0 + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] await acc.run() await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 100 + assert acc.char_brightness.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 40 + assert acc.char_brightness.value == 40 hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: (224.14)}) await hass.async_block_till_done() - assert acc.char_color_temperature.value == 224 + assert acc.char_color_temp.value == 224 # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") @@ -606,26 +680,22 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): 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_on_primary_iid, - HAP_REPR_VALUE: 1, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_primary_iid, + HAP_REPR_IID: char_brightness_iid, HAP_REPR_VALUE: 20, }, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_color_temperature_iid, + HAP_REPR_IID: char_color_temp_iid, HAP_REPR_VALUE: 250, }, ] }, "mock_addr", ) - await hass.async_block_till_done() + 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 From 4ae6435a6413932e67ad4492aa8dc3c00dbd0a1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Aug 2021 17:17:49 -0500 Subject: [PATCH 312/903] Avoid increasing yeelight rate limit when the state is already set (#54410) --- homeassistant/components/yeelight/light.py | 35 ++++++- tests/components/yeelight/test_light.py | 112 ++++++++++++++++++++- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index d2ddc92bb8d..b714ddfaba8 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import math import voluptuous as vol import yeelight @@ -576,6 +577,13 @@ class YeelightGenericLight(YeelightEntity, LightEntity): async def async_set_brightness(self, brightness, duration) -> None: """Set bulb brightness.""" if brightness: + if math.floor(self.brightness) == math.floor(brightness): + _LOGGER.debug("brightness already set to: %s", brightness) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return + _LOGGER.debug("Setting brightness: %s", brightness) await self._bulb.async_set_brightness( brightness / 255 * 100, duration=duration, light_type=self.light_type @@ -585,6 +593,13 @@ class YeelightGenericLight(YeelightEntity, LightEntity): async def async_set_hs(self, hs_color, duration) -> None: """Set bulb's color.""" if hs_color and COLOR_MODE_HS in self.supported_color_modes: + if self.color_mode == COLOR_MODE_HS and self.hs_color == hs_color: + _LOGGER.debug("HS already set to: %s", hs_color) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return + _LOGGER.debug("Setting HS: %s", hs_color) await self._bulb.async_set_hsv( hs_color[0], hs_color[1], duration=duration, light_type=self.light_type @@ -594,9 +609,16 @@ class YeelightGenericLight(YeelightEntity, LightEntity): async def async_set_rgb(self, rgb, duration) -> None: """Set bulb's color.""" if rgb and COLOR_MODE_RGB in self.supported_color_modes: + if self.color_mode == COLOR_MODE_RGB and self.rgb_color == rgb: + _LOGGER.debug("RGB already set to: %s", rgb) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return + _LOGGER.debug("Setting RGB: %s", rgb) await self._bulb.async_set_rgb( - rgb[0], rgb[1], rgb[2], duration=duration, light_type=self.light_type + *rgb, duration=duration, light_type=self.light_type ) @_async_cmd @@ -604,7 +626,16 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """Set bulb's color temperature.""" if colortemp and COLOR_MODE_COLOR_TEMP in self.supported_color_modes: temp_in_k = mired_to_kelvin(colortemp) - _LOGGER.debug("Setting color temp: %s K", temp_in_k) + + if ( + self.color_mode == COLOR_MODE_COLOR_TEMP + and self.color_temp == colortemp + ): + _LOGGER.debug("Color temp already set to: %s", temp_in_k) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return await self._bulb.async_set_color_temp( temp_in_k, duration=duration, light_type=self.light_type diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 9a1f632242b..8b7ec154b83 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -1,6 +1,6 @@ """Test the Yeelight light.""" import logging -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch from yeelight import ( BulbException, @@ -19,6 +19,7 @@ from yeelight.main import _MODEL_SPECS from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, @@ -428,6 +429,115 @@ async def test_services(hass: HomeAssistant, caplog): ) +async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant): + """Ensure we suppress state changes that will increase the rate limit when there is no change.""" + mocked_bulb = _mocked_bulb() + properties = {**PROPERTIES} + properties.pop("active_mode") + properties["color_mode"] = "3" # HSV + mocked_bulb.last_properties = properties + mocked_bulb.bulb_type = BulbType.Color + config_entry = MockConfigEntry( + domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} + ) + config_entry.add_to_hass(hass) + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # We use asyncio.create_task now to avoid + # blocking starting so we need to block again + await hass.async_block_till_done() + + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_HS_COLOR: (PROPERTIES["hue"], PROPERTIES["sat"]), + }, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [] + assert mocked_bulb.async_set_rgb.mock_calls == [] + assert mocked_bulb.async_set_color_temp.mock_calls == [] + assert mocked_bulb.async_set_brightness.mock_calls == [] + + mocked_bulb.last_properties["color_mode"] = 1 + rgb = int(PROPERTIES["rgb"]) + blue = rgb & 0xFF + green = (rgb >> 8) & 0xFF + red = (rgb >> 16) & 0xFF + + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_RGB_COLOR: (red, green, blue)}, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [] + assert mocked_bulb.async_set_rgb.mock_calls == [] + assert mocked_bulb.async_set_color_temp.mock_calls == [] + assert mocked_bulb.async_set_brightness.mock_calls == [] + mocked_bulb.async_set_rgb.reset_mock() + + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_BRIGHTNESS_PCT: PROPERTIES["current_brightness"], + }, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [] + assert mocked_bulb.async_set_rgb.mock_calls == [] + assert mocked_bulb.async_set_color_temp.mock_calls == [] + assert mocked_bulb.async_set_brightness.mock_calls == [] + + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP: 250}, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [] + assert mocked_bulb.async_set_rgb.mock_calls == [] + # Should call for the color mode change + assert mocked_bulb.async_set_color_temp.mock_calls == [ + call(4000, duration=350, light_type=ANY) + ] + assert mocked_bulb.async_set_brightness.mock_calls == [] + mocked_bulb.async_set_color_temp.reset_mock() + + mocked_bulb.last_properties["color_mode"] = 2 + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP: 250}, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [] + assert mocked_bulb.async_set_rgb.mock_calls == [] + assert mocked_bulb.async_set_color_temp.mock_calls == [] + assert mocked_bulb.async_set_brightness.mock_calls == [] + + mocked_bulb.last_properties["color_mode"] = 3 + # This last change should generate a call even though + # the color mode is the same since the HSV has changed + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_HS_COLOR: (5, 5)}, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [ + call(5.0, 5.0, duration=350, light_type=ANY) + ] + assert mocked_bulb.async_set_rgb.mock_calls == [] + assert mocked_bulb.async_set_color_temp.mock_calls == [] + assert mocked_bulb.async_set_brightness.mock_calls == [] + + async def test_device_types(hass: HomeAssistant, caplog): """Test different device types.""" mocked_bulb = _mocked_bulb() From 390023a576d81ef67f1c52524b4d1df9f25884a9 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 11 Aug 2021 00:18:57 +0000 Subject: [PATCH 313/903] [ci skip] Translation update --- .../components/deconz/translations/da.json | 2 +- .../emulated_roku/translations/lt.json | 11 +++++++ .../components/hangouts/translations/lt.json | 12 ++++++++ .../homekit_controller/translations/lt.json | 9 ++++++ .../components/hue/translations/da.json | 10 +++---- .../components/hue/translations/lt.json | 11 +++++++ .../components/mqtt/translations/lt.json | 16 ++++++++++ .../components/nest/translations/da.json | 2 +- .../components/nest/translations/lt.json | 3 ++ .../components/openuv/translations/lt.json | 14 +++++++++ .../components/tractive/translations/cs.json | 19 ++++++++++++ .../components/tractive/translations/fr.json | 19 ++++++++++++ .../uptimerobot/translations/cs.json | 11 ++++++- .../uptimerobot/translations/de.json | 13 ++++++++- .../uptimerobot/translations/fr.json | 29 +++++++++++++++++++ .../uptimerobot/translations/he.json | 7 +++++ .../uptimerobot/translations/hu.json | 13 ++++++++- 17 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/emulated_roku/translations/lt.json create mode 100644 homeassistant/components/hangouts/translations/lt.json create mode 100644 homeassistant/components/homekit_controller/translations/lt.json create mode 100644 homeassistant/components/hue/translations/lt.json create mode 100644 homeassistant/components/mqtt/translations/lt.json create mode 100644 homeassistant/components/openuv/translations/lt.json create mode 100644 homeassistant/components/tractive/translations/cs.json create mode 100644 homeassistant/components/tractive/translations/fr.json create mode 100644 homeassistant/components/uptimerobot/translations/fr.json diff --git a/homeassistant/components/deconz/translations/da.json b/homeassistant/components/deconz/translations/da.json index be165a206bf..00e054aecc9 100644 --- a/homeassistant/components/deconz/translations/da.json +++ b/homeassistant/components/deconz/translations/da.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Bridge er allerede konfigureret", "already_in_progress": "Konfigurationsflow for bro er allerede i gang.", - "no_bridges": "Ingen deConz-bridge fundet", + "no_bridges": "Ingen deConz-bro fundet", "not_deconz_bridge": "Ikke en deCONZ-bro", "updated_instance": "Opdaterede deCONZ-instans med ny v\u00e6rtadresse" }, diff --git a/homeassistant/components/emulated_roku/translations/lt.json b/homeassistant/components/emulated_roku/translations/lt.json new file mode 100644 index 00000000000..8ae517ecfbe --- /dev/null +++ b/homeassistant/components/emulated_roku/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host_ip": "Hosto IP adresas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/translations/lt.json b/homeassistant/components/hangouts/translations/lt.json new file mode 100644 index 00000000000..13dbbf8bdbc --- /dev/null +++ b/homeassistant/components/hangouts/translations/lt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "2fa": { + "data": { + "2fa": "2FA PIN" + }, + "title": "2 veiksni\u0173 autentifikavimas" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/translations/lt.json b/homeassistant/components/homekit_controller/translations/lt.json new file mode 100644 index 00000000000..965b32b366d --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/lt.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "\u012erenginio pasirinkimas" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/translations/da.json b/homeassistant/components/hue/translations/da.json index 031076172ac..f081a912dd7 100644 --- a/homeassistant/components/hue/translations/da.json +++ b/homeassistant/components/hue/translations/da.json @@ -2,22 +2,22 @@ "config": { "abort": { "all_configured": "Alle Philips Hue-broer er allerede konfigureret", - "already_configured": "Bridgen er allerede konfigureret", + "already_configured": "Enhed er allerede konfigureret", "already_in_progress": "Bro-konfiguration er allerede i gang.", - "cannot_connect": "Kunne ikke oprette forbindelse til bridgen", + "cannot_connect": "Kunne ikke oprette forbindelse", "discover_timeout": "Ingen Philips Hue-bro fundet", "no_bridges": "Ingen Philips Hue-broer fundet", "not_hue_bridge": "Ikke en Hue-bro", - "unknown": "Ukendt fejl opstod" + "unknown": "Uventet fejl" }, "error": { - "linking": "Der opstod en ukendt linkfejl.", + "linking": "Der opstod en uventet fejl", "register_failed": "Det lykkedes ikke at registrere, pr\u00f8v igen" }, "step": { "init": { "data": { - "host": "V\u00e6rt" + "host": "Server" }, "title": "V\u00e6lg Hue bridge" }, diff --git a/homeassistant/components/hue/translations/lt.json b/homeassistant/components/hue/translations/lt.json new file mode 100644 index 00000000000..1e12894085b --- /dev/null +++ b/homeassistant/components/hue/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "init": { + "data": { + "host": "Hostas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/translations/lt.json b/homeassistant/components/mqtt/translations/lt.json new file mode 100644 index 00000000000..35257770c75 --- /dev/null +++ b/homeassistant/components/mqtt/translations/lt.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepavyko prisijungti" + }, + "step": { + "broker": { + "data": { + "password": "Slapta\u017eodis", + "port": "Portas", + "username": "Prisijungimo vardas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/translations/da.json b/homeassistant/components/nest/translations/da.json index 054b4442506..5224e7a660d 100644 --- a/homeassistant/components/nest/translations/da.json +++ b/homeassistant/components/nest/translations/da.json @@ -14,7 +14,7 @@ "flow_impl": "Udbyder" }, "description": "V\u00e6lg hvilken godkendelsesudbyder du vil godkende med Nest.", - "title": "Godkendelsesudbyder" + "title": "Identitetsudbyder" }, "link": { "data": { diff --git a/homeassistant/components/nest/translations/lt.json b/homeassistant/components/nest/translations/lt.json index 3cac49e3871..629b65d347d 100644 --- a/homeassistant/components/nest/translations/lt.json +++ b/homeassistant/components/nest/translations/lt.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Nenumatyta klaida" + }, "step": { "link": { "data": { diff --git a/homeassistant/components/openuv/translations/lt.json b/homeassistant/components/openuv/translations/lt.json new file mode 100644 index 00000000000..1546651d54a --- /dev/null +++ b/homeassistant/components/openuv/translations/lt.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "invalid_api_key": "Neteisingas API raktas" + }, + "step": { + "user": { + "data": { + "api_key": "API raktas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/cs.json b/homeassistant/components/tractive/translations/cs.json new file mode 100644 index 00000000000..de52bfbd7a8 --- /dev/null +++ b/homeassistant/components/tractive/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/fr.json b/homeassistant/components/tractive/translations/fr.json new file mode 100644 index 00000000000..1d3c15c13d5 --- /dev/null +++ b/homeassistant/components/tractive/translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositif d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "email": "Adresse mail", + "password": "Mot de passe" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/cs.json b/homeassistant/components/uptimerobot/translations/cs.json index 7261d6146fb..dc5ccb72741 100644 --- a/homeassistant/components/uptimerobot/translations/cs.json +++ b/homeassistant/components/uptimerobot/translations/cs.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, "error": { "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_api_key": "Neplatn\u00fd kl\u00ed\u010d API", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Kl\u00ed\u010d API" + }, + "title": "Znovu ov\u011b\u0159it integraci" + }, "user": { "data": { "api_key": "Kl\u00ed\u010d API" diff --git a/homeassistant/components/uptimerobot/translations/de.json b/homeassistant/components/uptimerobot/translations/de.json index 7a50a5ba28e..a25f58dfe0c 100644 --- a/homeassistant/components/uptimerobot/translations/de.json +++ b/homeassistant/components/uptimerobot/translations/de.json @@ -2,18 +2,29 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_failed_existing": "Der Konfigurationseintrag konnte nicht aktualisiert werden. Bitte entferne die Integration und richte sie erneut ein.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel", + "reauth_failed_matching_account": "Der von dir angegebene API-Schl\u00fcssel stimmt nicht mit der Konto-ID f\u00fcr die vorhandene Konfiguration \u00fcberein.", "unknown": "Unerwarteter Fehler" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API-Schl\u00fcssel" + }, + "description": "Du musst einen neuen schreibgesch\u00fctzten API-Schl\u00fcssel von Uptime Robot bereitstellen.", + "title": "Integration erneut authentifizieren" + }, "user": { "data": { "api_key": "API-Schl\u00fcssel" - } + }, + "description": "Du musst einen schreibgesch\u00fctzten API-Schl\u00fcssel von Uptime Robot bereitstellen." } } } diff --git a/homeassistant/components/uptimerobot/translations/fr.json b/homeassistant/components/uptimerobot/translations/fr.json new file mode 100644 index 00000000000..2b4322bb410 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/fr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "reauth_failed_existing": "Impossible de mettre \u00e0 jour l'entr\u00e9e de configuration, veuillez supprimer l'int\u00e9gration et la configurer \u00e0 nouveau.", + "unknown": "Erreur inattendue" + }, + "error": { + "cannot_connect": "Echec de la connexion", + "invalid_api_key": "Cl\u00e9 API non valide", + "reauth_failed_matching_account": "La cl\u00e9 API que vous avez fournie ne correspond pas \u00e0 l\u2019ID de compte pour la configuration existante.", + "unknown": "Erreur inattendue" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Cl\u00e9 API" + }, + "description": "Vous devez fournir une nouvelle cl\u00e9 API en lecture seule \u00e0 partir d'Uptime Robot" + }, + "user": { + "data": { + "api_key": "Cl\u00e9 API" + }, + "description": "Vous devez fournir une cl\u00e9 API en lecture seule \u00e0 partir d'Uptime Robot" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/he.json b/homeassistant/components/uptimerobot/translations/he.json index 1a45e5c78cd..07de294aea4 100644 --- a/homeassistant/components/uptimerobot/translations/he.json +++ b/homeassistant/components/uptimerobot/translations/he.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { @@ -10,6 +11,12 @@ "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, "user": { "data": { "api_key": "\u05de\u05e4\u05ea\u05d7 API" diff --git a/homeassistant/components/uptimerobot/translations/hu.json b/homeassistant/components/uptimerobot/translations/hu.json index b9e14001679..000851093a5 100644 --- a/homeassistant/components/uptimerobot/translations/hu.json +++ b/homeassistant/components/uptimerobot/translations/hu.json @@ -2,18 +2,29 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_failed_existing": "Nem siker\u00fclt friss\u00edteni a konfigur\u00e1ci\u00f3s bejegyz\u00e9st. K\u00e9rj\u00fck, t\u00e1vol\u00edtsa el az integr\u00e1ci\u00f3t, \u00e9s \u00e1ll\u00edtsa be \u00fajra.", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt", "unknown": "V\u00e1ratlan hiba" }, "error": { "cannot_connect": "Nem siker\u00fclt csatlakozni", "invalid_api_key": "\u00c9rv\u00e9nytelen API-kulcs", + "reauth_failed_matching_account": "A megadott API -kulcs nem egyezik a megl\u00e9v\u0151 konfigur\u00e1ci\u00f3 fi\u00f3kazonos\u00edt\u00f3j\u00e1val.", "unknown": "V\u00e1ratlan hiba" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API kulcs" + }, + "description": "Meg kell adnia egy \u00faj, csak olvashat\u00f3 API-kulcsot az Uptime Robot-t\u00f3l", + "title": "Integr\u00e1ci\u00f3 \u00fajb\u00f3li hiteles\u00edt\u00e9se" + }, "user": { "data": { "api_key": "API kulcs" - } + }, + "description": "Meg kell adnia egy csak olvashat\u00f3 API-kulcsot az Uptime Robot-t\u00f3l" } } } From e99576c0947e710fa40967458c3c01ed1ba20872 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Aug 2021 19:33:06 -0500 Subject: [PATCH 314/903] Pass width and height when requesting camera snapshot (#53835) --- homeassistant/components/abode/camera.py | 6 +- homeassistant/components/amcrest/camera.py | 6 +- homeassistant/components/arlo/camera.py | 6 +- homeassistant/components/august/camera.py | 5 +- homeassistant/components/blink/camera.py | 6 +- homeassistant/components/bloomsky/camera.py | 6 +- homeassistant/components/buienradar/camera.py | 4 +- homeassistant/components/camera/__init__.py | 108 +++++++++++++++--- .../{homekit => camera}/img_util.py | 34 ++++-- homeassistant/components/camera/manifest.json | 1 + homeassistant/components/canary/camera.py | 4 +- homeassistant/components/demo/camera.py | 6 +- homeassistant/components/doorbird/camera.py | 6 +- .../components/environment_canada/camera.py | 6 +- homeassistant/components/esphome/camera.py | 4 +- homeassistant/components/ezviz/camera.py | 4 +- homeassistant/components/familyhub/camera.py | 6 +- homeassistant/components/ffmpeg/camera.py | 5 +- homeassistant/components/foscam/camera.py | 6 +- homeassistant/components/generic/camera.py | 10 +- .../components/homekit/manifest.json | 3 +- .../components/homekit/type_cameras.py | 10 +- .../components/homekit_controller/camera.py | 10 +- homeassistant/components/hyperion/camera.py | 4 +- homeassistant/components/local_file/camera.py | 7 +- .../components/logi_circle/camera.py | 6 +- homeassistant/components/mjpeg/camera.py | 16 ++- homeassistant/components/mqtt/camera.py | 6 +- homeassistant/components/neato/camera.py | 4 +- homeassistant/components/nest/camera_sdm.py | 4 +- .../components/nest/legacy/camera.py | 6 +- homeassistant/components/netatmo/camera.py | 8 +- homeassistant/components/onvif/camera.py | 6 +- homeassistant/components/proxy/camera.py | 16 ++- homeassistant/components/push/camera.py | 6 +- homeassistant/components/qvr_pro/camera.py | 5 +- homeassistant/components/ring/camera.py | 6 +- homeassistant/components/rpi_camera/camera.py | 6 +- homeassistant/components/skybell/camera.py | 6 +- .../components/synology_dsm/camera.py | 4 +- homeassistant/components/uvc/camera.py | 6 +- homeassistant/components/verisure/camera.py | 4 +- homeassistant/components/vivotek/camera.py | 6 +- homeassistant/components/xeoma/camera.py | 6 +- homeassistant/components/xiaomi/camera.py | 6 +- homeassistant/components/yi/camera.py | 6 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/camera/common.py | 17 +++ .../{homekit => camera}/test_img_util.py | 30 ++++- tests/components/camera/test_init.py | 47 ++++++++ tests/components/homekit/common.py | 17 --- tests/components/homekit/test_type_cameras.py | 4 +- 53 files changed, 418 insertions(+), 113 deletions(-) rename homeassistant/components/{homekit => camera}/img_util.py (72%) rename tests/components/{homekit => camera}/test_img_util.py (67%) delete mode 100644 tests/components/homekit/common.py diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index 99d4fd433a7..987e32f9911 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -1,4 +1,6 @@ """Support for Abode Security System cameras.""" +from __future__ import annotations + from datetime import timedelta import abodepy.helpers.constants as CONST @@ -73,7 +75,9 @@ class AbodeCamera(AbodeDevice, Camera): else: self._response = None - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Get a camera image.""" self.refresh_image() diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 5c7f8acf94a..1478c658d18 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,4 +1,6 @@ """Support for Amcrest IP cameras.""" +from __future__ import annotations + import asyncio from datetime import timedelta from functools import partial @@ -181,7 +183,9 @@ class AmcrestCam(Camera): finally: self._snapshot_task = None - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" _LOGGER.debug("Take snapshot from %s", self._name) try: diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index 87c6216e56d..6b14f0cee0c 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -1,4 +1,6 @@ """Support for Netgear Arlo IP cameras.""" +from __future__ import annotations + import logging from haffmpeg.camera import CameraMjpeg @@ -62,7 +64,9 @@ class ArloCam(Camera): self._last_refresh = None self.attrs = {} - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return self._camera.last_image_from_cache diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 6bb47a06eee..6f9ecf1b182 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,4 +1,5 @@ """Support for August doorbell camera.""" +from __future__ import annotations from yalexs.activity import ActivityType from yalexs.util import update_doorbell_image_from_activity @@ -68,7 +69,9 @@ class AugustCamera(AugustEntityMixin, Camera): if doorbell_activity is not None: update_doorbell_image_from_activity(self._detail, doorbell_activity) - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" self._update_from_data() diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index e2216dc8785..8b4f1ba4eec 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -1,4 +1,6 @@ """Support for Blink system camera.""" +from __future__ import annotations + import logging from homeassistant.components.camera import Camera @@ -65,6 +67,8 @@ class BlinkCamera(Camera): self._camera.snap_picture() self.data.refresh() - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return self._camera.image_from_cache.content diff --git a/homeassistant/components/bloomsky/camera.py b/homeassistant/components/bloomsky/camera.py index 570842b9c66..a7255a74d4c 100644 --- a/homeassistant/components/bloomsky/camera.py +++ b/homeassistant/components/bloomsky/camera.py @@ -1,4 +1,6 @@ """Support for a camera of a BloomSky weather station.""" +from __future__ import annotations + import logging import requests @@ -37,7 +39,9 @@ class BloomSkyCamera(Camera): self._logger = logging.getLogger(__name__) self._attr_unique_id = self._id - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Update the camera's image if it has changed.""" try: self._url = self._bloomsky.devices[self._id]["Data"]["ImageURL"] diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 34f1f173319..91e4bcffb17 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -143,7 +143,9 @@ class BuienradarCam(Camera): _LOGGER.error("Failed to fetch image, %s", type(err)) return False - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """ Return a still image response from the camera. diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index d1f354cc78e..c6cada2e3c9 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -8,7 +8,9 @@ from collections.abc import Awaitable, Mapping from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta +from functools import partial import hashlib +import inspect import logging import os from random import SystemRandom @@ -62,6 +64,7 @@ from .const import ( DOMAIN, SERVICE_RECORD, ) +from .img_util import scale_jpeg_camera_image from .prefs import CameraPreferences # mypy: allow-untyped-calls @@ -138,23 +141,72 @@ async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> return await _async_stream_endpoint_url(hass, camera, fmt) -@bind_hass -async def async_get_image( - hass: HomeAssistant, entity_id: str, timeout: int = 10 +async def _async_get_image( + camera: Camera, + timeout: int = 10, + width: int | None = None, + height: int | None = None, ) -> Image: - """Fetch an image from a camera entity.""" - camera = _get_camera_from_entity_id(hass, entity_id) + """Fetch a snapshot image from a camera. + If width and height are passed, an attempt to scale + the image will be made on a best effort basis. + Not all cameras can scale images or return jpegs + that we can scale, however the majority of cases + are handled. + """ with suppress(asyncio.CancelledError, asyncio.TimeoutError): async with async_timeout.timeout(timeout): - image = await camera.async_camera_image() + # Calling inspect will be removed in 2022.1 after all + # custom components have had a chance to change their signature + sig = inspect.signature(camera.async_camera_image) + if "height" in sig.parameters and "width" in sig.parameters: + image_bytes = await camera.async_camera_image( + width=width, height=height + ) + else: + _LOGGER.warning( + "The camera entity %s does not support requesting width and height, please open an issue with the integration author", + camera.entity_id, + ) + image_bytes = await camera.async_camera_image() - if image: - return Image(camera.content_type, image) + if image_bytes: + content_type = camera.content_type + image = Image(content_type, image_bytes) + if ( + width is not None + and height is not None + and "jpeg" in content_type + or "jpg" in content_type + ): + assert width is not None + assert height is not None + return Image( + content_type, scale_jpeg_camera_image(image, width, height) + ) + + return image raise HomeAssistantError("Unable to get image") +@bind_hass +async def async_get_image( + hass: HomeAssistant, + entity_id: str, + timeout: int = 10, + width: int | None = None, + height: int | None = None, +) -> Image: + """Fetch an image from a camera entity. + + width and height will be passed to the underlying camera. + """ + camera = _get_camera_from_entity_id(hass, entity_id) + return await _async_get_image(camera, timeout, width, height) + + @bind_hass async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None: """Fetch the stream source for a camera entity.""" @@ -387,12 +439,27 @@ class Camera(Entity): """Return the source of the stream.""" return None - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" raise NotImplementedError() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" + sig = inspect.signature(self.camera_image) + # Calling inspect will be removed in 2022.1 after all + # custom components have had a chance to change their signature + if "height" in sig.parameters and "width" in sig.parameters: + return await self.hass.async_add_executor_job( + partial(self.camera_image, width=width, height=height) + ) + _LOGGER.warning( + "The camera entity %s does not support requesting width and height, please open an issue with the integration author", + self.entity_id, + ) return await self.hass.async_add_executor_job(self.camera_image) async def handle_async_still_stream( @@ -529,14 +596,19 @@ class CameraImageView(CameraView): async def handle(self, request: web.Request, camera: Camera) -> web.Response: """Serve camera image.""" - with suppress(asyncio.CancelledError, asyncio.TimeoutError): - async with async_timeout.timeout(CAMERA_IMAGE_TIMEOUT): - image = await camera.async_camera_image() - - if image: - return web.Response(body=image, content_type=camera.content_type) - - raise web.HTTPInternalServerError() + width = request.query.get("width") + height = request.query.get("height") + try: + image = await _async_get_image( + camera, + CAMERA_IMAGE_TIMEOUT, + int(width) if width else None, + int(height) if height else None, + ) + except (HomeAssistantError, ValueError) as ex: + raise web.HTTPInternalServerError() from ex + else: + return web.Response(body=image.content, content_type=image.content_type) class CameraMjpegStream(CameraView): diff --git a/homeassistant/components/homekit/img_util.py b/homeassistant/components/camera/img_util.py similarity index 72% rename from homeassistant/components/homekit/img_util.py rename to homeassistant/components/camera/img_util.py index 7d7a45081a6..4cfb4fda278 100644 --- a/homeassistant/components/homekit/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -1,19 +1,32 @@ -"""Image processing for HomeKit component.""" +"""Image processing for cameras.""" import logging +from typing import TYPE_CHECKING, cast SUPPORTED_SCALING_FACTORS = [(7, 8), (3, 4), (5, 8), (1, 2), (3, 8), (1, 4), (1, 8)] _LOGGER = logging.getLogger(__name__) +JPEG_QUALITY = 75 -def scale_jpeg_camera_image(cam_image, width, height): +if TYPE_CHECKING: + from turbojpeg import TurboJPEG + + from . import Image + + +def scale_jpeg_camera_image(cam_image: "Image", width: int, height: int) -> bytes: """Scale a camera image as close as possible to one of the supported scaling factors.""" turbo_jpeg = TurboJPEGSingleton.instance() if not turbo_jpeg: return cam_image.content - (current_width, current_height, _, _) = turbo_jpeg.decode_header(cam_image.content) + try: + (current_width, current_height, _, _) = turbo_jpeg.decode_header( + cam_image.content + ) + except OSError: + return cam_image.content if current_width <= width or current_height <= height: return cam_image.content @@ -26,10 +39,13 @@ def scale_jpeg_camera_image(cam_image, width, height): scaling_factor = supported_sf break - return turbo_jpeg.scale_with_quality( - cam_image.content, - scaling_factor=scaling_factor, - quality=75, + return cast( + bytes, + turbo_jpeg.scale_with_quality( + cam_image.content, + scaling_factor=scaling_factor, + quality=JPEG_QUALITY, + ), ) @@ -45,13 +61,13 @@ class TurboJPEGSingleton: __instance = None @staticmethod - def instance(): + def instance() -> "TurboJPEG": """Singleton for TurboJPEG.""" if TurboJPEGSingleton.__instance is None: TurboJPEGSingleton() return TurboJPEGSingleton.__instance - def __init__(self): + def __init__(self) -> None: """Try to create TurboJPEG only once.""" # pylint: disable=unused-private-member # https://github.com/PyCQA/pylint/issues/4681 diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index ed8e10c1956..6a27999c7fe 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -3,6 +3,7 @@ "name": "Camera", "documentation": "https://www.home-assistant.io/integrations/camera", "dependencies": ["http"], + "requirements": ["PyTurboJPEG==1.5.0"], "after_dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 2699ba1f640..7a2d22c2406 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -123,7 +123,9 @@ class CanaryCamera(CoordinatorEntity, Camera): """Return the camera motion detection status.""" return not self.location.is_recording - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" await self.hass.async_add_executor_job(self.renew_live_stream_session) live_stream_url = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 56726bba8b7..b3f9b505aee 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -1,4 +1,6 @@ """Demo camera platform that has a fake camera.""" +from __future__ import annotations + from pathlib import Path from homeassistant.components.camera import SUPPORT_ON_OFF, Camera @@ -25,7 +27,9 @@ class DemoCamera(Camera): self.is_streaming = True self._images_index = 0 - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes: """Return a faked still image response.""" self._images_index = (self._images_index + 1) % 4 image_path = Path(__file__).parent / f"demo_{self._images_index}.jpg" diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 53fcdbcee70..16606156314 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -1,4 +1,6 @@ """Support for viewing the camera feed from a DoorBird video doorbell.""" +from __future__ import annotations + import asyncio import datetime import logging @@ -112,7 +114,9 @@ class DoorBirdCamera(DoorBirdEntity, Camera): """Get the name of the camera.""" return self._name - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Pull a still image from the camera.""" now = dt_util.utcnow() diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 019dcb1aee5..ecd0c562d16 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -1,4 +1,6 @@ """Support for the Environment Canada radar imagery.""" +from __future__ import annotations + import datetime from env_canada import ECRadar @@ -68,7 +70,9 @@ class ECCamera(Camera): self.image = None self.timestamp = None - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" self.update() return self.image diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 938d78362f7..47010324290 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -50,7 +50,9 @@ class EsphomeCamera(Camera, EsphomeEntity[CameraInfo, CameraState]): async with self._image_cond: self._image_cond.notify_all() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return single camera image bytes.""" if not self.available: return None diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 76fbaee3757..4e5fdb90c79 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -325,7 +325,9 @@ class EzvizCamera(CoordinatorEntity, Camera): """Return the name of this camera.""" return self._serial - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a frame from the camera stream.""" ffmpeg = ImageFrame(self._ffmpeg.binary) diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py index ea654074a5a..65b7a63e419 100644 --- a/homeassistant/components/familyhub/camera.py +++ b/homeassistant/components/familyhub/camera.py @@ -1,4 +1,6 @@ """Family Hub camera for Samsung Refrigerators.""" +from __future__ import annotations + from pyfamilyhublocal import FamilyHubCam import voluptuous as vol @@ -38,7 +40,9 @@ class FamilyHubCamera(Camera): self._name = name self.family_hub_cam = family_hub_cam - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response.""" return await self.family_hub_cam.async_get_cam_image() diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index 4cd8b0d1453..323eae7c129 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -1,4 +1,5 @@ """Support for Cameras with FFmpeg as decoder.""" +from __future__ import annotations from haffmpeg.camera import CameraMjpeg from haffmpeg.tools import IMAGE_JPEG @@ -49,7 +50,9 @@ class FFmpegCamera(Camera): """Return the stream source.""" return self._input.split(" ")[-1] - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return await async_get_image( self.hass, diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 31ac8c2cad9..7a1e1037ddb 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -1,4 +1,6 @@ """This component provides basic support for Foscam IP cameras.""" +from __future__ import annotations + import asyncio from libpyfoscam import FoscamCamera @@ -172,7 +174,9 @@ class HassFoscamCamera(Camera): """Return the entity unique ID.""" return self._unique_id - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data # Handle exception if host is not reachable or url failed diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 56b490e165a..b6e08ea8582 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,4 +1,6 @@ """Support for IP Cameras.""" +from __future__ import annotations + import asyncio import logging @@ -118,13 +120,17 @@ class GenericCamera(Camera): """Return the interval between frames of the mjpeg stream.""" return self._frame_interval - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" return asyncio.run_coroutine_threadsafe( self.async_camera_image(), self.hass.loop ).result() - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: url = self._still_image_url.async_render(parse_result=False) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 187d730de2f..c63ce2a8927 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -6,8 +6,7 @@ "HAP-python==4.0.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", - "base36==0.1.1", - "PyTurboJPEG==1.5.0" + "base36==0.1.1" ], "dependencies": ["http", "camera", "ffmpeg", "network"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 077366870e2..4a8999ede08 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -55,7 +55,6 @@ from .const import ( SERV_SPEAKER, SERV_STATELESS_PROGRAMMABLE_SWITCH, ) -from .img_util import scale_jpeg_camera_image from .util import pid_is_alive _LOGGER = logging.getLogger(__name__) @@ -467,8 +466,9 @@ class Camera(HomeAccessory, PyhapCamera): async def async_get_snapshot(self, image_size): """Return a jpeg of a snapshot from the camera.""" - return scale_jpeg_camera_image( - await self.hass.components.camera.async_get_image(self.entity_id), - image_size["image-width"], - image_size["image-height"], + image = await self.hass.components.camera.async_get_image( + self.entity_id, + width=image_size["image-width"], + height=image_size["image-height"], ) + return image.content diff --git a/homeassistant/components/homekit_controller/camera.py b/homeassistant/components/homekit_controller/camera.py index fc6a5bb4522..a0b15087356 100644 --- a/homeassistant/components/homekit_controller/camera.py +++ b/homeassistant/components/homekit_controller/camera.py @@ -1,4 +1,6 @@ """Support for Homekit cameras.""" +from __future__ import annotations + from aiohomekit.model.services import ServicesTypes from homeassistant.components.camera import Camera @@ -21,12 +23,14 @@ class HomeKitCamera(AccessoryEntity, Camera): """Return the current state of the camera.""" return "idle" - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a jpeg with the current camera snapshot.""" return await self._accessory.pairing.image( self._aid, - 640, - 480, + width or 640, + height or 480, ) diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 22134400a45..809449543af 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -210,7 +210,9 @@ class HyperionCamera(Camera): finally: await self._stop_image_streaming_for_client() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return single camera image bytes.""" async with self._image_streaming() as is_streaming: if is_streaming: diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index 86a075c1a14..6e665ccd1c2 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -1,4 +1,6 @@ """Camera that loads a picture from a local file.""" +from __future__ import annotations + import logging import mimetypes import os @@ -73,7 +75,9 @@ class LocalFile(Camera): if content is not None: self.content_type = content - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" try: with open(self._file_path, "rb") as file: @@ -84,6 +88,7 @@ class LocalFile(Camera): self._name, self._file_path, ) + return None def check_file_path_access(self, file_path): """Check that filepath given is readable.""" diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 1afeb190c8b..30407f03ecf 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -1,4 +1,6 @@ """Support to the Logi Circle cameras.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -142,7 +144,9 @@ class LogiCam(Camera): return state - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image from the camera.""" return await self._camera.live_stream.download_jpeg() diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index d5008d1778c..d486f78d334 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -1,4 +1,6 @@ """Support for IP Cameras.""" +from __future__ import annotations + import asyncio from contextlib import closing import logging @@ -106,7 +108,9 @@ class MjpegCamera(Camera): self._auth = aiohttp.BasicAuth(self._username, password=self._password) self._verify_ssl = device_info.get(CONF_VERIFY_SSL) - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" # DigestAuth is not supported if ( @@ -130,11 +134,17 @@ class MjpegCamera(Camera): except aiohttp.ClientError as err: _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) - def camera_image(self): + return None + + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" if self._username and self._password: if self._authentication == HTTP_DIGEST_AUTHENTICATION: - auth = HTTPDigestAuth(self._username, self._password) + auth: HTTPDigestAuth | HTTPBasicAuth = HTTPDigestAuth( + self._username, self._password + ) else: auth = HTTPBasicAuth(self._username, self._password) req = requests.get( diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index adcb9ca623a..ebd6956e8fd 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -1,4 +1,6 @@ """Camera that loads a picture from an MQTT topic.""" +from __future__ import annotations + import functools import voluptuous as vol @@ -98,6 +100,8 @@ class MqttCamera(MqttEntity, Camera): }, ) - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" return self._last_image diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index b6def2cfe38..392d586068d 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -66,7 +66,9 @@ class NeatoCleaningMap(Camera): self._image_url: str | None = None self._image: bytes | None = None - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" self.update() return self._image diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 5f5fdbc8d93..242c6147201 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -180,7 +180,9 @@ class NestCamera(Camera): self._device.add_update_listener(self.async_write_ha_state) ) - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" # Returns the snapshot of the last event for ~30 seconds after the event active_event_image = await self._async_active_event_image() diff --git a/homeassistant/components/nest/legacy/camera.py b/homeassistant/components/nest/legacy/camera.py index 77629e4dcff..3ef0089d2bc 100644 --- a/homeassistant/components/nest/legacy/camera.py +++ b/homeassistant/components/nest/legacy/camera.py @@ -1,4 +1,6 @@ """Support for Nest Cameras.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -131,7 +133,9 @@ class NestCamera(Camera): def _ready_for_snapshot(self, now): return self._next_snapshot_at is None or now > self._next_snapshot_at - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" now = utcnow() if self._ready_for_snapshot(now): diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 32d0eb46286..4d6141e2dfb 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -194,10 +194,14 @@ class NetatmoCamera(NetatmoBase, Camera): self.data_handler.data[self._data_classes[0]["name"]], ) - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: - return await self._data.async_get_live_snapshot(camera_id=self._id) + return cast( + bytes, await self._data.async_get_live_snapshot(camera_id=self._id) + ) except ( aiohttp.ClientPayloadError, aiohttp.ContentTypeError, diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 0e95d24ef78..4d80231df23 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,4 +1,6 @@ """Support for ONVIF Cameras with FFmpeg as decoder.""" +from __future__ import annotations + import asyncio from haffmpeg.camera import CameraMjpeg @@ -120,7 +122,9 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): """Return the stream source.""" return self._stream_uri - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" image = None diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index 8fda507ace2..3c296b7d164 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -1,4 +1,6 @@ """Proxy camera platform that enables image processing of camera data.""" +from __future__ import annotations + import asyncio from datetime import timedelta import io @@ -219,13 +221,17 @@ class ProxyCamera(Camera): self._last_image = None self._mode = config.get(CONF_MODE) - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return camera image.""" return asyncio.run_coroutine_threadsafe( self.async_camera_image(), self.hass.loop ).result() - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" now = dt_util.utcnow() @@ -244,13 +250,13 @@ class ProxyCamera(Camera): job = _resize_image else: job = _crop_image - image = await self.hass.async_add_executor_job( + image_bytes: bytes = await self.hass.async_add_executor_job( job, image.content, self._image_opts ) if self._cache_images: - self._last_image = image - return image + self._last_image = image_bytes + return image_bytes async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from camera images.""" diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index ff0ac45c139..8f4d1d04dcf 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -1,4 +1,6 @@ """Camera platform that receives images through HTTP POST.""" +from __future__ import annotations + import asyncio from collections import deque from datetime import timedelta @@ -155,7 +157,9 @@ class PushCamera(Camera): self.async_write_ha_state() - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response.""" if self.queue: if self._state == STATE_IDLE: diff --git a/homeassistant/components/qvr_pro/camera.py b/homeassistant/components/qvr_pro/camera.py index 2f4353063d1..cac288eaef0 100644 --- a/homeassistant/components/qvr_pro/camera.py +++ b/homeassistant/components/qvr_pro/camera.py @@ -1,4 +1,5 @@ """Support for QVR Pro streams.""" +from __future__ import annotations import logging @@ -88,7 +89,9 @@ class QVRProCamera(Camera): return attrs - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Get image bytes from camera.""" try: return self._client.get_snapshot(self.guid) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 580fc71e141..77317d62ab3 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -1,4 +1,6 @@ """This component provides support to the Ring Door Bell camera.""" +from __future__ import annotations + import asyncio from datetime import timedelta from itertools import chain @@ -101,7 +103,9 @@ class RingCam(RingEntityMixin, Camera): "last_video_id": self._last_video_id, } - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" ffmpeg = ImageFrame(self._ffmpeg.binary) diff --git a/homeassistant/components/rpi_camera/camera.py b/homeassistant/components/rpi_camera/camera.py index 070e861b3c9..980586d4def 100644 --- a/homeassistant/components/rpi_camera/camera.py +++ b/homeassistant/components/rpi_camera/camera.py @@ -1,4 +1,6 @@ """Camera platform that has a Raspberry Pi camera.""" +from __future__ import annotations + import logging import os import shutil @@ -122,7 +124,9 @@ class RaspberryCamera(Camera): ): pass - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return raspistill image response.""" with open(self._config[CONF_FILE_PATH], "rb") as file: return file.read() diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index 87dc3c0bf8d..20e93fb90f7 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -1,4 +1,6 @@ """Camera support for the Skybell HD Doorbell.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -75,7 +77,9 @@ class SkybellCamera(SkybellDevice, Camera): return self._device.activity_image return self._device.image - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Get the latest camera image.""" super().update() diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 8341b8b121a..d609a434ae2 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -123,7 +123,9 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): """Return the camera motion detection status.""" return self.camera_data.is_motion_detection_enabled # type: ignore[no-any-return] - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" _LOGGER.debug( "SynoDSMCamera.camera_image(%s)", diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index 74bf175f75a..77ff6a30f95 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -194,10 +194,12 @@ class UnifiVideoCamera(Camera): self._caminfo = caminfo return True - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return the image of this camera.""" if not self._camera and not self._login(): - return + return None def _get_image(retry=True): try: diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index a137f61d98f..455d7070a8b 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -79,7 +79,9 @@ class VerisureSmartcam(CoordinatorEntity, Camera): "via_device": (DOMAIN, self.coordinator.entry.data[CONF_GIID]), } - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" self.check_imagelist() if not self._image: diff --git a/homeassistant/components/vivotek/camera.py b/homeassistant/components/vivotek/camera.py index 953d64f0ff6..b813d337e82 100644 --- a/homeassistant/components/vivotek/camera.py +++ b/homeassistant/components/vivotek/camera.py @@ -1,4 +1,6 @@ """Support for Vivotek IP Cameras.""" +from __future__ import annotations + from libpyvivotek import VivotekCamera import voluptuous as vol @@ -87,7 +89,9 @@ class VivotekCam(Camera): """Return the interval between frames of the mjpeg stream.""" return self._frame_interval - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" return self._cam.snapshot() diff --git a/homeassistant/components/xeoma/camera.py b/homeassistant/components/xeoma/camera.py index d6f313c0382..049b4bfcbc0 100644 --- a/homeassistant/components/xeoma/camera.py +++ b/homeassistant/components/xeoma/camera.py @@ -1,4 +1,6 @@ """Support for Xeoma Cameras.""" +from __future__ import annotations + import logging from pyxeoma.xeoma import Xeoma, XeomaError @@ -109,7 +111,9 @@ class XeomaCamera(Camera): self._password = password self._last_image = None - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index 359d6c8b896..c89b23e9081 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -1,4 +1,6 @@ """This component provides support for Xiaomi Cameras.""" +from __future__ import annotations + import asyncio from ftplib import FTP, error_perm import logging @@ -138,7 +140,9 @@ class XiaomiCamera(Camera): return f"ftp://{self.user}:{self.passwd}@{host}:{self.port}{ftp.pwd()}/{video}" - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index c130532a2e1..6f898bb9a9b 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -1,4 +1,6 @@ """Support for Xiaomi Cameras (HiSilicon Hi3518e V200).""" +from __future__ import annotations + import asyncio import logging @@ -119,7 +121,9 @@ class YiCamera(Camera): self._is_on = False return None - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" url = await self._get_latest_video_url() if url and url != self._last_url: diff --git a/requirements_all.txt b/requirements_all.txt index 813f40acf54..901ac8fd3b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -57,7 +57,7 @@ PySocks==1.7.1 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 -# homeassistant.components.homekit +# homeassistant.components.camera PyTurboJPEG==1.5.0 # homeassistant.components.vicare diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bc6cb76018..37ee164cd60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -26,7 +26,7 @@ PyRMVtransport==0.3.2 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 -# homeassistant.components.homekit +# homeassistant.components.camera PyTurboJPEG==1.5.0 # homeassistant.components.xiaomi_aqara diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index 93e2596e343..756a553f3c7 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -3,8 +3,12 @@ All containing methods are legacy helpers that should not be used by new components. Instead call the service directly. """ +from unittest.mock import Mock + from homeassistant.components.camera.const import DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM +EMPTY_8_6_JPEG = b"empty_8_6" + def mock_camera_prefs(hass, entity_id, prefs=None): """Fixture for cloud component.""" @@ -13,3 +17,16 @@ def mock_camera_prefs(hass, entity_id, prefs=None): prefs_to_set.update(prefs) hass.data[DATA_CAMERA_PREFS]._prefs[entity_id] = prefs_to_set return prefs_to_set + + +def mock_turbo_jpeg( + first_width=None, second_width=None, first_height=None, second_height=None +): + """Mock a TurboJPEG instance.""" + mocked_turbo_jpeg = Mock() + mocked_turbo_jpeg.decode_header.side_effect = [ + (first_width, first_height, 0, 0), + (second_width, second_height, 0, 0), + ] + mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG + return mocked_turbo_jpeg diff --git a/tests/components/homekit/test_img_util.py b/tests/components/camera/test_img_util.py similarity index 67% rename from tests/components/homekit/test_img_util.py rename to tests/components/camera/test_img_util.py index 45af8e6b1e6..4f32715800e 100644 --- a/tests/components/homekit/test_img_util.py +++ b/tests/components/camera/test_img_util.py @@ -1,8 +1,10 @@ -"""Test HomeKit img_util module.""" +"""Test img_util module.""" from unittest.mock import patch +from turbojpeg import TurboJPEG + from homeassistant.components.camera import Image -from homeassistant.components.homekit.img_util import ( +from homeassistant.components.camera.img_util import ( TurboJPEGSingleton, scale_jpeg_camera_image, ) @@ -12,13 +14,23 @@ from .common import EMPTY_8_6_JPEG, mock_turbo_jpeg EMPTY_16_12_JPEG = b"empty_16_12" +def _clear_turbojpeg_singleton(): + TurboJPEGSingleton.__instance = None + + +def _reset_turbojpeg_singleton(): + TurboJPEGSingleton.__instance = TurboJPEG() + + def test_turbojpeg_singleton(): """Verify the instance always gives back the same.""" + _clear_turbojpeg_singleton() assert TurboJPEGSingleton.instance() == TurboJPEGSingleton.instance() def test_scale_jpeg_camera_image(): """Test we can scale a jpeg image.""" + _clear_turbojpeg_singleton() camera_image = Image("image/jpeg", EMPTY_16_12_JPEG) @@ -27,6 +39,12 @@ def test_scale_jpeg_camera_image(): TurboJPEGSingleton() assert scale_jpeg_camera_image(camera_image, 16, 12) == camera_image.content + turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) + turbo_jpeg.decode_header.side_effect = OSError + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): + TurboJPEGSingleton() + assert scale_jpeg_camera_image(camera_image, 16, 12) == camera_image.content + turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): TurboJPEGSingleton() @@ -44,11 +62,11 @@ def test_scale_jpeg_camera_image(): def test_turbojpeg_load_failure(): """Handle libjpegturbo not being installed.""" - + _clear_turbojpeg_singleton() with patch("turbojpeg.TurboJPEG", side_effect=Exception): TurboJPEGSingleton() assert TurboJPEGSingleton.instance() is False - with patch("turbojpeg.TurboJPEG"): - TurboJPEGSingleton() - assert TurboJPEGSingleton.instance() + _clear_turbojpeg_singleton() + TurboJPEGSingleton() + assert TurboJPEGSingleton.instance() is not None diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 7c7890a3e5f..f4267de234c 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -20,6 +20,8 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from .common import EMPTY_8_6_JPEG, mock_turbo_jpeg + from tests.components.camera import common @@ -75,6 +77,51 @@ async def test_get_image_from_camera(hass, image_mock_url): assert image.content == b"Test" +async def test_get_image_from_camera_with_width_height(hass, image_mock_url): + """Grab an image from camera entity with width and height.""" + + turbo_jpeg = mock_turbo_jpeg( + first_width=16, first_height=12, second_width=300, second_height=200 + ) + with patch( + "homeassistant.components.camera.img_util.TurboJPEGSingleton.instance", + return_value=turbo_jpeg, + ), patch( + "homeassistant.components.demo.camera.Path.read_bytes", + autospec=True, + return_value=b"Test", + ) as mock_camera: + image = await camera.async_get_image( + hass, "camera.demo_camera", width=640, height=480 + ) + + assert mock_camera.called + assert image.content == b"Test" + + +async def test_get_image_from_camera_with_width_height_scaled(hass, image_mock_url): + """Grab an image from camera entity with width and height and scale it.""" + + turbo_jpeg = mock_turbo_jpeg( + first_width=16, first_height=12, second_width=300, second_height=200 + ) + with patch( + "homeassistant.components.camera.img_util.TurboJPEGSingleton.instance", + return_value=turbo_jpeg, + ), patch( + "homeassistant.components.demo.camera.Path.read_bytes", + autospec=True, + return_value=b"Valid jpeg", + ) as mock_camera: + image = await camera.async_get_image( + hass, "camera.demo_camera", width=4, height=3 + ) + + assert mock_camera.called + assert image.content_type == "image/jpeg" + assert image.content == EMPTY_8_6_JPEG + + async def test_get_stream_source_from_camera(hass, mock_camera): """Fetch stream source from camera entity.""" diff --git a/tests/components/homekit/common.py b/tests/components/homekit/common.py deleted file mode 100644 index 6b1d87e3f54..00000000000 --- a/tests/components/homekit/common.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Collection of fixtures and functions for the HomeKit tests.""" -from unittest.mock import Mock - -EMPTY_8_6_JPEG = b"empty_8_6" - - -def mock_turbo_jpeg( - first_width=None, second_width=None, first_height=None, second_height=None -): - """Mock a TurboJPEG instance.""" - mocked_turbo_jpeg = Mock() - mocked_turbo_jpeg.decode_header.side_effect = [ - (first_width, first_height, 0, 0), - (second_width, second_height, 0, 0), - ] - mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG - return mocked_turbo_jpeg diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index b9df572a699..991965b30b5 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -7,6 +7,7 @@ from pyhap.accessory_driver import AccessoryDriver import pytest from homeassistant.components import camera, ffmpeg +from homeassistant.components.camera.img_util import TurboJPEGSingleton from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( AUDIO_CODEC_COPY, @@ -26,14 +27,13 @@ from homeassistant.components.homekit.const import ( VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, ) -from homeassistant.components.homekit.img_util import TurboJPEGSingleton from homeassistant.components.homekit.type_cameras import Camera from homeassistant.components.homekit.type_switches import Switch from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from .common import mock_turbo_jpeg +from tests.components.camera.common import mock_turbo_jpeg MOCK_START_STREAM_TLV = "ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA" MOCK_END_POINTS_TLV = "ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA==" From d0b11568cc056c667f70ee6ca44cdf63e15f6de0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Aug 2021 20:28:01 -0500 Subject: [PATCH 315/903] Ensure HomeKit passes min/max mireds as ints (#54372) --- .../components/homekit/type_lights.py | 5 +++-- tests/components/homekit/test_type_lights.py | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index aea760534fd..90c55d52153 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -1,5 +1,6 @@ """Class to hold all light accessories.""" import logging +import math from pyhap.const import CATEGORY_LIGHTBULB @@ -89,8 +90,8 @@ class Light(HomeAccessory): self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) if self.color_temp_supported: - min_mireds = attributes.get(ATTR_MIN_MIREDS, 153) - max_mireds = attributes.get(ATTR_MAX_MIREDS, 500) + min_mireds = math.floor(attributes.get(ATTR_MIN_MIREDS, 153)) + max_mireds = math.ceil(attributes.get(ATTR_MAX_MIREDS, 500)) self.char_color_temp = serv_light.configure_char( CHAR_COLOR_TEMPERATURE, value=min_mireds, diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 90e3aa0cabe..de0fd532ec9 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -15,6 +15,8 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + ATTR_MAX_MIREDS, + ATTR_MIN_MIREDS, ATTR_SUPPORTED_COLOR_MODES, DOMAIN, ) @@ -639,6 +641,26 @@ async def test_light_set_brightness_and_color(hass, hk_driver, events): ) +async def test_light_min_max_mireds(hass, hk_driver, events): + """Test mireds are forced to ints.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], + ATTR_BRIGHTNESS: 255, + ATTR_MAX_MIREDS: 500.5, + ATTR_MIN_MIREDS: 100.5, + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + acc.char_color_temp.properties["maxValue"] == 500 + acc.char_color_temp.properties["minValue"] == 100 + + async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): """Test light with all chars in one go.""" entity_id = "light.demo" From 4d40d958488ffe40148ab6fd7a702c1da5b2fca6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Aug 2021 20:31:11 -0500 Subject: [PATCH 316/903] Add support for width and height to ffmpeg based camera snapshots (#53837) --- homeassistant/components/canary/camera.py | 18 +++---- homeassistant/components/ezviz/camera.py | 12 ++--- homeassistant/components/ffmpeg/__init__.py | 12 +++++ homeassistant/components/onvif/camera.py | 19 +++---- homeassistant/components/ring/camera.py | 21 +++----- homeassistant/components/xiaomi/camera.py | 14 ++--- homeassistant/components/yi/camera.py | 14 ++--- tests/components/ffmpeg/test_init.py | 59 ++++++++++++++++++++- 8 files changed, 109 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 7a2d22c2406..a475a27f942 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -1,7 +1,6 @@ """Support for Canary camera.""" from __future__ import annotations -import asyncio from datetime import timedelta from typing import Final @@ -9,9 +8,9 @@ from aiohttp.web import Request, StreamResponse from canary.api import Device, Location from canary.live_stream_api import LiveStreamSession from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, Camera, @@ -131,16 +130,13 @@ class CanaryCamera(CoordinatorEntity, Camera): live_stream_url = await self.hass.async_add_executor_job( getattr, self._live_stream_session, "live_stream_url" ) - - ffmpeg = ImageFrame(self._ffmpeg.binary) - image: bytes | None = await asyncio.shield( - ffmpeg.get_image( - live_stream_url, - output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments, - ) + return await ffmpeg.async_get_image( + self.hass, + live_stream_url, + extra_cmd=self._ffmpeg_arguments, + width=width, + height=height, ) - return image async def handle_async_mjpeg_stream( self, request: Request diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 4e5fdb90c79..44a90e2928f 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -1,13 +1,12 @@ """Support ezviz camera devices.""" from __future__ import annotations -import asyncio import logging -from haffmpeg.tools import IMAGE_JPEG, ImageFrame from pyezviz.exceptions import HTTPError, InvalidHost, PyEzvizError import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.config_entries import ( @@ -329,12 +328,11 @@ class EzvizCamera(CoordinatorEntity, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a frame from the camera stream.""" - ffmpeg = ImageFrame(self._ffmpeg.binary) - - image = await asyncio.shield( - ffmpeg.get_image(self._rtsp_stream, output_format=IMAGE_JPEG) + if self._rtsp_stream is None: + return None + return await ffmpeg.async_get_image( + self.hass, self._rtsp_stream, width=width, height=height ) - return image @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 52e034c6265..74c826f47d6 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity +from homeassistant.loader import bind_hass DOMAIN = "ffmpeg" @@ -89,15 +90,26 @@ async def async_setup(hass, config): return True +@bind_hass async def async_get_image( hass: HomeAssistant, input_source: str, output_format: str = IMAGE_JPEG, extra_cmd: str | None = None, + width: int | None = None, + height: int | None = None, ) -> bytes | None: """Get an image from a frame of an RTSP stream.""" manager = hass.data[DATA_FFMPEG] ffmpeg = ImageFrame(manager.binary) + + if width and height and (extra_cmd is None or "-s" not in extra_cmd): + size_cmd = f"-s {width}x{height}" + if extra_cmd is None: + extra_cmd = size_cmd + else: + extra_cmd += " " + size_cmd + image = await asyncio.shield( ffmpeg.get_image(input_source, output_format=output_format, extra_cmd=extra_cmd) ) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 4d80231df23..bb7cffa86f9 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,14 +1,12 @@ """Support for ONVIF Cameras with FFmpeg as decoder.""" from __future__ import annotations -import asyncio - from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame from onvif.exceptions import ONVIFError import voluptuous as vol from yarl import URL +from homeassistant.components import ffmpeg from homeassistant.components.camera import SUPPORT_STREAM, Camera from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, DATA_FFMPEG from homeassistant.const import HTTP_BASIC_AUTHENTICATION @@ -141,15 +139,12 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): ) if image is None: - ffmpeg = ImageFrame(self.hass.data[DATA_FFMPEG].binary) - image = await asyncio.shield( - ffmpeg.get_image( - self._stream_uri, - output_format=IMAGE_JPEG, - extra_cmd=self.device.config_entry.options.get( - CONF_EXTRA_ARGUMENTS - ), - ) + return await ffmpeg.async_get_image( + self.hass, + self._stream_uri, + extra_cmd=self.device.config_entry.options.get(CONF_EXTRA_ARGUMENTS), + width=width, + height=height, ) return image diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 77317d62ab3..509877ae5ff 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -1,15 +1,14 @@ """This component provides support to the Ring Door Bell camera.""" from __future__ import annotations -import asyncio from datetime import timedelta from itertools import chain import logging from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import requests +from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ATTR_ATTRIBUTION @@ -44,12 +43,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class RingCam(RingEntityMixin, Camera): """An implementation of a Ring Door Bell camera.""" - def __init__(self, config_entry_id, ffmpeg, device): + def __init__(self, config_entry_id, ffmpeg_manager, device): """Initialize a Ring Door Bell camera.""" super().__init__(config_entry_id, device) self._name = self._device.name - self._ffmpeg = ffmpeg + self._ffmpeg_manager = ffmpeg_manager self._last_event = None self._last_video_id = None self._video_url = None @@ -107,25 +106,19 @@ class RingCam(RingEntityMixin, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - ffmpeg = ImageFrame(self._ffmpeg.binary) - if self._video_url is None: return - image = await asyncio.shield( - ffmpeg.get_image( - self._video_url, - output_format=IMAGE_JPEG, - ) + return await ffmpeg.async_get_image( + self.hass, self._video_url, width=width, height=height ) - return image async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" if self._video_url is None: return - stream = CameraMjpeg(self._ffmpeg.binary) + stream = CameraMjpeg(self._ffmpeg_manager.binary) await stream.open_camera(self._video_url) try: @@ -134,7 +127,7 @@ class RingCam(RingEntityMixin, Camera): self.hass, request, stream_reader, - self._ffmpeg.ffmpeg_stream_content_type, + self._ffmpeg_manager.ffmpeg_stream_content_type, ) finally: await stream.close() diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index c89b23e9081..016fe7dd2ba 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -1,14 +1,13 @@ """This component provides support for Xiaomi Cameras.""" from __future__ import annotations -import asyncio from ftplib import FTP, error_perm import logging from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ( @@ -153,11 +152,12 @@ class XiaomiCamera(Camera): url = await self.hass.async_add_executor_job(self.get_latest_video_url, host) if url != self._last_url: - ffmpeg = ImageFrame(self._manager.binary) - self._last_image = await asyncio.shield( - ffmpeg.get_image( - url, output_format=IMAGE_JPEG, extra_cmd=self._extra_arguments - ) + self._last_image = await ffmpeg.async_get_image( + self.hass, + url, + extra_cmd=self._extra_arguments, + width=width, + height=height, ) self._last_url = url diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index 6f898bb9a9b..91dfaab38bf 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -1,14 +1,13 @@ """Support for Xiaomi Cameras (HiSilicon Hi3518e V200).""" from __future__ import annotations -import asyncio import logging from aioftp import Client, StatusCodeError from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ( @@ -127,11 +126,12 @@ class YiCamera(Camera): """Return a still image response from the camera.""" url = await self._get_latest_video_url() if url and url != self._last_url: - ffmpeg = ImageFrame(self._manager.binary) - self._last_image = await asyncio.shield( - ffmpeg.get_image( - url, output_format=IMAGE_JPEG, extra_cmd=self._extra_arguments - ), + self._last_image = await ffmpeg.async_get_image( + self.hass, + url, + extra_cmd=self._extra_arguments, + width=width, + height=height, ) self._last_url = url diff --git a/tests/components/ffmpeg/test_init.py b/tests/components/ffmpeg/test_init.py index 3c6a2fbb92d..e1730ffdabb 100644 --- a/tests/components/ffmpeg/test_init.py +++ b/tests/components/ffmpeg/test_init.py @@ -1,7 +1,7 @@ """The tests for Home Assistant ffmpeg.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch -import homeassistant.components.ffmpeg as ffmpeg +from homeassistant.components import ffmpeg from homeassistant.components.ffmpeg import ( DOMAIN, SERVICE_RESTART, @@ -181,3 +181,58 @@ async def test_setup_component_test_service_start_with_entity(hass): assert ffmpeg_dev.called_start assert ffmpeg_dev.called_entities == ["test.ffmpeg_device"] + + +async def test_async_get_image_with_width_height(hass): + """Test fetching an image with a specific width and height.""" + with assert_setup_component(1): + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + get_image_mock = AsyncMock() + with patch( + "homeassistant.components.ffmpeg.ImageFrame", + return_value=Mock(get_image=get_image_mock), + ): + await ffmpeg.async_get_image(hass, "rtsp://fake", width=640, height=480) + + assert get_image_mock.call_args_list == [ + call("rtsp://fake", output_format="mjpeg", extra_cmd="-s 640x480") + ] + + +async def test_async_get_image_with_extra_cmd_overlapping_width_height(hass): + """Test fetching an image with and extra_cmd with width and height and a specific width and height.""" + with assert_setup_component(1): + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + get_image_mock = AsyncMock() + with patch( + "homeassistant.components.ffmpeg.ImageFrame", + return_value=Mock(get_image=get_image_mock), + ): + await ffmpeg.async_get_image( + hass, "rtsp://fake", extra_cmd="-s 1024x768", width=640, height=480 + ) + + assert get_image_mock.call_args_list == [ + call("rtsp://fake", output_format="mjpeg", extra_cmd="-s 1024x768") + ] + + +async def test_async_get_image_with_extra_cmd_width_height(hass): + """Test fetching an image with and extra_cmd and a specific width and height.""" + with assert_setup_component(1): + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + get_image_mock = AsyncMock() + with patch( + "homeassistant.components.ffmpeg.ImageFrame", + return_value=Mock(get_image=get_image_mock), + ): + await ffmpeg.async_get_image( + hass, "rtsp://fake", extra_cmd="-vf any", width=640, height=480 + ) + + assert get_image_mock.call_args_list == [ + call("rtsp://fake", output_format="mjpeg", extra_cmd="-vf any -s 640x480") + ] From 10551743d6f7eb8549339f5c68ef642a5c97d17e Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 10 Aug 2021 20:28:03 -0700 Subject: [PATCH 317/903] Bump pyopenuv to 2.1.0 (#54436) --- homeassistant/components/openuv/__init__.py | 2 +- homeassistant/components/openuv/config_flow.py | 2 +- homeassistant/components/openuv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index efe6fa89ca8..bcdd0b2ba40 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -59,8 +59,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_API_KEY], config_entry.data.get(CONF_LATITUDE, hass.config.latitude), config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), - websession, altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation), + session=websession, ) ) await openuv.async_update() diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 54b2aca0b75..3595b124053 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -71,7 +71,7 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() websession = aiohttp_client.async_get_clientsession(self.hass) - client = Client(user_input[CONF_API_KEY], 0, 0, websession) + client = Client(user_input[CONF_API_KEY], 0, 0, session=websession) try: await client.uv_index() diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index 81e38d251f1..842d4966805 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -3,7 +3,7 @@ "name": "OpenUV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openuv", - "requirements": ["pyopenuv==1.0.9"], + "requirements": ["pyopenuv==2.1.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 901ac8fd3b8..dfcd9d694fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1649,7 +1649,7 @@ pyobihai==1.3.1 pyombi==0.1.10 # homeassistant.components.openuv -pyopenuv==1.0.9 +pyopenuv==2.1.0 # homeassistant.components.opnsense pyopnsense==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 37ee164cd60..638bd10520f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.openuv -pyopenuv==1.0.9 +pyopenuv==2.1.0 # homeassistant.components.opnsense pyopnsense==0.2.0 From adcbd8b115908042b3fcd34708acb69d4c2be22f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 11 Aug 2021 08:31:52 +0200 Subject: [PATCH 318/903] =?UTF-8?q?Activate=20mypy=20for=20Tr=C3=A5dfri=20?= =?UTF-8?q?(#54416)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Activate mypy. --- homeassistant/components/tradfri/__init__.py | 8 ++++++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index cf39d3d6c05..8508dab5b96 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -1,6 +1,9 @@ """Support for IKEA Tradfri.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from pytradfri import Gateway, RequestError from pytradfri.api.aiocoap_api import APIFactory @@ -70,7 +73,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): load_json, hass.config.path(CONFIG_FILE) ) - for host, info in legacy_hosts.items(): + for host, info in legacy_hosts.items(): # type: ignore if host in configured_hosts: continue @@ -103,7 +106,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Create a gateway.""" # host, identity, key, allow_tradfri_groups - tradfri_data = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {} + tradfri_data: dict[str, Any] = {} + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = tradfri_data listeners = tradfri_data[LISTENERS] = [] factory = await APIFactory.init( diff --git a/mypy.ini b/mypy.ini index 6fffe2bc3c1..0cd2a419fe0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1691,9 +1691,6 @@ ignore_errors = true [mypy-homeassistant.components.tplink.*] ignore_errors = true -[mypy-homeassistant.components.tradfri.*] -ignore_errors = true - [mypy-homeassistant.components.tuya.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 9747a5ee8c0..188f2a0a41b 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -156,7 +156,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.todoist.*", "homeassistant.components.toon.*", "homeassistant.components.tplink.*", - "homeassistant.components.tradfri.*", "homeassistant.components.tuya.*", "homeassistant.components.unifi.*", "homeassistant.components.upnp.*", From 480fd53b4b4640e362aeb7b883dbd6727954aea2 Mon Sep 17 00:00:00 2001 From: Brett Date: Wed, 11 Aug 2021 17:49:31 +1000 Subject: [PATCH 319/903] Advantage Air code cleanup (#54449) --- homeassistant/components/advantage_air/sensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index eca7651d6eb..f6d59129603 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -112,7 +112,7 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): def __init__(self, instance, ac_key, zone_key): """Initialize an Advantage Air Zone wireless signal sensor.""" - super().__init__(instance, ac_key, zone_key=zone_key) + super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} Signal' self._attr_unique_id = ( f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-signal' @@ -149,7 +149,9 @@ class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): """Initialize an Advantage Air Zone Temp Sensor.""" super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} Temperature' - self._attr_unique_id = f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-temp' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-temp' + ) @property def state(self): From 930c1dbe9bd7bbb18bd4bfc4129fd330000eb6a9 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 11 Aug 2021 00:58:53 -0700 Subject: [PATCH 320/903] Bump aioambient to 1.2.6 (#54442) --- homeassistant/components/ambient_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 42b22d26a10..35b4770e872 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.2.5"], + "requirements": ["aioambient==1.2.6"], "codeowners": ["@bachya"], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index dfcd9d694fb..8fe9b1c6d57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -139,7 +139,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.5 +aioambient==1.2.6 # homeassistant.components.asuswrt aioasuswrt==1.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 638bd10520f..67caf01a0e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,7 +78,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.5 +aioambient==1.2.6 # homeassistant.components.asuswrt aioasuswrt==1.3.4 From 4e07ab1b323724b2aaae7af10eee9ac4ca7bb531 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Aug 2021 10:45:05 +0200 Subject: [PATCH 321/903] Move temperature conversions to sensor base class (1/8) (#48261) * Move temperature conversions to entity base class (1/8) * Update integrations a-c * Leave old temperature conversion until all integrations are migrated * tweak * Use contextlib.suppress * Remove the MeasurableUnitEntity mixin * Address comments, add tests * Fix f-string * Drop deprecation warning from base entity class * Update with _attr-shorthand * Fix rebase mistakes * Fix additional rebase mistakes * Only report temperature conversion once * Fix additional rebase mistakes * Format homeassistant/components/bbox/sensor.py * Fix check for overidden _attr_state * Remove test workarounds from implementation * Remove useless None-check * Tweaks * Migrate new sensors a-c * Update climacell * Push deprecation of temperature conversion forward * Override __repr__ in SensorEntity * Include native_value in SensorEntity attributes * Pylint * Black * Black * Fix rebase mistakes * black * Fix rebase mistakes * Revert changes in august/sensor.py * Revert handling of unit converted restored state * Apply code review suggestion * Fix arlo test --- homeassistant/components/abode/sensor.py | 8 +- .../components/accuweather/sensor.py | 6 +- homeassistant/components/acmeda/sensor.py | 2 +- homeassistant/components/adguard/sensor.py | 4 +- homeassistant/components/ads/sensor.py | 4 +- .../components/advantage_air/sensor.py | 14 +-- homeassistant/components/aemet/sensor.py | 6 +- homeassistant/components/aftership/sensor.py | 4 +- homeassistant/components/airly/const.py | 14 +-- homeassistant/components/airly/sensor.py | 2 +- homeassistant/components/airnow/sensor.py | 4 +- homeassistant/components/airvisual/sensor.py | 36 ++++--- .../components/alarmdecoder/sensor.py | 4 +- .../components/alpha_vantage/sensor.py | 8 +- homeassistant/components/ambee/const.py | 46 ++++---- homeassistant/components/ambee/sensor.py | 2 +- .../components/ambient_station/sensor.py | 8 +- homeassistant/components/amcrest/sensor.py | 4 +- .../components/android_ip_webcam/sensor.py | 4 +- homeassistant/components/apcupsd/sensor.py | 10 +- homeassistant/components/aqualogic/sensor.py | 8 +- homeassistant/components/arduino/sensor.py | 2 +- homeassistant/components/arest/sensor.py | 6 +- homeassistant/components/arlo/sensor.py | 2 +- homeassistant/components/arwn/sensor.py | 4 +- homeassistant/components/asuswrt/sensor.py | 14 +-- homeassistant/components/atag/sensor.py | 6 +- homeassistant/components/atome/sensor.py | 8 +- homeassistant/components/august/sensor.py | 8 +- homeassistant/components/aurora/sensor.py | 4 +- .../components/aurora_abb_powerone/sensor.py | 6 +- homeassistant/components/awair/sensor.py | 4 +- .../components/azure_devops/sensor.py | 4 +- homeassistant/components/bbox/sensor.py | 22 ++-- .../components/beewi_smartclim/sensor.py | 10 +- homeassistant/components/bh1750/sensor.py | 4 +- homeassistant/components/bitcoin/sensor.py | 70 ++++++------ homeassistant/components/bizkaibus/sensor.py | 4 +- homeassistant/components/blebox/sensor.py | 4 +- homeassistant/components/blink/sensor.py | 6 +- homeassistant/components/blockchain/sensor.py | 4 +- homeassistant/components/bloomsky/sensor.py | 10 +- homeassistant/components/bme280/sensor.py | 4 +- homeassistant/components/bme680/sensor.py | 16 +-- homeassistant/components/bmp280/sensor.py | 6 +- .../components/bmw_connected_drive/sensor.py | 20 ++-- homeassistant/components/bosch_shc/sensor.py | 32 +++--- homeassistant/components/broadlink/sensor.py | 6 +- homeassistant/components/brother/const.py | 44 ++++---- homeassistant/components/brother/sensor.py | 2 +- .../components/brottsplatskartan/sensor.py | 2 +- homeassistant/components/buienradar/sensor.py | 28 ++--- homeassistant/components/canary/sensor.py | 2 +- .../components/cert_expiry/sensor.py | 2 +- homeassistant/components/citybikes/sensor.py | 4 +- homeassistant/components/climacell/sensor.py | 4 +- homeassistant/components/co2signal/sensor.py | 4 +- homeassistant/components/coinbase/sensor.py | 8 +- .../components/comed_hourly_pricing/sensor.py | 4 +- .../components/comfoconnect/sensor.py | 4 +- .../components/command_line/sensor.py | 4 +- .../components/compensation/sensor.py | 4 +- .../components/coronavirus/sensor.py | 4 +- homeassistant/components/cpuspeed/sensor.py | 4 +- homeassistant/components/cups/sensor.py | 8 +- .../components/currencylayer/sensor.py | 4 +- homeassistant/components/sensor/__init__.py | 102 +++++++++++++++++- homeassistant/helpers/entity.py | 35 +++--- tests/components/arlo/test_sensor.py | 76 ++++++------- tests/components/sensor/test_init.py | 30 ++++++ .../custom_components/test/sensor.py | 13 ++- 71 files changed, 516 insertions(+), 360 deletions(-) create mode 100644 tests/components/sensor/test_init.py diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index a0681e0440f..03687fc3907 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -61,14 +61,14 @@ class AbodeSensor(AbodeDevice, SensorEntity): self._attr_name = f"{device.name} {description.name}" self._attr_unique_id = f"{device.device_uuid}-{description.key}" if description.key == CONST.TEMP_STATUS_KEY: - self._attr_unit_of_measurement = device.temp_unit + self._attr_native_unit_of_measurement = device.temp_unit elif description.key == CONST.HUMI_STATUS_KEY: - self._attr_unit_of_measurement = device.humidity_unit + self._attr_native_unit_of_measurement = device.humidity_unit elif description.key == CONST.LUX_STATUS_KEY: - self._attr_unit_of_measurement = device.lux_unit + self._attr_native_unit_of_measurement = device.lux_unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == CONST.TEMP_STATUS_KEY: return self._device.temp diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 4a5af6054e1..b5f979b45cf 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -88,10 +88,10 @@ class AccuWeatherSensor(CoordinatorEntity, SensorEntity): ) if coordinator.is_metric: self._unit_system = API_METRIC - self._attr_unit_of_measurement = description.unit_metric + self._attr_native_unit_of_measurement = description.unit_metric else: self._unit_system = API_IMPERIAL - self._attr_unit_of_measurement = description.unit_imperial + self._attr_native_unit_of_measurement = description.unit_imperial self._attr_device_info = { "identifiers": {(DOMAIN, coordinator.location_key)}, "name": NAME, @@ -101,7 +101,7 @@ class AccuWeatherSensor(CoordinatorEntity, SensorEntity): self.forecast_day = forecast_day @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" if self.forecast_day is not None: if self.entity_description.device_class == DEVICE_CLASS_TEMPERATURE: diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index 4f617c5726f..7cded0adb30 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -42,6 +42,6 @@ class AcmedaBattery(AcmedaBase, SensorEntity): return f"{super().name} Battery" @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.roller.battery diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index 7499cf51d0c..a7f4dabde9f 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -82,12 +82,12 @@ class AdGuardHomeSensor(AdGuardHomeDeviceEntity, SensorEntity): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index fe68c4c860b..26b04d86050 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -50,7 +50,7 @@ class AdsSensor(AdsEntity, SensorEntity): def __init__(self, ads_hub, ads_var, ads_type, name, unit_of_measurement, factor): """Initialize AdsSensor entity.""" super().__init__(ads_hub, name, ads_var) - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._ads_type = ads_type self._factor = factor @@ -64,6 +64,6 @@ class AdsSensor(AdsEntity, SensorEntity): ) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the device.""" return self._state_dict[STATE_KEY_STATE] diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index f6d59129603..65b7b35740e 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -45,7 +45,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AdvantageAirTimeTo(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air timer control.""" - _attr_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT + _attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT def __init__(self, instance, ac_key, action): """Initialize the Advantage Air timer control.""" @@ -58,7 +58,7 @@ class AdvantageAirTimeTo(AdvantageAirEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the current value.""" return self._ac[self._time_key] @@ -78,7 +78,7 @@ class AdvantageAirTimeTo(AdvantageAirEntity, SensorEntity): class AdvantageAirZoneVent(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone Vent Sensor.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = STATE_CLASS_MEASUREMENT def __init__(self, instance, ac_key, zone_key): @@ -90,7 +90,7 @@ class AdvantageAirZoneVent(AdvantageAirEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the current value of the air vent.""" if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: return self._zone["value"] @@ -107,7 +107,7 @@ class AdvantageAirZoneVent(AdvantageAirEntity, SensorEntity): class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone wireless signal sensor.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = STATE_CLASS_MEASUREMENT def __init__(self, instance, ac_key, zone_key): @@ -119,7 +119,7 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the current value of the wireless signal.""" return self._zone["rssi"] @@ -140,7 +140,7 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone wireless signal sensor.""" - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS _attr_state_class = STATE_CLASS_MEASUREMENT _attr_icon = "mdi:thermometer" _attr_entity_registry_enabled_default = False diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 3fd0769cb00..35336980e1a 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -85,7 +85,7 @@ class AbstractAemetSensor(CoordinatorEntity, SensorEntity): self._attr_name = f"{self._name} {self._sensor_name}" self._attr_unique_id = self._unique_id self._attr_device_class = sensor_configuration.get(SENSOR_DEVICE_CLASS) - self._attr_unit_of_measurement = sensor_configuration.get(SENSOR_UNIT) + self._attr_native_unit_of_measurement = sensor_configuration.get(SENSOR_UNIT) class AemetSensor(AbstractAemetSensor): @@ -106,7 +106,7 @@ class AemetSensor(AbstractAemetSensor): self._weather_coordinator = weather_coordinator @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._weather_coordinator.data.get(self._sensor_type) @@ -134,7 +134,7 @@ class AemetForecastSensor(AbstractAemetSensor): ) @property - def state(self): + def native_value(self): """Return the state of the device.""" forecast = None forecasts = self._weather_coordinator.data.get( diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index fd8e095f65f..a3b41f8314c 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -109,7 +109,7 @@ async def async_setup_platform( class AfterShipSensor(SensorEntity): """Representation of a AfterShip sensor.""" - _attr_unit_of_measurement: str = "packages" + _attr_native_unit_of_measurement: str = "packages" _attr_icon: str = ICON def __init__(self, aftership: Tracking, name: str) -> None: @@ -120,7 +120,7 @@ class AfterShipSensor(SensorEntity): self._attr_name = name @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index 157f28c33f7..e6b87db6f15 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -50,34 +50,34 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_CAQI, name=ATTR_API_CAQI, - unit_of_measurement="CAQI", + native_unit_of_measurement="CAQI", ), AirlySensorEntityDescription( key=ATTR_API_PM1, icon="mdi:blur", name=ATTR_API_PM1, - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), AirlySensorEntityDescription( key=ATTR_API_PM25, icon="mdi:blur", name="PM2.5", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), AirlySensorEntityDescription( key=ATTR_API_PM10, icon="mdi:blur", name=ATTR_API_PM10, - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), AirlySensorEntityDescription( key=ATTR_API_HUMIDITY, device_class=DEVICE_CLASS_HUMIDITY, name=ATTR_API_HUMIDITY.capitalize(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, value=lambda value: round(value, 1), ), @@ -85,14 +85,14 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( key=ATTR_API_PRESSURE, device_class=DEVICE_CLASS_PRESSURE, name=ATTR_API_PRESSURE.capitalize(), - unit_of_measurement=PRESSURE_HPA, + native_unit_of_measurement=PRESSURE_HPA, state_class=STATE_CLASS_MEASUREMENT, ), AirlySensorEntityDescription( key=ATTR_API_TEMPERATURE, device_class=DEVICE_CLASS_TEMPERATURE, name=ATTR_API_TEMPERATURE.capitalize(), - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, state_class=STATE_CLASS_MEASUREMENT, value=lambda value: round(value, 1), ), diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 2c811b00aa6..b5d45afd2d8 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -84,7 +84,7 @@ class AirlySensor(CoordinatorEntity, SensorEntity): self.entity_description = description @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" state = self.coordinator.data[self.entity_description.key] return cast(StateType, self.entity_description.value(state)) diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 31ea5e298e3..a0f8d7e701b 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -72,11 +72,11 @@ class AirNowSensor(CoordinatorEntity, SensorEntity): self._attr_name = f"AirNow {SENSOR_TYPES[self.kind][ATTR_LABEL]}" self._attr_icon = SENSOR_TYPES[self.kind][ATTR_ICON] self._attr_device_class = SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] - self._attr_unit_of_measurement = SENSOR_TYPES[self.kind][ATTR_UNIT] + self._attr_native_unit_of_measurement = SENSOR_TYPES[self.kind][ATTR_UNIT] self._attr_unique_id = f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}" @property - def state(self): + def native_value(self): """Return the state.""" self._state = self.coordinator.data[self.kind] return self._state diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 2f8dd07c625..922c84357ae 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -212,7 +212,7 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): self._attr_icon = icon self._attr_name = f"{GEOGRAPHY_SENSOR_LOCALES[locale]} {name}" self._attr_unique_id = f"{config_entry.unique_id}_{locale}_{kind}" - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit self._config_entry = config_entry self._kind = kind self._locale = locale @@ -232,16 +232,16 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): if self._kind == SENSOR_KIND_LEVEL: aqi = data[f"aqi{self._locale}"] - [(self._attr_state, self._attr_icon)] = [ + [(self._attr_native_value, self._attr_icon)] = [ (name, icon) for (floor, ceiling), (name, icon) in POLLUTANT_LEVELS.items() if floor <= aqi <= ceiling ] elif self._kind == SENSOR_KIND_AQI: - self._attr_state = data[f"aqi{self._locale}"] + self._attr_native_value = data[f"aqi{self._locale}"] elif self._kind == SENSOR_KIND_POLLUTANT: symbol = data[f"main{self._locale}"] - self._attr_state = symbol + self._attr_native_value = symbol self._attr_extra_state_attributes.update( { ATTR_POLLUTANT_SYMBOL: symbol, @@ -298,7 +298,7 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): f"{coordinator.data['settings']['node_name']} Node/Pro: {name}" ) self._attr_unique_id = f"{coordinator.data['serial_number']}_{kind}" - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit self._kind = kind @property @@ -320,24 +320,30 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): """Update the entity from the latest data.""" if self._kind == SENSOR_KIND_AQI: if self.coordinator.data["settings"]["is_aqi_usa"]: - self._attr_state = self.coordinator.data["measurements"]["aqi_us"] + self._attr_native_value = self.coordinator.data["measurements"][ + "aqi_us" + ] else: - self._attr_state = self.coordinator.data["measurements"]["aqi_cn"] + self._attr_native_value = self.coordinator.data["measurements"][ + "aqi_cn" + ] elif self._kind == SENSOR_KIND_BATTERY_LEVEL: - self._attr_state = self.coordinator.data["status"]["battery"] + self._attr_native_value = self.coordinator.data["status"]["battery"] elif self._kind == SENSOR_KIND_CO2: - self._attr_state = self.coordinator.data["measurements"].get("co2") + self._attr_native_value = self.coordinator.data["measurements"].get("co2") elif self._kind == SENSOR_KIND_HUMIDITY: - self._attr_state = self.coordinator.data["measurements"].get("humidity") + self._attr_native_value = self.coordinator.data["measurements"].get( + "humidity" + ) elif self._kind == SENSOR_KIND_PM_0_1: - self._attr_state = self.coordinator.data["measurements"].get("pm0_1") + self._attr_native_value = self.coordinator.data["measurements"].get("pm0_1") elif self._kind == SENSOR_KIND_PM_1_0: - self._attr_state = self.coordinator.data["measurements"].get("pm1_0") + self._attr_native_value = self.coordinator.data["measurements"].get("pm1_0") elif self._kind == SENSOR_KIND_PM_2_5: - self._attr_state = self.coordinator.data["measurements"].get("pm2_5") + self._attr_native_value = self.coordinator.data["measurements"].get("pm2_5") elif self._kind == SENSOR_KIND_TEMPERATURE: - self._attr_state = self.coordinator.data["measurements"].get( + self._attr_native_value = self.coordinator.data["measurements"].get( "temperature_C" ) elif self._kind == SENSOR_KIND_VOC: - self._attr_state = self.coordinator.data["measurements"].get("voc") + self._attr_native_value = self.coordinator.data["measurements"].get("voc") diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py index 67b7ee4861a..16471010ee9 100644 --- a/homeassistant/components/alarmdecoder/sensor.py +++ b/homeassistant/components/alarmdecoder/sensor.py @@ -32,6 +32,6 @@ class AlarmDecoderSensor(SensorEntity): ) def _message_callback(self, message): - if self._attr_state != message.text: - self._attr_state = message.text + if self._attr_native_value != message.text: + self._attr_native_value = message.text self.schedule_update_ha_state() diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 512de247ff2..583485ca703 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -112,7 +112,7 @@ class AlphaVantageSensor(SensorEntity): self._symbol = symbol[CONF_SYMBOL] self._attr_name = symbol.get(CONF_NAME, self._symbol) self._timeseries = timeseries - self._attr_unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol) + self._attr_native_unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol) self._attr_icon = ICONS.get(symbol.get(CONF_CURRENCY, "USD")) def update(self): @@ -120,7 +120,7 @@ class AlphaVantageSensor(SensorEntity): _LOGGER.debug("Requesting new data for symbol %s", self._symbol) all_values, _ = self._timeseries.get_intraday(self._symbol) values = next(iter(all_values.values())) - self._attr_state = values["1. open"] + self._attr_native_value = values["1. open"] self._attr_extra_state_attributes = ( { ATTR_ATTRIBUTION: ATTRIBUTION, @@ -148,7 +148,7 @@ class AlphaVantageForeignExchange(SensorEntity): else f"{self._to_currency}/{self._from_currency}" ) self._attr_icon = ICONS.get(self._from_currency, "USD") - self._attr_unit_of_measurement = self._to_currency + self._attr_native_unit_of_measurement = self._to_currency def update(self): """Get the latest data and updates the states.""" @@ -160,7 +160,7 @@ class AlphaVantageForeignExchange(SensorEntity): values, _ = self._foreign_exchange.get_currency_exchange_rate( from_currency=self._from_currency, to_currency=self._to_currency ) - self._attr_state = round(float(values["5. Exchange Rate"]), 4) + self._attr_native_value = round(float(values["5. Exchange Rate"]), 4) self._attr_extra_state_attributes = ( { ATTR_ATTRIBUTION: ATTRIBUTION, diff --git a/homeassistant/components/ambee/const.py b/homeassistant/components/ambee/const.py index d2570bea710..3fd57c17c63 100644 --- a/homeassistant/components/ambee/const.py +++ b/homeassistant/components/ambee/const.py @@ -39,38 +39,38 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { SensorEntityDescription( key="particulate_matter_2_5", name="Particulate Matter < 2.5 μm", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="particulate_matter_10", name="Particulate Matter < 10 μm", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="sulphur_dioxide", name="Sulphur Dioxide (SO2)", - unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="nitrogen_dioxide", name="Nitrogen Dioxide (NO2)", - unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="ozone", name="Ozone", - unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="carbon_monoxide", name="Carbon Monoxide (CO)", device_class=DEVICE_CLASS_CO, - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( @@ -85,21 +85,21 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Grass Pollen", icon="mdi:grass", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, ), SensorEntityDescription( key="tree", name="Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, ), SensorEntityDescription( key="weed", name="Weed Pollen", icon="mdi:sprout", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, ), SensorEntityDescription( key="grass_risk", @@ -124,7 +124,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Poaceae Grass Pollen", icon="mdi:grass", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -132,7 +132,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Alder Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -140,7 +140,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Birch Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -148,7 +148,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Cypress Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -156,7 +156,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Elm Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -164,7 +164,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Hazel Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -172,7 +172,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Oak Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -180,7 +180,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Pine Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -188,7 +188,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Plane Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -196,7 +196,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Poplar Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -204,7 +204,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Chenopod Weed Pollen", icon="mdi:sprout", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -212,7 +212,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Mugwort Weed Pollen", icon="mdi:sprout", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -220,7 +220,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Nettle Weed Pollen", icon="mdi:sprout", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -228,7 +228,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Ragweed Weed Pollen", icon="mdi:sprout", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), ], diff --git a/homeassistant/components/ambee/sensor.py b/homeassistant/components/ambee/sensor.py index ecd04ffd204..bd125ac973e 100644 --- a/homeassistant/components/ambee/sensor.py +++ b/homeassistant/components/ambee/sensor.py @@ -66,7 +66,7 @@ class AmbeeSensorEntity(CoordinatorEntity, SensorEntity): } @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" value = getattr(self.coordinator.data, self.entity_description.key) if isinstance(value, str): diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index a606b401bc0..935a53e9384 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -61,7 +61,7 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): ambient, mac_address, station_name, sensor_type, sensor_name, device_class ) - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit @callback def update_from_latest_data(self) -> None: @@ -75,10 +75,10 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): ].get(TYPE_SOLARRADIATION) if w_m2_brightness_val is None: - self._attr_state = None + self._attr_native_value = None else: - self._attr_state = round(float(w_m2_brightness_val) / 0.0079) + self._attr_native_value = round(float(w_m2_brightness_val) / 0.0079) else: - self._attr_state = self._ambient.stations[self._mac_address][ + self._attr_native_value = self._ambient.stations[self._mac_address][ ATTR_LAST_DATA ].get(self._sensor_type) diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index a30de62494e..de8370a15fc 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -61,7 +61,7 @@ class AmcrestSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -76,7 +76,7 @@ class AmcrestSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py index adedb297cd1..4bef3848617 100644 --- a/homeassistant/components/android_ip_webcam/sensor.py +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -50,12 +50,12 @@ class IPWebcamSensor(AndroidIPCamEntity, SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index bf1b8bf6db5..5937ff6a852 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -165,16 +165,16 @@ class APCUPSdSensor(SensorEntity): self.type = sensor_type self._attr_name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] self._attr_icon = SENSOR_TYPES[self.type][2] - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._attr_device_class = SENSOR_TYPES[sensor_type][3] def update(self): """Get the latest status and use it to update our sensor state.""" if self.type.upper() not in self._data.status: - self._attr_state = None + self._attr_native_value = None else: - self._attr_state, inferred_unit = infer_unit( + self._attr_native_value, inferred_unit = infer_unit( self._data.status[self.type.upper()] ) - if not self._attr_unit_of_measurement: - self._attr_unit_of_measurement = inferred_unit + if not self._attr_native_unit_of_measurement: + self._attr_native_unit_of_measurement = inferred_unit diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index fff73cf00fa..394f8844adb 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -92,11 +92,11 @@ class AquaLogicSensor(SensorEntity): panel = self._processor.panel if panel is not None: if panel.is_metric: - self._attr_unit_of_measurement = SENSOR_TYPES[self._type][1][0] + self._attr_native_unit_of_measurement = SENSOR_TYPES[self._type][1][0] else: - self._attr_unit_of_measurement = SENSOR_TYPES[self._type][1][1] + self._attr_native_unit_of_measurement = SENSOR_TYPES[self._type][1][1] - self._attr_state = getattr(panel, self._type) + self._attr_native_value = getattr(panel, self._type) self.async_write_ha_state() else: - self._attr_unit_of_measurement = None + self._attr_native_unit_of_measurement = None diff --git a/homeassistant/components/arduino/sensor.py b/homeassistant/components/arduino/sensor.py index fa624a7d167..0853fb5537d 100644 --- a/homeassistant/components/arduino/sensor.py +++ b/homeassistant/components/arduino/sensor.py @@ -42,4 +42,4 @@ class ArduinoSensor(SensorEntity): def update(self): """Get the latest value from the pin.""" - self._attr_state = self._board.get_analog_inputs()[self._pin][1] + self._attr_native_value = self._board.get_analog_inputs()[self._pin][1] diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 7129b989f47..addd666e30e 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -141,7 +141,7 @@ class ArestSensor(SensorEntity): self.arest = arest self._attr_name = f"{location.title()} {name.title()}" self._variable = variable - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._renderer = renderer if pin is not None: @@ -155,9 +155,9 @@ class ArestSensor(SensorEntity): self._attr_available = self.arest.available values = self.arest.data if "error" in values: - self._attr_state = values["error"] + self._attr_native_value = values["error"] else: - self._attr_state = self._renderer( + self._attr_native_value = self._renderer( values.get("value", values.get(self._variable, None)) ) diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index e78c8b7bf49..d17668ae721 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -127,7 +127,7 @@ class ArloSensor(SensorEntity): self.async_schedule_update_ha_state(True) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index 2300319f9a4..321be5035cd 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -138,7 +138,7 @@ class ArwnSensor(SensorEntity): # This mqtt topic for the sensor which is its uid self._attr_unique_id = topic self._state_key = state_key - self._attr_unit_of_measurement = units + self._attr_native_unit_of_measurement = units self._attr_icon = icon self._attr_device_class = device_class @@ -147,5 +147,5 @@ class ArwnSensor(SensorEntity): ev = {} ev.update(event) self._attr_extra_state_attributes = ev - self._attr_state = ev.get(self._state_key, None) + self._attr_native_value = ev.get(self._state_key, None) self.async_write_ha_state() diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index ef186a80085..3367cc37ee4 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -48,13 +48,13 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_CONNECTED_DEVICE[0], name="Devices Connected", icon="mdi:router-network", - unit_of_measurement=UNIT_DEVICES, + native_unit_of_measurement=UNIT_DEVICES, ), AsusWrtSensorEntityDescription( key=SENSORS_RATES[0], name="Download Speed", icon="mdi:download-network", - unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, entity_registry_enabled_default=False, factor=125000, ), @@ -62,7 +62,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_RATES[1], name="Upload Speed", icon="mdi:upload-network", - unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, entity_registry_enabled_default=False, factor=125000, ), @@ -70,7 +70,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_BYTES[0], name="Download", icon="mdi:download", - unit_of_measurement=DATA_GIGABYTES, + native_unit_of_measurement=DATA_GIGABYTES, entity_registry_enabled_default=False, factor=1000000000, ), @@ -78,7 +78,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_BYTES[1], name="Upload", icon="mdi:upload", - unit_of_measurement=DATA_GIGABYTES, + native_unit_of_measurement=DATA_GIGABYTES, entity_registry_enabled_default=False, factor=1000000000, ), @@ -150,11 +150,11 @@ class AsusWrtSensor(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{DOMAIN} {self.name}" self._attr_state_class = STATE_CLASS_MEASUREMENT - if description.unit_of_measurement == DATA_GIGABYTES: + if description.native_unit_of_measurement == DATA_GIGABYTES: self._attr_last_reset = dt_util.utc_from_timestamp(0) @property - def state(self) -> str: + def native_value(self) -> str: """Return current state.""" descr = self.entity_description state = self.coordinator.data.get(descr.key) diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py index 014c6cb463e..386b5999712 100644 --- a/homeassistant/components/atag/sensor.py +++ b/homeassistant/components/atag/sensor.py @@ -49,10 +49,12 @@ class AtagSensor(AtagEntity, SensorEntity): PERCENTAGE, TIME_HOURS, ): - self._attr_unit_of_measurement = coordinator.data.report[self._id].measure + self._attr_native_unit_of_measurement = coordinator.data.report[ + self._id + ].measure @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.coordinator.data.report[self._id].state diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py index 7295a9cee41..498e760924a 100644 --- a/homeassistant/components/atome/sensor.py +++ b/homeassistant/components/atome/sensor.py @@ -258,10 +258,10 @@ class AtomeSensor(SensorEntity): if sensor_type == LIVE_TYPE: self._attr_device_class = DEVICE_CLASS_POWER - self._attr_unit_of_measurement = POWER_WATT + self._attr_native_unit_of_measurement = POWER_WATT else: self._attr_device_class = DEVICE_CLASS_ENERGY - self._attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR def update(self): """Update device state.""" @@ -269,13 +269,13 @@ class AtomeSensor(SensorEntity): update_function() if self._sensor_type == LIVE_TYPE: - self._attr_state = self._data.live_power + self._attr_native_value = self._data.live_power self._attr_extra_state_attributes = { "subscribed_power": self._data.subscribed_power, "is_connected": self._data.is_connected, } else: - self._attr_state = getattr(self._data, f"{self._sensor_type}_usage") + self._attr_native_value = getattr(self._data, f"{self._sensor_type}_usage") self._attr_last_reset = dt_util.as_utc( getattr(self._data, f"{self._sensor_type}_last_reset") ) diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index a174964f349..b6d93d3b3b1 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -146,7 +146,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._attr_available = True if lock_activity is not None: - self._attr_state = lock_activity.operated_by + self._attr_native_value = lock_activity.operated_by self._operated_remote = lock_activity.operated_remote self._operated_keypad = lock_activity.operated_keypad self._operated_autorelock = lock_activity.operated_autorelock @@ -208,7 +208,7 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity): """Representation of an August sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, data, sensor_type, device, old_device): """Initialize the sensor.""" @@ -223,8 +223,8 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity): def _update_from_data(self): """Get the latest state of the sensor.""" state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] - self._attr_state = state_provider(self._detail) - self._attr_available = self._attr_state is not None + self._attr_native_value = state_provider(self._detail) + self._attr_available = self._attr_native_value is not None @property def old_unique_id(self) -> str: diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index 76be6ca97f8..96bdbbf1370 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -22,9 +22,9 @@ async def async_setup_entry(hass, entry, async_add_entries): class AuroraSensor(AuroraEntity, SensorEntity): """Implementation of an aurora sensor.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE @property - def state(self): + def native_value(self): """Return % chance the aurora is visible.""" return self.coordinator.data diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 9c798b8e6d4..b1bcec18796 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -51,7 +51,7 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity): """Representation of a Sensor.""" _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT _attr_device_class = DEVICE_CLASS_POWER def __init__(self, client, name, typename): @@ -68,7 +68,7 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity): self.client.connect() # read ADC channel 3 (grid power output) power_watts = self.client.measure(3, True) - self._attr_state = round(power_watts, 1) + self._attr_native_value = round(power_watts, 1) except AuroraError as error: # aurorapy does not have different exceptions (yet) for dealing # with timeout vs other comms errors. @@ -82,7 +82,7 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity): _LOGGER.debug("No response from inverter (could be dark)") else: raise error - self._attr_state = None + self._attr_native_value = None finally: if self.client.serline.isOpen(): self.client.close() diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 968587c3b10..3b46d3b2317 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -144,7 +144,7 @@ class AwairSensor(CoordinatorEntity, SensorEntity): return False @property - def state(self) -> float: + def native_value(self) -> float: """Return the state, rounding off to reasonable values.""" state: float @@ -175,7 +175,7 @@ class AwairSensor(CoordinatorEntity, SensorEntity): return SENSOR_TYPES[self._kind][ATTR_DEVICE_CLASS] @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit the value is expressed in.""" return SENSOR_TYPES[self._kind][ATTR_UNIT] diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index d7589cf5014..67d472abc1e 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -71,7 +71,7 @@ class AzureDevOpsSensor(AzureDevOpsDeviceEntity, SensorEntity): unit_of_measurement: str = "", ) -> None: """Initialize Azure DevOps sensor.""" - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self.client = client self.organization = organization self.project = project @@ -107,7 +107,7 @@ class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): _LOGGER.warning(exception) self._attr_available = False return False - self._attr_state = build.build_number + self._attr_native_value = build.build_number self._attr_extra_state_attributes = { "definition_id": build.definition.id, "definition_name": build.definition.name, diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index b0ace5fa675..9ccd197e05e 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -94,7 +94,7 @@ class BboxUptimeSensor(SensorEntity): def __init__(self, bbox_data, sensor_type, name): """Initialize the sensor.""" self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._attr_icon = SENSOR_TYPES[sensor_type][2] self.bbox_data = bbox_data @@ -104,7 +104,7 @@ class BboxUptimeSensor(SensorEntity): uptime = utcnow() - timedelta( seconds=self.bbox_data.router_infos["device"]["uptime"] ) - self._attr_state = uptime.replace(microsecond=0).isoformat() + self._attr_native_value = uptime.replace(microsecond=0).isoformat() class BboxSensor(SensorEntity): @@ -116,7 +116,7 @@ class BboxSensor(SensorEntity): """Initialize the sensor.""" self.type = sensor_type self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._attr_icon = SENSOR_TYPES[sensor_type][2] self.bbox_data = bbox_data @@ -124,19 +124,25 @@ class BboxSensor(SensorEntity): """Get the latest data from Bbox and update the state.""" self.bbox_data.update() if self.type == "down_max_bandwidth": - self._attr_state = round( + self._attr_native_value = round( self.bbox_data.data["rx"]["maxBandwidth"] / 1000, 2 ) elif self.type == "up_max_bandwidth": - self._attr_state = round( + self._attr_native_value = round( self.bbox_data.data["tx"]["maxBandwidth"] / 1000, 2 ) elif self.type == "current_down_bandwidth": - self._attr_state = round(self.bbox_data.data["rx"]["bandwidth"] / 1000, 2) + self._attr_native_value = round( + self.bbox_data.data["rx"]["bandwidth"] / 1000, 2 + ) elif self.type == "current_up_bandwidth": - self._attr_state = round(self.bbox_data.data["tx"]["bandwidth"] / 1000, 2) + self._attr_native_value = round( + self.bbox_data.data["tx"]["bandwidth"] / 1000, 2 + ) elif self.type == "number_of_reboots": - self._attr_state = self.bbox_data.router_infos["device"]["numberofboots"] + self._attr_native_value = self.bbox_data.router_infos["device"][ + "numberofboots" + ] class BboxData: diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 2ed6b71be41..9ec81956c56 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -63,17 +63,17 @@ class BeewiSmartclimSensor(SensorEntity): self._poller = poller self._attr_name = name self._device = device - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit self._attr_device_class = self._device self._attr_unique_id = f"{mac}_{device}" def update(self): """Fetch new state data from the poller.""" self._poller.update_sensor() - self._attr_state = None + self._attr_native_value = None if self._device == DEVICE_CLASS_TEMPERATURE: - self._attr_state = self._poller.get_temperature() + self._attr_native_value = self._poller.get_temperature() if self._device == DEVICE_CLASS_HUMIDITY: - self._attr_state = self._poller.get_humidity() + self._attr_native_value = self._poller.get_humidity() if self._device == DEVICE_CLASS_BATTERY: - self._attr_state = self._poller.get_battery() + self._attr_native_value = self._poller.get_battery() diff --git a/homeassistant/components/bh1750/sensor.py b/homeassistant/components/bh1750/sensor.py index 8a1f8c60ccf..ad5ca13684a 100644 --- a/homeassistant/components/bh1750/sensor.py +++ b/homeassistant/components/bh1750/sensor.py @@ -101,7 +101,7 @@ class BH1750Sensor(SensorEntity): def __init__(self, bh1750_sensor, name, unit, multiplier=1.0): """Initialize the sensor.""" self._attr_name = name - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit self._multiplier = multiplier self.bh1750_sensor = bh1750_sensor @@ -109,7 +109,7 @@ class BH1750Sensor(SensorEntity): """Get the latest data from the BH1750 and update the states.""" await self.hass.async_add_executor_job(self.bh1750_sensor.update) if self.bh1750_sensor.sample_ok and self.bh1750_sensor.light_level >= 0: - self._attr_state = int( + self._attr_native_value = int( round(self.bh1750_sensor.light_level * self._multiplier) ) else: diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index 29945bd56dc..b66f775eae2 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -39,22 +39,22 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="trade_volume_btc", name="Trade volume", - unit_of_measurement="BTC", + native_unit_of_measurement="BTC", ), SensorEntityDescription( key="miners_revenue_usd", name="Miners revenue", - unit_of_measurement="USD", + native_unit_of_measurement="USD", ), SensorEntityDescription( key="btc_mined", name="Mined", - unit_of_measurement="BTC", + native_unit_of_measurement="BTC", ), SensorEntityDescription( key="trade_volume_usd", name="Trade volume", - unit_of_measurement="USD", + native_unit_of_measurement="USD", ), SensorEntityDescription( key="difficulty", @@ -63,7 +63,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="minutes_between_blocks", name="Time between Blocks", - unit_of_measurement=TIME_MINUTES, + native_unit_of_measurement=TIME_MINUTES, ), SensorEntityDescription( key="number_of_transactions", @@ -72,7 +72,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="hash_rate", name="Hash rate", - unit_of_measurement=f"PH/{TIME_SECONDS}", + native_unit_of_measurement=f"PH/{TIME_SECONDS}", ), SensorEntityDescription( key="timestamp", @@ -89,22 +89,22 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="total_fees_btc", name="Total fees", - unit_of_measurement="BTC", + native_unit_of_measurement="BTC", ), SensorEntityDescription( key="total_btc_sent", name="Total sent", - unit_of_measurement="BTC", + native_unit_of_measurement="BTC", ), SensorEntityDescription( key="estimated_btc_sent", name="Estimated sent", - unit_of_measurement="BTC", + native_unit_of_measurement="BTC", ), SensorEntityDescription( key="total_btc", name="Total", - unit_of_measurement="BTC", + native_unit_of_measurement="BTC", ), SensorEntityDescription( key="total_blocks", @@ -117,17 +117,17 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="estimated_transaction_volume_usd", name="Est. Transaction volume", - unit_of_measurement="USD", + native_unit_of_measurement="USD", ), SensorEntityDescription( key="miners_revenue_btc", name="Miners revenue", - unit_of_measurement="BTC", + native_unit_of_measurement="BTC", ), SensorEntityDescription( key="market_price_usd", name="Market price", - unit_of_measurement="USD", + native_unit_of_measurement="USD", ), ) @@ -182,48 +182,48 @@ class BitcoinSensor(SensorEntity): sensor_type = self.entity_description.key if sensor_type == "exchangerate": - self._attr_state = ticker[self._currency].p15min - self._attr_unit_of_measurement = self._currency + self._attr_native_value = ticker[self._currency].p15min + self._attr_native_unit_of_measurement = self._currency elif sensor_type == "trade_volume_btc": - self._attr_state = f"{stats.trade_volume_btc:.1f}" + self._attr_native_value = f"{stats.trade_volume_btc:.1f}" elif sensor_type == "miners_revenue_usd": - self._attr_state = f"{stats.miners_revenue_usd:.0f}" + self._attr_native_value = f"{stats.miners_revenue_usd:.0f}" elif sensor_type == "btc_mined": - self._attr_state = str(stats.btc_mined * 0.00000001) + self._attr_native_value = str(stats.btc_mined * 0.00000001) elif sensor_type == "trade_volume_usd": - self._attr_state = f"{stats.trade_volume_usd:.1f}" + self._attr_native_value = f"{stats.trade_volume_usd:.1f}" elif sensor_type == "difficulty": - self._attr_state = f"{stats.difficulty:.0f}" + self._attr_native_value = f"{stats.difficulty:.0f}" elif sensor_type == "minutes_between_blocks": - self._attr_state = f"{stats.minutes_between_blocks:.2f}" + self._attr_native_value = f"{stats.minutes_between_blocks:.2f}" elif sensor_type == "number_of_transactions": - self._attr_state = str(stats.number_of_transactions) + self._attr_native_value = str(stats.number_of_transactions) elif sensor_type == "hash_rate": - self._attr_state = f"{stats.hash_rate * 0.000001:.1f}" + self._attr_native_value = f"{stats.hash_rate * 0.000001:.1f}" elif sensor_type == "timestamp": - self._attr_state = stats.timestamp + self._attr_native_value = stats.timestamp elif sensor_type == "mined_blocks": - self._attr_state = str(stats.mined_blocks) + self._attr_native_value = str(stats.mined_blocks) elif sensor_type == "blocks_size": - self._attr_state = f"{stats.blocks_size:.1f}" + self._attr_native_value = f"{stats.blocks_size:.1f}" elif sensor_type == "total_fees_btc": - self._attr_state = f"{stats.total_fees_btc * 0.00000001:.2f}" + self._attr_native_value = f"{stats.total_fees_btc * 0.00000001:.2f}" elif sensor_type == "total_btc_sent": - self._attr_state = f"{stats.total_btc_sent * 0.00000001:.2f}" + self._attr_native_value = f"{stats.total_btc_sent * 0.00000001:.2f}" elif sensor_type == "estimated_btc_sent": - self._attr_state = f"{stats.estimated_btc_sent * 0.00000001:.2f}" + self._attr_native_value = f"{stats.estimated_btc_sent * 0.00000001:.2f}" elif sensor_type == "total_btc": - self._attr_state = f"{stats.total_btc * 0.00000001:.2f}" + self._attr_native_value = f"{stats.total_btc * 0.00000001:.2f}" elif sensor_type == "total_blocks": - self._attr_state = f"{stats.total_blocks:.0f}" + self._attr_native_value = f"{stats.total_blocks:.0f}" elif sensor_type == "next_retarget": - self._attr_state = f"{stats.next_retarget:.2f}" + self._attr_native_value = f"{stats.next_retarget:.2f}" elif sensor_type == "estimated_transaction_volume_usd": - self._attr_state = f"{stats.estimated_transaction_volume_usd:.2f}" + self._attr_native_value = f"{stats.estimated_transaction_volume_usd:.2f}" elif sensor_type == "miners_revenue_btc": - self._attr_state = f"{stats.miners_revenue_btc * 0.00000001:.1f}" + self._attr_native_value = f"{stats.miners_revenue_btc * 0.00000001:.1f}" elif sensor_type == "market_price_usd": - self._attr_state = f"{stats.market_price_usd:.2f}" + self._attr_native_value = f"{stats.market_price_usd:.2f}" class BitcoinData: diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py index 16f247693af..f83751fb503 100644 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -37,7 +37,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BizkaibusSensor(SensorEntity): """The class for handling the data.""" - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES def __init__(self, data, name): """Initialize the sensor.""" @@ -48,7 +48,7 @@ class BizkaibusSensor(SensorEntity): """Get the latest data from the webservice.""" self.data.update() with suppress(TypeError): - self._attr_state = self.data.info[0][ATTR_DUE_IN] + self._attr_native_value = self.data.info[0][ATTR_DUE_IN] class Bizkaibus: diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index 09bfca88776..200661dcd1c 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -20,10 +20,10 @@ class BleBoxSensorEntity(BleBoxEntity, SensorEntity): def __init__(self, feature): """Initialize a BleBox sensor feature.""" super().__init__(feature) - self._attr_unit_of_measurement = BLEBOX_TO_UNIT_MAP[feature.unit] + self._attr_native_unit_of_measurement = BLEBOX_TO_UNIT_MAP[feature.unit] self._attr_device_class = BLEBOX_TO_HASS_DEVICE_CLASSES[feature.device_class] @property - def state(self): + def native_value(self): """Return the state.""" return self._feature.current diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 1f7cad3f872..88f10183b32 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -44,7 +44,7 @@ class BlinkSensor(SensorEntity): self._attr_device_class = device_class self.data = data self._camera = data.cameras[camera] - self._attr_unit_of_measurement = units + self._attr_native_unit_of_measurement = units self._attr_unique_id = f"{self._camera.serial}-{sensor_type}" self._sensor_key = ( "temperature_calibrated" if sensor_type == "temperature" else sensor_type @@ -54,9 +54,9 @@ class BlinkSensor(SensorEntity): """Retrieve sensor data from the camera.""" self.data.refresh() try: - self._attr_state = self._camera.attributes[self._sensor_key] + self._attr_native_value = self._camera.attributes[self._sensor_key] except KeyError: - self._attr_state = None + self._attr_native_value = None _LOGGER.error( "%s not a valid camera attribute. Did the API change?", self._sensor_key ) diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index bbb9c892871..9d31d4c0583 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -48,7 +48,7 @@ class BlockchainSensor(SensorEntity): _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON - _attr_unit_of_measurement = "BTC" + _attr_native_unit_of_measurement = "BTC" def __init__(self, name, addresses): """Initialize the sensor.""" @@ -57,4 +57,4 @@ class BlockchainSensor(SensorEntity): def update(self): """Get the latest state of the sensor.""" - self._attr_state = get_balance(self.addresses) + self._attr_native_value = get_balance(self.addresses) diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 7aa2fe9baba..288a1767c7e 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -86,9 +86,13 @@ class BloomSkySensor(SensorEntity): self._sensor_name = sensor_name self._attr_name = f"{device['DeviceName']} {sensor_name}" self._attr_unique_id = f"{self._device_id}-{sensor_name}" - self._attr_unit_of_measurement = SENSOR_UNITS_IMPERIAL.get(sensor_name, None) + self._attr_native_unit_of_measurement = SENSOR_UNITS_IMPERIAL.get( + sensor_name, None + ) if self._bloomsky.is_metric: - self._attr_unit_of_measurement = SENSOR_UNITS_METRIC.get(sensor_name, None) + self._attr_native_unit_of_measurement = SENSOR_UNITS_METRIC.get( + sensor_name, None + ) @property def device_class(self): @@ -99,6 +103,6 @@ class BloomSkySensor(SensorEntity): """Request an update from the BloomSky API.""" self._bloomsky.refresh_devices() state = self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] - self._attr_state = ( + self._attr_native_value = ( f"{state:.2f}" if self._sensor_name in FORMAT_NUMBERS else state ) diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py index 60ce963bf9e..3b9589d0a6a 100644 --- a/homeassistant/components/bme280/sensor.py +++ b/homeassistant/components/bme280/sensor.py @@ -127,11 +127,11 @@ class BME280Sensor(CoordinatorEntity, SensorEntity): self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" self.temp_unit = temp_unit self.type = sensor_type - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._attr_device_class = SENSOR_TYPES[sensor_type][2] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.type == SENSOR_TEMP: temperature = round(self.coordinator.data.temperature, 1) diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py index 527a971b237..9669738b2e5 100644 --- a/homeassistant/components/bme680/sensor.py +++ b/homeassistant/components/bme680/sensor.py @@ -327,25 +327,27 @@ class BME680Sensor(SensorEntity): self.bme680_client = bme680_client self.temp_unit = temp_unit self.type = sensor_type - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._attr_device_class = SENSOR_TYPES[sensor_type][2] async def async_update(self): """Get the latest data from the BME680 and update the states.""" await self.hass.async_add_executor_job(self.bme680_client.update) if self.type == SENSOR_TEMP: - self._attr_state = round(self.bme680_client.sensor_data.temperature, 1) + self._attr_native_value = round( + self.bme680_client.sensor_data.temperature, 1 + ) if self.temp_unit == TEMP_FAHRENHEIT: - self._attr_state = round(celsius_to_fahrenheit(self.state), 1) + self._attr_native_value = round(celsius_to_fahrenheit(self.state), 1) elif self.type == SENSOR_HUMID: - self._attr_state = round(self.bme680_client.sensor_data.humidity, 1) + self._attr_native_value = round(self.bme680_client.sensor_data.humidity, 1) elif self.type == SENSOR_PRESS: - self._attr_state = round(self.bme680_client.sensor_data.pressure, 1) + self._attr_native_value = round(self.bme680_client.sensor_data.pressure, 1) elif self.type == SENSOR_GAS: - self._attr_state = int( + self._attr_native_value = int( round(self.bme680_client.sensor_data.gas_resistance, 0) ) elif self.type == SENSOR_AQ: aq_score = self.bme680_client.sensor_data.air_quality if aq_score is not None: - self._attr_state = round(aq_score, 1) + self._attr_native_value = round(aq_score, 1) diff --git a/homeassistant/components/bmp280/sensor.py b/homeassistant/components/bmp280/sensor.py index 7bf355bb736..21ab71e5ce6 100644 --- a/homeassistant/components/bmp280/sensor.py +++ b/homeassistant/components/bmp280/sensor.py @@ -78,7 +78,7 @@ class Bmp280Sensor(SensorEntity): """Initialize the sensor.""" self._bmp280 = bmp280 self._attr_name = name - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement class Bmp280TemperatureSensor(Bmp280Sensor): @@ -94,7 +94,7 @@ class Bmp280TemperatureSensor(Bmp280Sensor): def update(self): """Fetch new state data for the sensor.""" try: - self._attr_state = round(self._bmp280.temperature, 1) + self._attr_native_value = round(self._bmp280.temperature, 1) if not self.available: _LOGGER.warning("Communication restored with temperature sensor") self._attr_available = True @@ -119,7 +119,7 @@ class Bmp280PressureSensor(Bmp280Sensor): def update(self): """Fetch new state data for the sensor.""" try: - self._attr_state = round(self._bmp280.pressure) + self._attr_native_value = round(self._bmp280.pressure) if not self.available: _LOGGER.warning("Communication restored with pressure sensor") self._attr_available = True diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index df899496339..76d183bf8e8 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -516,7 +516,7 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): self._attr_device_class = attribute_info.get( attribute, [None, None, None, None] )[1] - self._attr_unit_of_measurement = attribute_info.get( + self._attr_native_unit_of_measurement = attribute_info.get( attribute, [None, None, None, None] )[2] @@ -525,24 +525,24 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): _LOGGER.debug("Updating %s", self._vehicle.name) vehicle_state = self._vehicle.state if self._attribute == "charging_status": - self._attr_state = getattr(vehicle_state, self._attribute).value + self._attr_native_value = getattr(vehicle_state, self._attribute).value elif self.unit_of_measurement == VOLUME_GALLONS: value = getattr(vehicle_state, self._attribute) value_converted = self.hass.config.units.volume(value, VOLUME_LITERS) - self._attr_state = round(value_converted) + self._attr_native_value = round(value_converted) elif self.unit_of_measurement == LENGTH_MILES: value = getattr(vehicle_state, self._attribute) value_converted = self.hass.config.units.length(value, LENGTH_KILOMETERS) - self._attr_state = round(value_converted) + self._attr_native_value = round(value_converted) elif self._service is None: - self._attr_state = getattr(vehicle_state, self._attribute) + self._attr_native_value = getattr(vehicle_state, self._attribute) elif self._service == SERVICE_LAST_TRIP: vehicle_last_trip = self._vehicle.state.last_trip if self._attribute == "date_utc": date_str = getattr(vehicle_last_trip, "date") - self._attr_state = dt_util.parse_datetime(date_str).isoformat() + self._attr_native_value = dt_util.parse_datetime(date_str).isoformat() else: - self._attr_state = getattr(vehicle_last_trip, self._attribute) + self._attr_native_value = getattr(vehicle_last_trip, self._attribute) elif self._service == SERVICE_ALL_TRIPS: vehicle_all_trips = self._vehicle.state.all_trips for attribute in ( @@ -555,13 +555,13 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): if self._attribute.startswith(f"{attribute}_"): attr = getattr(vehicle_all_trips, attribute) sub_attr = self._attribute.replace(f"{attribute}_", "") - self._attr_state = getattr(attr, sub_attr) + self._attr_native_value = getattr(attr, sub_attr) return if self._attribute == "reset_date_utc": date_str = getattr(vehicle_all_trips, "reset_date") - self._attr_state = dt_util.parse_datetime(date_str).isoformat() + self._attr_native_value = dt_util.parse_datetime(date_str).isoformat() else: - self._attr_state = getattr(vehicle_all_trips, self._attribute) + self._attr_native_value = getattr(vehicle_all_trips, self._attribute) vehicle_state = self._vehicle.state charging_state = vehicle_state.charging_status in [ChargingState.CHARGING] diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 55aa1eb5772..6ea4f3c7065 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -147,7 +147,7 @@ class TemperatureSensor(SHCEntity, SensorEntity): """Representation of an SHC temperature reporting sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC temperature reporting sensor.""" @@ -156,7 +156,7 @@ class TemperatureSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_temperature" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.temperature @@ -165,7 +165,7 @@ class HumiditySensor(SHCEntity, SensorEntity): """Representation of an SHC humidity reporting sensor.""" _attr_device_class = DEVICE_CLASS_HUMIDITY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC humidity reporting sensor.""" @@ -174,7 +174,7 @@ class HumiditySensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_humidity" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.humidity @@ -183,7 +183,7 @@ class PuritySensor(SHCEntity, SensorEntity): """Representation of an SHC purity reporting sensor.""" _attr_icon = "mdi:molecule-co2" - _attr_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC purity reporting sensor.""" @@ -192,7 +192,7 @@ class PuritySensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_purity" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.purity @@ -207,7 +207,7 @@ class AirQualitySensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_airquality" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.combined_rating.name @@ -229,7 +229,7 @@ class TemperatureRatingSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_temperature_rating" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.temperature_rating.name @@ -244,7 +244,7 @@ class HumidityRatingSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_humidity_rating" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.humidity_rating.name @@ -259,7 +259,7 @@ class PurityRatingSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_purity_rating" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.purity_rating.name @@ -268,7 +268,7 @@ class PowerSensor(SHCEntity, SensorEntity): """Representation of an SHC power reporting sensor.""" _attr_device_class = DEVICE_CLASS_POWER - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC power reporting sensor.""" @@ -277,7 +277,7 @@ class PowerSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_power" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.powerconsumption @@ -286,7 +286,7 @@ class EnergySensor(SHCEntity, SensorEntity): """Representation of an SHC energy reporting sensor.""" _attr_device_class = DEVICE_CLASS_ENERGY - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC energy reporting sensor.""" @@ -295,7 +295,7 @@ class EnergySensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{self._device.serial}_energy" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.energyconsumption / 1000.0 @@ -304,7 +304,7 @@ class ValveTappetSensor(SHCEntity, SensorEntity): """Representation of an SHC valve tappet reporting sensor.""" _attr_icon = "mdi:gauge" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC valve tappet reporting sensor.""" @@ -313,7 +313,7 @@ class ValveTappetSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_valvetappet" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.position diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 30bc8047d03..f708790a5ce 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -91,10 +91,10 @@ class BroadlinkSensor(BroadlinkEntity, SensorEntity): self._attr_device_class = SENSOR_TYPES[monitored_condition][2] self._attr_name = f"{device.name} {SENSOR_TYPES[monitored_condition][0]}" self._attr_state_class = SENSOR_TYPES[monitored_condition][3] - self._attr_state = self._coordinator.data[monitored_condition] + self._attr_native_value = self._coordinator.data[monitored_condition] self._attr_unique_id = f"{device.unique_id}-{monitored_condition}" - self._attr_unit_of_measurement = SENSOR_TYPES[monitored_condition][1] + self._attr_native_unit_of_measurement = SENSOR_TYPES[monitored_condition][1] def _update_state(self, data): """Update the state of the entity.""" - self._attr_state = data[self._monitored_condition] + self._attr_native_value = data[self._monitored_condition] diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index 95ffcf063f2..8e34f9f983b 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -87,154 +87,154 @@ SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( key=ATTR_PAGE_COUNTER, icon="mdi:file-document-outline", name=ATTR_PAGE_COUNTER.replace("_", " ").title(), - unit_of_measurement=UNIT_PAGES, + native_unit_of_measurement=UNIT_PAGES, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BW_COUNTER, icon="mdi:file-document-outline", name=ATTR_BW_COUNTER.replace("_", " ").title(), - unit_of_measurement=UNIT_PAGES, + native_unit_of_measurement=UNIT_PAGES, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_COLOR_COUNTER, icon="mdi:file-document-outline", name=ATTR_COLOR_COUNTER.replace("_", " ").title(), - unit_of_measurement=UNIT_PAGES, + native_unit_of_measurement=UNIT_PAGES, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_DUPLEX_COUNTER, icon="mdi:file-document-outline", name=ATTR_DUPLEX_COUNTER.replace("_", " ").title(), - unit_of_measurement=UNIT_PAGES, + native_unit_of_measurement=UNIT_PAGES, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_DRUM_REMAINING_LIFE, icon="mdi:chart-donut", name=ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BLACK_DRUM_REMAINING_LIFE, icon="mdi:chart-donut", name=ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_CYAN_DRUM_REMAINING_LIFE, icon="mdi:chart-donut", name=ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_MAGENTA_DRUM_REMAINING_LIFE, icon="mdi:chart-donut", name=ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_YELLOW_DRUM_REMAINING_LIFE, icon="mdi:chart-donut", name=ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BELT_UNIT_REMAINING_LIFE, icon="mdi:current-ac", name=ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_FUSER_REMAINING_LIFE, icon="mdi:water-outline", name=ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_LASER_REMAINING_LIFE, icon="mdi:spotlight-beam", name=ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_PF_KIT_1_REMAINING_LIFE, icon="mdi:printer-3d", name=ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_PF_KIT_MP_REMAINING_LIFE, icon="mdi:printer-3d", name=ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BLACK_TONER_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_CYAN_TONER_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_MAGENTA_TONER_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_YELLOW_TONER_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BLACK_INK_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_CYAN_INK_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_MAGENTA_INK_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_YELLOW_INK_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 0ff5c14d9cc..8dd150b48bf 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -64,7 +64,7 @@ class BrotherPrinterSensor(CoordinatorEntity, SensorEntity): self.entity_description = description @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" if self.entity_description.key == ATTR_UPTIME: return cast( diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index c327f9122ce..22d9ea8e5d8 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -103,4 +103,4 @@ class BrottsplatskartanSensor(SensorEntity): ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION } self._attr_extra_state_attributes.update(incident_counts) - self._attr_state = len(incidents) + self._attr_native_value = len(incidents) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 7af84f48af7..1d349fe6f53 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -364,7 +364,7 @@ class BrSensor(SensorEntity): self._attr_name = f"{client_name} {SENSOR_TYPES[sensor_type][0]}" self._attr_icon = SENSOR_TYPES[sensor_type][2] self.type = sensor_type - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._measured = None self._attr_unique_id = "{:2.6f}{:2.6f}{}".format( coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], sensor_type @@ -438,7 +438,7 @@ class BrSensor(SensorEntity): img = condition.get(IMAGE) if new_state != self.state or img != self.entity_picture: - self._attr_state = new_state + self._attr_native_value = new_state self._attr_entity_picture = img return True return False @@ -446,9 +446,11 @@ class BrSensor(SensorEntity): if self.type.startswith(WINDSPEED): # hass wants windspeeds in km/h not m/s, so convert: try: - self._attr_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + self._attr_native_value = data.get(FORECAST)[fcday].get( + self.type[:-3] + ) if self.state is not None: - self._attr_state = round(self.state * 3.6, 1) + self._attr_native_value = round(self.state * 3.6, 1) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) @@ -456,7 +458,7 @@ class BrSensor(SensorEntity): # update all other sensors try: - self._attr_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + self._attr_native_value = data.get(FORECAST)[fcday].get(self.type[:-3]) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) @@ -480,7 +482,7 @@ class BrSensor(SensorEntity): img = condition.get(IMAGE) if new_state != self.state or img != self.entity_picture: - self._attr_state = new_state + self._attr_native_value = new_state self._attr_entity_picture = img return True @@ -490,25 +492,27 @@ class BrSensor(SensorEntity): # update nested precipitation forecast sensors nested = data.get(PRECIPITATION_FORECAST) self._timeframe = nested.get(TIMEFRAME) - self._attr_state = nested.get(self.type[len(PRECIPITATION_FORECAST) + 1 :]) + self._attr_native_value = nested.get( + self.type[len(PRECIPITATION_FORECAST) + 1 :] + ) return True if self.type in [WINDSPEED, WINDGUST]: # hass wants windspeeds in km/h not m/s, so convert: - self._attr_state = data.get(self.type) + self._attr_native_value = data.get(self.type) if self.state is not None: - self._attr_state = round(data.get(self.type) * 3.6, 1) + self._attr_native_value = round(data.get(self.type) * 3.6, 1) return True if self.type == VISIBILITY: # hass wants visibility in km (not m), so convert: - self._attr_state = data.get(self.type) + self._attr_native_value = data.get(self.type) if self.state is not None: - self._attr_state = round(self.state / 1000, 1) + self._attr_native_value = round(self.state / 1000, 1) return True # update all other sensors - self._attr_state = data.get(self.type) + self._attr_native_value = data.get(self.type) if self.type.startswith(PRECIPITATION_FORECAST): result = {ATTR_ATTRIBUTION: data.get(ATTRIBUTION)} if self._timeframe is not None: diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index acb885055a3..3870cb357ef 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -144,7 +144,7 @@ class CanarySensor(CoordinatorEntity, SensorEntity): return None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" return self.reading diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 787465bb6f3..7b6445a2f35 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -85,7 +85,7 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.coordinator.data: return self.coordinator.data.isoformat() diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 7d54d259051..fd0c96c6fbe 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -265,7 +265,7 @@ class CityBikesNetwork: class CityBikesStation(SensorEntity): """CityBikes API Sensor.""" - _attr_unit_of_measurement = "bikes" + _attr_native_unit_of_measurement = "bikes" _attr_icon = "mdi:bike" def __init__(self, network, station_id, entity_id): @@ -281,7 +281,7 @@ class CityBikesStation(SensorEntity): station_data = station break self._attr_name = station_data.get(ATTR_NAME) - self._attr_state = station_data.get(ATTR_FREE_BIKES) + self._attr_native_value = station_data.get(ATTR_FREE_BIKES) self._attr_extra_state_attributes = ( { ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION, diff --git a/homeassistant/components/climacell/sensor.py b/homeassistant/components/climacell/sensor.py index 3f96dd9e02c..1ba5bbe3a34 100644 --- a/homeassistant/components/climacell/sensor.py +++ b/homeassistant/components/climacell/sensor.py @@ -68,7 +68,7 @@ class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): f"{self._config_entry.unique_id}_{slugify(description.name)}" ) self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: self.attribution} - self._attr_unit_of_measurement = ( + self._attr_native_unit_of_measurement = ( description.unit_metric if hass.config.units.is_metric else description.unit_imperial @@ -80,7 +80,7 @@ class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): """Return the raw state.""" @property - def state(self) -> str | int | float | None: + def native_value(self) -> str | int | float | None: """Return the state.""" state = self._state if ( diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index ea1cd1f6169..a4c1062e2c6 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -123,12 +123,12 @@ class CO2Sensor(update_coordinator.CoordinatorEntity[CO2SignalResponse], SensorE ) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return sensor state.""" return round(self.coordinator.data["data"][self._description.key], 2) # type: ignore[misc] @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" if self._description.unit_of_measurement: return self._description.unit_of_measurement diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index f836a604f6a..d5abb7d66f5 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -116,12 +116,12 @@ class AccountSensor(SensorEntity): return self._id @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement @@ -181,12 +181,12 @@ class ExchangeRateSensor(SensorEntity): return self._id @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 5d4ec6eec13..48ec0c46536 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -86,12 +86,12 @@ class ComedHourlyPricingSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 728bc13b76b..a6a625bab99 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -297,7 +297,7 @@ class ComfoConnectSensor(SensorEntity): self.schedule_update_ha_state() @property - def state(self): + def native_value(self): """Return the state of the entity.""" try: return self._ccb.data[self._sensor_id] @@ -325,7 +325,7 @@ class ComfoConnectSensor(SensorEntity): return SENSOR_TYPES[self._sensor_type][ATTR_ICON] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity.""" return SENSOR_TYPES[self._sensor_type][ATTR_UNIT] diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 10c5a16f60b..43e05a429b6 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -84,12 +84,12 @@ class CommandSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 35ca07ce522..257c6b4a354 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -107,7 +107,7 @@ class CompensationSensor(SensorEntity): return False @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -123,7 +123,7 @@ class CompensationSensor(SensorEntity): return ret @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py index b467a5fee12..92fdf232214 100644 --- a/homeassistant/components/coronavirus/sensor.py +++ b/homeassistant/components/coronavirus/sensor.py @@ -27,7 +27,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class CoronavirusSensor(CoordinatorEntity, SensorEntity): """Sensor representing corona virus data.""" - _attr_unit_of_measurement = "people" + _attr_native_unit_of_measurement = "people" def __init__(self, coordinator, country, info_type): """Initialize coronavirus sensor.""" @@ -53,7 +53,7 @@ class CoronavirusSensor(CoordinatorEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """State of the sensor.""" if self.country == OPTION_WORLDWIDE: sum_cases = 0 diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py index 01938344694..c34ea939de7 100644 --- a/homeassistant/components/cpuspeed/sensor.py +++ b/homeassistant/components/cpuspeed/sensor.py @@ -43,12 +43,12 @@ class CpuSpeedSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return FREQUENCY_GIGAHERTZ diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index 6a3fc7b4215..74d3d9a36a2 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -111,7 +111,7 @@ class CupsSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._printer is None: return None @@ -183,7 +183,7 @@ class IPPSensor(SensorEntity): return self._available @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._attributes is None: return None @@ -257,7 +257,7 @@ class MarkerSensor(SensorEntity): return ICON_MARKER @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._attributes is None: return None @@ -265,7 +265,7 @@ class MarkerSensor(SensorEntity): return self._attributes[self._printer]["marker-levels"][self._index] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index f42534f509b..fd3f3b2f8c5 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -65,7 +65,7 @@ class CurrencylayerSensor(SensorEntity): self._state = None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._quote @@ -80,7 +80,7 @@ class CurrencylayerSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index e36640b1c1d..c88b7da13f4 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Mapping +from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta import logging @@ -26,6 +27,8 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -34,7 +37,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, StateType _LOGGER: Final = logging.getLogger(__name__) @@ -102,14 +105,18 @@ class SensorEntityDescription(EntityDescription): state_class: str | None = None last_reset: datetime | None = None + native_unit_of_measurement: str | None = None class SensorEntity(Entity): """Base class for sensor entities.""" entity_description: SensorEntityDescription - _attr_state_class: str | None _attr_last_reset: datetime | None + _attr_native_unit_of_measurement: str | None + _attr_native_value: StateType = None + _attr_state_class: str | None + _temperature_conversion_reported = False @property def state_class(self) -> str | None: @@ -145,3 +152,94 @@ class SensorEntity(Entity): return {ATTR_LAST_RESET: last_reset.isoformat()} return None + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self._attr_native_value + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor, if any.""" + if hasattr(self, "_attr_native_unit_of_measurement"): + return self._attr_native_unit_of_measurement + if hasattr(self, "entity_description"): + return self.entity_description.native_unit_of_measurement + return None + + @property + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the entity, after unit conversion.""" + if ( + hasattr(self, "_attr_unit_of_measurement") + and self._attr_unit_of_measurement is not None + ): + return self._attr_unit_of_measurement + if ( + hasattr(self, "entity_description") + and self.entity_description.unit_of_measurement is not None + ): + return self.entity_description.unit_of_measurement + + native_unit_of_measurement = self.native_unit_of_measurement + + if native_unit_of_measurement in (TEMP_CELSIUS, TEMP_FAHRENHEIT): + return self.hass.config.units.temperature_unit + + return native_unit_of_measurement + + @property + def state(self) -> Any: + """Return the state of the sensor and perform unit conversions, if needed.""" + # Test if _attr_state has been set in this instance + if "_attr_state" in self.__dict__: + return self._attr_state + + unit_of_measurement = self.native_unit_of_measurement + value = self.native_value + + units = self.hass.config.units + if ( + value is not None + and unit_of_measurement in (TEMP_CELSIUS, TEMP_FAHRENHEIT) + and unit_of_measurement != units.temperature_unit + ): + if ( + self.device_class != DEVICE_CLASS_TEMPERATURE + and not self._temperature_conversion_reported + ): + self._temperature_conversion_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Entity %s (%s) with device_class %s reports a temperature in " + "%s which will be converted to %s. Temperature conversion for " + "entities without correct device_class is deprecated and will" + " be removed from Home Assistant Core 2022.3. Please update " + "your configuration if device_class is manually configured, " + "otherwise %s", + self.entity_id, + type(self), + self.device_class, + unit_of_measurement, + units.temperature_unit, + report_issue, + ) + value_s = str(value) + prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 + # Suppress ValueError (Could not convert sensor_value to float) + with suppress(ValueError): + temp = units.temperature(float(value), unit_of_measurement) + value = str(round(temp) if prec == 0 else round(temp, prec)) + + return value + + def __repr__(self) -> str: + """Return the representation. + + Entity.__repr__ includes the state in the generated string, this fails if we're + called before self.hass is set. + """ + if not self.hass: + return f"" + + return super().__repr__() diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 131460baa93..63e84371fad 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -539,25 +539,13 @@ class Entity(ABC): if end - start > 0.4 and not self._slow_reported: self._slow_reported = True - extra = "" - if "custom_components" in type(self).__module__: - extra = "Please report it to the custom component author." - else: - extra = ( - "Please create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - ) - if self.platform: - extra += ( - f"+label%3A%22integration%3A+{self.platform.platform_name}%22" - ) - + report_issue = self._suggest_report_issue() _LOGGER.warning( - "Updating state for %s (%s) took %.3f seconds. %s", + "Updating state for %s (%s) took %.3f seconds. Please %s", self.entity_id, type(self), end - start, - extra, + report_issue, ) # Overwrite properties that have been set in the config file. @@ -858,6 +846,23 @@ class Entity(ABC): if self.parallel_updates: self.parallel_updates.release() + def _suggest_report_issue(self) -> str: + """Suggest to report an issue.""" + report_issue = "" + if "custom_components" in type(self).__module__: + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + if self.platform: + report_issue += ( + f"+label%3A%22integration%3A+{self.platform.platform_name}%22" + ) + + return report_issue + @dataclass class ToggleEntityDescription(EntityDescription): diff --git a/tests/components/arlo/test_sensor.py b/tests/components/arlo/test_sensor.py index bf67ab21c97..2c1f3e26b54 100644 --- a/tests/components/arlo/test_sensor.py +++ b/tests/components/arlo/test_sensor.py @@ -19,53 +19,55 @@ def _get_named_tuple(input_dict): return namedtuple("Struct", input_dict.keys())(*input_dict.values()) -def _get_sensor(name="Last", sensor_type="last_capture", data=None): +def _get_sensor(hass, name="Last", sensor_type="last_capture", data=None): if data is None: data = {} sensor_entry = next( sensor_entry for sensor_entry in SENSOR_TYPES if sensor_entry.key == sensor_type ) sensor_entry.name = name - return arlo.ArloSensor(data, sensor_entry) + sensor = arlo.ArloSensor(data, sensor_entry) + sensor.hass = hass + return sensor @pytest.fixture() -def default_sensor(): +def default_sensor(hass): """Create an ArloSensor with default values.""" - return _get_sensor() + return _get_sensor(hass) @pytest.fixture() -def battery_sensor(): +def battery_sensor(hass): """Create an ArloSensor with battery data.""" data = _get_named_tuple({"battery_level": 50}) - return _get_sensor("Battery Level", "battery_level", data) + return _get_sensor(hass, "Battery Level", "battery_level", data) @pytest.fixture() -def temperature_sensor(): +def temperature_sensor(hass): """Create a temperature ArloSensor.""" - return _get_sensor("Temperature", "temperature") + return _get_sensor(hass, "Temperature", "temperature") @pytest.fixture() -def humidity_sensor(): +def humidity_sensor(hass): """Create a humidity ArloSensor.""" - return _get_sensor("Humidity", "humidity") + return _get_sensor(hass, "Humidity", "humidity") @pytest.fixture() -def cameras_sensor(): +def cameras_sensor(hass): """Create a total cameras ArloSensor.""" data = _get_named_tuple({"cameras": [0, 0]}) - return _get_sensor("Arlo Cameras", "total_cameras", data) + return _get_sensor(hass, "Arlo Cameras", "total_cameras", data) @pytest.fixture() -def captured_sensor(): +def captured_sensor(hass): """Create a captured today ArloSensor.""" data = _get_named_tuple({"captured_today": [0, 0, 0, 0, 0]}) - return _get_sensor("Captured Today", "captured_today", data) + return _get_sensor(hass, "Captured Today", "captured_today", data) class PlatformSetupFixture: @@ -88,14 +90,6 @@ def platform_setup(): return PlatformSetupFixture() -@pytest.fixture() -def sensor_with_hass_data(default_sensor, hass): - """Create a sensor with async_dispatcher_connected mocked.""" - hass.data = {} - default_sensor.hass = hass - return default_sensor - - @pytest.fixture() def mock_dispatch(): """Mock the dispatcher connect method.""" @@ -145,14 +139,14 @@ def test_sensor_name(default_sensor): assert default_sensor.name == "Last" -async def test_async_added_to_hass(sensor_with_hass_data, mock_dispatch): +async def test_async_added_to_hass(default_sensor, mock_dispatch): """Test dispatcher called when added.""" - await sensor_with_hass_data.async_added_to_hass() + await default_sensor.async_added_to_hass() assert len(mock_dispatch.mock_calls) == 1 kall = mock_dispatch.call_args args, kwargs = kall assert len(args) == 3 - assert args[0] == sensor_with_hass_data.hass + assert args[0] == default_sensor.hass assert args[1] == "arlo_update" assert not kwargs @@ -197,22 +191,22 @@ def test_update_captured_today(captured_sensor): assert captured_sensor.state == 5 -def _test_attributes(sensor_type): +def _test_attributes(hass, sensor_type): data = _get_named_tuple({"model_id": "TEST123"}) - sensor = _get_sensor("test", sensor_type, data) + sensor = _get_sensor(hass, "test", sensor_type, data) attrs = sensor.extra_state_attributes assert attrs.get(ATTR_ATTRIBUTION) == "Data provided by arlo.netgear.com" assert attrs.get("brand") == "Netgear Arlo" assert attrs.get("model") == "TEST123" -def test_state_attributes(): +def test_state_attributes(hass): """Test attributes for camera sensor types.""" - _test_attributes("battery_level") - _test_attributes("signal_strength") - _test_attributes("temperature") - _test_attributes("humidity") - _test_attributes("air_quality") + _test_attributes(hass, "battery_level") + _test_attributes(hass, "signal_strength") + _test_attributes(hass, "temperature") + _test_attributes(hass, "humidity") + _test_attributes(hass, "air_quality") def test_attributes_total_cameras(cameras_sensor): @@ -223,17 +217,17 @@ def test_attributes_total_cameras(cameras_sensor): assert attrs.get("model") is None -def _test_update(sensor_type, key, value): +def _test_update(hass, sensor_type, key, value): data = _get_named_tuple({key: value}) - sensor = _get_sensor("test", sensor_type, data) + sensor = _get_sensor(hass, "test", sensor_type, data) sensor.update() assert sensor.state == value -def test_update(): +def test_update(hass): """Test update method for direct transcription sensor types.""" - _test_update("battery_level", "battery_level", 100) - _test_update("signal_strength", "signal_strength", 100) - _test_update("temperature", "ambient_temperature", 21.4) - _test_update("humidity", "ambient_humidity", 45.1) - _test_update("air_quality", "ambient_air_quality", 14.2) + _test_update(hass, "battery_level", "battery_level", 100) + _test_update(hass, "signal_strength", "signal_strength", 100) + _test_update(hass, "temperature", "ambient_temperature", 21.4) + _test_update(hass, "humidity", "ambient_humidity", 45.1) + _test_update(hass, "air_quality", "ambient_air_quality", 14.2) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py new file mode 100644 index 00000000000..f09cd489489 --- /dev/null +++ b/tests/components/sensor/test_init.py @@ -0,0 +1,30 @@ +"""The test for sensor device automation.""" +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.setup import async_setup_component + + +async def test_deprecated_temperature_conversion( + hass, caplog, enable_custom_integrations +): + """Test warning on deprecated temperature conversion.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", native_value="0.0", native_unit_of_measurement=TEMP_FAHRENHEIT + ) + + entity0 = platform.ENTITIES["0"] + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.state == "-17.8" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert ( + "Entity sensor.test () " + "with device_class None reports a temperature in °F which will be converted to " + "°C. Temperature conversion for entities without correct device_class is " + "deprecated and will be removed from Home Assistant Core 2022.3. Please update " + "your configuration if device_class is manually configured, otherwise report it " + "to the custom component author." + ) in caplog.text diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 384db20d2d4..7c121d1c05a 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -61,7 +61,7 @@ async def async_setup_platform( async_add_entities_callback(list(ENTITIES.values())) -class MockSensor(MockEntity): +class MockSensor(MockEntity, sensor.SensorEntity): """Mock Sensor class.""" @property @@ -70,6 +70,11 @@ class MockSensor(MockEntity): return self._handle("device_class") @property - def unit_of_measurement(self): - """Return the unit_of_measurement of this sensor.""" - return self._handle("unit_of_measurement") + def native_unit_of_measurement(self): + """Return the native unit_of_measurement of this sensor.""" + return self._handle("native_unit_of_measurement") + + @property + def native_value(self): + """Return the native value of this sensor.""" + return self._handle("native_value") From 2d669a4613d41a00b75592edaca999db52bfe981 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 11 Aug 2021 11:07:04 +0200 Subject: [PATCH 322/903] Remove legacy code. (#54452) --- homeassistant/components/tradfri/__init__.py | 21 +----- homeassistant/components/tradfri/const.py | 1 - tests/components/tradfri/test_init.py | 69 -------------------- 3 files changed, 1 insertion(+), 90 deletions(-) diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 8508dab5b96..e2c90098314 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -17,7 +17,6 @@ from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import load_json from .const import ( ATTR_TRADFRI_GATEWAY, @@ -29,7 +28,6 @@ from .const import ( CONF_IDENTITY, CONF_IMPORT_GROUPS, CONF_KEY, - CONFIG_FILE, DEFAULT_ALLOW_TRADFRI_GROUPS, DEVICES, DOMAIN, @@ -69,27 +67,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): entry.data.get("host") for entry in hass.config_entries.async_entries(DOMAIN) ] - legacy_hosts = await hass.async_add_executor_job( - load_json, hass.config.path(CONFIG_FILE) - ) - - for host, info in legacy_hosts.items(): # type: ignore - if host in configured_hosts: - continue - - info[CONF_HOST] = host - info[CONF_IMPORT_GROUPS] = conf[CONF_ALLOW_TRADFRI_GROUPS] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=info - ) - ) - host = conf.get(CONF_HOST) import_groups = conf[CONF_ALLOW_TRADFRI_GROUPS] - if host is None or host in configured_hosts or host in legacy_hosts: + if host is None or host in configured_hosts: return True hass.async_create_task( diff --git a/homeassistant/components/tradfri/const.py b/homeassistant/components/tradfri/const.py index f7c2bf6cbe5..1f382548263 100644 --- a/homeassistant/components/tradfri/const.py +++ b/homeassistant/components/tradfri/const.py @@ -15,7 +15,6 @@ CONF_IDENTITY = "identity" CONF_IMPORT_GROUPS = "import_groups" CONF_GATEWAY_ID = "gateway_id" CONF_KEY = "key" -CONFIG_FILE = ".tradfri_psk.conf" DEFAULT_ALLOW_TRADFRI_GROUPS = False DOMAIN = "tradfri" KEY_API = "tradfri_api" diff --git a/tests/components/tradfri/test_init.py b/tests/components/tradfri/test_init.py index 8e11ab06f34..e8cc83a456c 100644 --- a/tests/components/tradfri/test_init.py +++ b/tests/components/tradfri/test_init.py @@ -1,81 +1,12 @@ """Tests for Tradfri setup.""" from unittest.mock import patch -from homeassistant import config_entries from homeassistant.components import tradfri from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_config_yaml_host_not_imported(hass): - """Test that we don't import a configured host.""" - MockConfigEntry(domain="tradfri", data={"host": "mock-host"}).add_to_hass(hass) - - with patch( - "homeassistant.components.tradfri.load_json", return_value={} - ), patch.object(hass.config_entries.flow, "async_init") as mock_init: - assert await async_setup_component( - hass, "tradfri", {"tradfri": {"host": "mock-host"}} - ) - await hass.async_block_till_done() - - assert len(mock_init.mock_calls) == 0 - - -async def test_config_yaml_host_imported(hass): - """Test that we import a configured host.""" - with patch("homeassistant.components.tradfri.load_json", return_value={}): - assert await async_setup_component( - hass, "tradfri", {"tradfri": {"host": "mock-host"}} - ) - await hass.async_block_till_done() - - progress = hass.config_entries.flow.async_progress() - assert len(progress) == 1 - assert progress[0]["handler"] == "tradfri" - assert progress[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - - -async def test_config_json_host_not_imported(hass): - """Test that we don't import a configured host.""" - MockConfigEntry(domain="tradfri", data={"host": "mock-host"}).add_to_hass(hass) - - with patch( - "homeassistant.components.tradfri.load_json", - return_value={"mock-host": {"key": "some-info"}}, - ), patch.object(hass.config_entries.flow, "async_init") as mock_init: - assert await async_setup_component(hass, "tradfri", {"tradfri": {}}) - await hass.async_block_till_done() - - assert len(mock_init.mock_calls) == 0 - - -async def test_config_json_host_imported( - hass, mock_gateway_info, mock_entry_setup, gateway_id -): - """Test that we import a configured host.""" - mock_gateway_info.side_effect = lambda hass, host, identity, key: { - "host": host, - "identity": identity, - "key": key, - "gateway_id": gateway_id, - } - - with patch( - "homeassistant.components.tradfri.load_json", - return_value={"mock-host": {"key": "some-info"}}, - ): - assert await async_setup_component(hass, "tradfri", {"tradfri": {}}) - await hass.async_block_till_done() - - config_entry = mock_entry_setup.mock_calls[0][1][1] - assert config_entry.domain == "tradfri" - assert config_entry.source == config_entries.SOURCE_IMPORT - assert config_entry.title == "mock-host" - - async def test_entry_setup_unload(hass, api_factory, gateway_id): """Test config entry setup and unload.""" entry = MockConfigEntry( From 2f5c3c08ef8cde919171d20b9c1f76b85cd80090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 11 Aug 2021 11:27:41 +0200 Subject: [PATCH 323/903] Use monitor name for uptimerobot device registry (#54456) --- homeassistant/components/uptimerobot/entity.py | 2 +- tests/components/uptimerobot/test_init.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index 8ef60b3848b..89ff7680eae 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -28,7 +28,7 @@ class UptimeRobotEntity(CoordinatorEntity): self._monitor = monitor self._attr_device_info = { "identifiers": {(DOMAIN, str(self.monitor.id))}, - "name": "Uptime Robot", + "name": self.monitor.friendly_name, "manufacturer": "Uptime Robot Team", "entry_type": "service", "model": self.monitor.type.name, diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 756831e7615..43f78e7a19f 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -150,6 +150,7 @@ async def test_device_management(hass: HomeAssistant): assert len(devices) == 1 assert devices[0].identifiers == {(DOMAIN, "1234")} + assert devices[0].name == "Test monitor" assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON assert hass.states.get(f"{UPTIMEROBOT_TEST_ENTITY}_2") is None From bc417162cf264a5561555f3ce74ad7bdb9bbca69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 11 Aug 2021 12:50:17 +0300 Subject: [PATCH 324/903] Fix Huawei LTE entity state updating (#54447) Since 91a2b96, we no longer key this by the router URL, but the relevant config entry unique id. Closes https://github.com/home-assistant/core/issues/54243 --- homeassistant/components/huawei_lte/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 81b715b71fe..0c545486c82 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -665,9 +665,9 @@ class HuaweiLteBaseEntity(Entity): async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self._async_maybe_update) ) - async def _async_maybe_update(self, url: str) -> None: + async def _async_maybe_update(self, config_entry_unique_id: str) -> None: """Update state if the update signal comes from our router.""" - if url == self.router.url: + if config_entry_unique_id == self.router.config_entry.unique_id: self.async_schedule_update_ha_state(True) async def async_will_remove_from_hass(self) -> None: From 4d9318419725cf9d318848f46c4b2883cd6a63ec Mon Sep 17 00:00:00 2001 From: Felix <0xFelix@users.noreply.github.com> Date: Wed, 11 Aug 2021 12:17:12 +0200 Subject: [PATCH 325/903] Strip attributes whitespace in universal media_player (#54451) When using whitespace in attributes the lookup of the state attribute fails because an entity with whitespace in its name cannot be found. Works: entity_id|state_attribute Does not work: entity_id | state_attribute Fixes #53804 --- homeassistant/components/universal/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 0ebd9eaf890..2e3e6892c1c 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -158,7 +158,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): self._cmds = commands self._attrs = {} for key, val in attributes.items(): - attr = val.split("|", 1) + attr = list(map(str.strip, val.split("|", 1))) if len(attr) == 1: attr.append(None) self._attrs[key] = attr From 4ef9269790170d94b4b8f15f591e9f9f08abfb24 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 11 Aug 2021 12:42:28 +0200 Subject: [PATCH 326/903] Replace prepare_service_call with a simpler fixture in modbus (#53975) * Convert prepare_service_call to a fixture. --- tests/components/modbus/conftest.py | 52 ++++---- tests/components/modbus/test_binary_sensor.py | 35 ++--- tests/components/modbus/test_climate.py | 124 ++++++++++++------ tests/components/modbus/test_cover.py | 90 ++++++------- tests/components/modbus/test_fan.py | 45 ++++--- tests/components/modbus/test_light.py | 45 ++++--- tests/components/modbus/test_sensor.py | 35 ++--- tests/components/modbus/test_switch.py | 45 ++++--- 8 files changed, 253 insertions(+), 218 deletions(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 86eff5e44ad..a33d0932c1d 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -1,4 +1,5 @@ """The tests for the Modbus sensor component.""" +from dataclasses import dataclass from datetime import timedelta import logging from unittest import mock @@ -24,6 +25,16 @@ TEST_MODBUS_NAME = "modbusTest" _LOGGER = logging.getLogger(__name__) +@dataclass +class ReadResult: + """Storage class for register read results.""" + + def __init__(self, register_words): + """Init.""" + self.registers = register_words + self.bits = register_words + + @pytest.fixture def mock_pymodbus(): """Mock pymodbus.""" @@ -59,9 +70,15 @@ async def mock_modbus(hass, caplog, request, do_config): } ] } + mock_pb = mock.MagicMock() with mock.patch( - "homeassistant.components.modbus.modbus.ModbusTcpClient", autospec=True - ) as mock_pb: + "homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb + ): + mock_pb.read_coils.return_value = ReadResult([0x00]) + read_result = ReadResult([0x00, 0x00]) + mock_pb.read_discrete_inputs.return_value = read_result + mock_pb.read_input_registers.return_value = read_result + mock_pb.read_holding_registers.return_value = read_result if request.param["testLoad"]: assert await async_setup_component(hass, DOMAIN, config) is True else: @@ -77,14 +94,11 @@ async def mock_test_state(hass, request): return request.param -# dataclass -class ReadResult: - """Storage class for register read results.""" - - def __init__(self, register_words): - """Init.""" - self.registers = register_words - self.bits = register_words +@pytest.fixture +async def mock_ha(hass): + """Load homeassistant to allow service calls.""" + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() async def base_test( @@ -191,21 +205,3 @@ async def base_test( # Check state entity_id = f"{entity_domain}.{device_name}" return hass.states.get(entity_id).state - - -async def prepare_service_update(hass, config): - """Run test for service write_coil.""" - - config_modbus = { - DOMAIN: { - CONF_NAME: DEFAULT_HUB, - CONF_TYPE: "tcp", - CONF_HOST: "modbusTest", - CONF_PORT: 5001, - **config, - }, - } - assert await async_setup_component(hass, DOMAIN, config_modbus) - await hass.async_block_till_done() - assert await async_setup_component(hass, "homeassistant", {}) - await hass.async_block_till_done() diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index e77fd380a22..dc9a547dc18 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import ReadResult, base_test SENSOR_NAME = "test_binary_sensor" ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" @@ -102,33 +102,34 @@ async def test_all_binary_sensor(hass, do_type, regs, expected): assert state == expected -async def test_service_binary_sensor_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: SENSOR_NAME, + CONF_ADDRESS: 1234, + CONF_INPUT_TYPE: CALL_TYPE_COIL, + } + ] + }, + ], +) +async def test_service_binary_sensor_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - config = { - CONF_BINARY_SENSORS: [ - { - CONF_NAME: SENSOR_NAME, - CONF_ADDRESS: 1234, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - } - ] - } - mock_pymodbus.read_coils.return_value = ReadResult([0x00]) - await prepare_service_update( - hass, - config, - ) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_OFF - mock_pymodbus.read_coils.return_value = ReadResult([0x01]) + mock_modbus.read_coils.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) + await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_ON diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 97d2c32ba69..71dbb6aa8a7 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import ReadResult, base_test CLIMATE_NAME = "test_climate" ENTITY_ID = f"{CLIMATE_DOMAIN}.{CLIMATE_NAME}" @@ -94,25 +94,24 @@ async def test_temperature_climate(hass, regs, expected): assert state == expected -async def test_service_climate_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: CLIMATE_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + } + ] + }, + ], +) +async def test_service_climate_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - - config = { - CONF_CLIMATES: [ - { - CONF_NAME: CLIMATE_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_SCAN_INTERVAL: 0, - } - ] - } - mock_pymodbus.read_input_registers.return_value = ReadResult([0x00]) - await prepare_service_update( - hass, - config, - ) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -120,34 +119,75 @@ async def test_service_climate_update(hass, mock_pymodbus): @pytest.mark.parametrize( - "data_type, temperature, result", + "temperature, result, do_config", [ - (DATA_TYPE_INT16, 35, [0x00]), - (DATA_TYPE_INT32, 36, [0x00, 0x00]), - (DATA_TYPE_FLOAT32, 37.5, [0x00, 0x00]), - (DATA_TYPE_FLOAT64, "39", [0x00, 0x00, 0x00, 0x00]), + ( + 35, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: CLIMATE_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_DATA_TYPE: DATA_TYPE_INT16, + } + ] + }, + ), + ( + 36, + [0x00, 0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: CLIMATE_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_DATA_TYPE: DATA_TYPE_INT32, + } + ] + }, + ), + ( + 37.5, + [0x00, 0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: CLIMATE_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_DATA_TYPE: DATA_TYPE_FLOAT32, + } + ] + }, + ), + ( + "39", + [0x00, 0x00, 0x00, 0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: CLIMATE_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_DATA_TYPE: DATA_TYPE_FLOAT64, + } + ] + }, + ), ], ) async def test_service_climate_set_temperature( - hass, data_type, temperature, result, mock_pymodbus + hass, temperature, result, mock_modbus, mock_ha ): - """Run test for service homeassistant.update_entity.""" - config = { - CONF_CLIMATES: [ - { - CONF_NAME: CLIMATE_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_DATA_TYPE: data_type, - } - ] - } - mock_pymodbus.read_holding_registers.return_value = ReadResult(result) - await prepare_service_update( - hass, - config, - ) + """Test set_temperature.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, "set_temperature", diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index 8d7e7e39cf8..b1add3e3745 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import ReadResult, base_test COVER_NAME = "test_cover" ENTITY_ID = f"{COVER_DOMAIN}.{COVER_NAME}" @@ -158,28 +158,27 @@ async def test_register_cover(hass, regs, expected): assert state == expected -async def test_service_cover_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_COVERS: [ + { + CONF_NAME: COVER_NAME, + CONF_ADDRESS: 1234, + CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, + } + ] + }, + ], +) +async def test_service_cover_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - - config = { - CONF_COVERS: [ - { - CONF_NAME: COVER_NAME, - CONF_ADDRESS: 1234, - CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, - } - ] - } - mock_pymodbus.read_holding_registers.return_value = ReadResult([0x00]) - await prepare_service_update( - hass, - config, - ) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_CLOSED - mock_pymodbus.read_holding_registers.return_value = ReadResult([0x01]) + mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -223,51 +222,52 @@ async def test_restore_state_cover(hass, mock_test_state, mock_modbus): assert hass.states.get(ENTITY_ID).state == test_state -async def test_service_cover_move(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_COVERS: [ + { + CONF_NAME: COVER_NAME, + CONF_ADDRESS: 1234, + CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, + }, + { + CONF_NAME: f"{COVER_NAME}2", + CONF_INPUT_TYPE: CALL_TYPE_COIL, + CONF_ADDRESS: 1234, + CONF_SCAN_INTERVAL: 0, + }, + ] + }, + ], +) +async def test_service_cover_move(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" ENTITY_ID2 = f"{ENTITY_ID}2" - config = { - CONF_COVERS: [ - { - CONF_NAME: COVER_NAME, - CONF_ADDRESS: 1234, - CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 0, - }, - { - CONF_NAME: f"{COVER_NAME}2", - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, - CONF_SCAN_INTERVAL: 0, - }, - ] - } - mock_pymodbus.read_holding_registers.return_value = ReadResult([0x01]) - await prepare_service_update( - hass, - config, - ) + mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) await hass.services.async_call( "cover", "open_cover", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OPEN - mock_pymodbus.read_holding_registers.return_value = ReadResult([0x00]) + mock_modbus.read_holding_registers.return_value = ReadResult([0x00]) await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_CLOSED - mock_pymodbus.reset() - mock_pymodbus.read_holding_registers.side_effect = ModbusException("fail write_") + mock_modbus.reset() + mock_modbus.read_holding_registers.side_effect = ModbusException("fail write_") await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID}, blocking=True ) - assert mock_pymodbus.read_holding_registers.called + assert mock_modbus.read_holding_registers.called assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - mock_pymodbus.read_coils.side_effect = ModbusException("fail write_") + mock_modbus.read_coils.side_effect = ModbusException("fail write_") await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID2}, blocking=True ) diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 13714d6bd0e..e0d23ad48db 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.setup import async_setup_component -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import ReadResult, base_test FAN_NAME = "test_fan" ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" @@ -277,30 +277,29 @@ async def test_fan_service_turn(hass, caplog, mock_pymodbus): assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE -async def test_service_fan_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_FANS: [ + { + CONF_NAME: FAN_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + } + ] + }, + ], +) +async def test_service_fan_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - - config = { - CONF_FANS: [ - { - CONF_NAME: FAN_NAME, - CONF_ADDRESS: 1234, - CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_VERIFY: {}, - } - ] - } - mock_pymodbus.read_discrete_inputs.return_value = ReadResult([0x01]) - await prepare_service_update( - hass, - config, - ) - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True - ) - assert hass.states.get(ENTITY_ID).state == STATE_ON - mock_pymodbus.read_coils.return_value = ReadResult([0x00]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF + mock_modbus.read_coils.return_value = ReadResult([0x01]) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + assert hass.states.get(ENTITY_ID).state == STATE_ON diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index c7b9b820934..3b3966cdf8a 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.setup import async_setup_component -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import ReadResult, base_test LIGHT_NAME = "test_light" ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" @@ -277,30 +277,29 @@ async def test_light_service_turn(hass, caplog, mock_pymodbus): assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE -async def test_service_light_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_LIGHTS: [ + { + CONF_NAME: LIGHT_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + } + ] + }, + ], +) +async def test_service_light_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - - config = { - CONF_LIGHTS: [ - { - CONF_NAME: LIGHT_NAME, - CONF_ADDRESS: 1234, - CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_VERIFY: {}, - } - ] - } - mock_pymodbus.read_discrete_inputs.return_value = ReadResult([0x01]) - await prepare_service_update( - hass, - config, - ) - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True - ) - assert hass.states.get(ENTITY_ID).state == STATE_ON - mock_pymodbus.read_coils.return_value = ReadResult([0x00]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF + mock_modbus.read_coils.return_value = ReadResult([0x01]) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + assert hass.states.get(ENTITY_ID).state == STATE_ON diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index a5ec79d62e4..ef784c9edb6 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import ReadResult, base_test SENSOR_NAME = "test_sensor" ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" @@ -599,27 +599,28 @@ async def test_restore_state_sensor(hass, mock_test_state, mock_modbus): assert hass.states.get(ENTITY_ID).state == mock_test_state[0].state -async def test_service_sensor_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: SENSOR_NAME, + CONF_ADDRESS: 1234, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, + } + ] + }, + ], +) +async def test_service_sensor_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - config = { - CONF_SENSORS: [ - { - CONF_NAME: SENSOR_NAME, - CONF_ADDRESS: 1234, - CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, - } - ] - } - mock_pymodbus.read_input_registers.return_value = ReadResult([27]) - await prepare_service_update( - hass, - config, - ) + mock_modbus.read_input_registers.return_value = ReadResult([27]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == "27" - mock_pymodbus.read_input_registers.return_value = ReadResult([32]) + mock_modbus.read_input_registers.return_value = ReadResult([32]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index c620429aad2..48c8ca9e15f 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -39,7 +39,7 @@ from homeassistant.core import State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import ReadResult, base_test from tests.common import async_fire_time_changed @@ -291,33 +291,32 @@ async def test_switch_service_turn(hass, caplog, mock_pymodbus): assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE -async def test_service_switch_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SWITCHES: [ + { + CONF_NAME: SWITCH_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + } + ] + }, + ], +) +async def test_service_switch_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - - config = { - CONF_SWITCHES: [ - { - CONF_NAME: SWITCH_NAME, - CONF_ADDRESS: 1234, - CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_VERIFY: {}, - } - ] - } - mock_pymodbus.read_discrete_inputs.return_value = ReadResult([0x01]) - await prepare_service_update( - hass, - config, - ) - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True - ) - assert hass.states.get(ENTITY_ID).state == STATE_ON - mock_pymodbus.read_coils.return_value = ReadResult([0x00]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF + mock_modbus.read_coils.return_value = ReadResult([0x01]) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + assert hass.states.get(ENTITY_ID).state == STATE_ON async def test_delay_switch(hass, mock_pymodbus): From 6285c7775b836b6a6fe4f47037f967c54e7072ce Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 11 Aug 2021 15:56:41 +0200 Subject: [PATCH 327/903] Use SensorEntityDescription and set state class measurement for NUT sensors (#54269) --- homeassistant/components/nut/config_flow.py | 13 +- homeassistant/components/nut/const.py | 805 ++++++++++++++------ homeassistant/components/nut/sensor.py | 47 +- 3 files changed, 586 insertions(+), 279 deletions(-) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 0b5ad8bbc1f..70c097bd6f1 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -25,19 +25,12 @@ from .const import ( DOMAIN, KEY_STATUS, KEY_STATUS_DISPLAY, - SENSOR_NAME, SENSOR_TYPES, ) _LOGGER = logging.getLogger(__name__) -SENSOR_DICT = { - sensor_id: sensor_spec[SENSOR_NAME] - for sensor_id, sensor_spec in SENSOR_TYPES.items() -} - - def _base_schema(discovery_info): """Generate base schema.""" base_schema = {} @@ -59,15 +52,15 @@ def _resource_schema_base(available_resources, selected_resources): """Resource selection schema.""" known_available_resources = { - sensor_id: sensor[SENSOR_NAME] - for sensor_id, sensor in SENSOR_TYPES.items() + sensor_id: sensor_desc.name + for sensor_id, sensor_desc in SENSOR_TYPES.items() if sensor_id in available_resources } if KEY_STATUS in known_available_resources: known_available_resources[KEY_STATUS_DISPLAY] = SENSOR_TYPES[ KEY_STATUS_DISPLAY - ][SENSOR_NAME] + ].name return { vol.Required(CONF_RESOURCES, default=selected_resources): cv.multi_select( diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 1f5fecdd219..b48121eeaf8 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -1,10 +1,17 @@ """The nut component.""" + +from __future__ import annotations + +from typing import Final + from homeassistant.components.sensor import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, ) from homeassistant.const import ( ELECTRIC_CURRENT_AMPERE, @@ -40,246 +47,559 @@ PYNUT_MODEL = "model" PYNUT_FIRMWARE = "firmware" PYNUT_NAME = "name" -SENSOR_TYPES = { - "ups.status.display": ["Status", "", "mdi:information-outline", None], - "ups.status": ["Status Data", "", "mdi:information-outline", None], - "ups.alarm": ["Alarms", "", "mdi:alarm", None], - "ups.temperature": [ - "UPS Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "ups.load": ["Load", PERCENTAGE, "mdi:gauge", None], - "ups.load.high": ["Overload Setting", PERCENTAGE, "mdi:gauge", None], - "ups.id": ["System identifier", "", "mdi:information-outline", None], - "ups.delay.start": ["Load Restart Delay", TIME_SECONDS, "mdi:timer-outline", None], - "ups.delay.reboot": ["UPS Reboot Delay", TIME_SECONDS, "mdi:timer-outline", None], - "ups.delay.shutdown": [ - "UPS Shutdown Delay", - TIME_SECONDS, - "mdi:timer-outline", - None, - ], - "ups.timer.start": ["Load Start Timer", TIME_SECONDS, "mdi:timer-outline", None], - "ups.timer.reboot": ["Load Reboot Timer", TIME_SECONDS, "mdi:timer-outline", None], - "ups.timer.shutdown": [ - "Load Shutdown Timer", - TIME_SECONDS, - "mdi:timer-outline", - None, - ], - "ups.test.interval": [ - "Self-Test Interval", - TIME_SECONDS, - "mdi:timer-outline", - None, - ], - "ups.test.result": ["Self-Test Result", "", "mdi:information-outline", None], - "ups.test.date": ["Self-Test Date", "", "mdi:calendar", None], - "ups.display.language": ["Language", "", "mdi:information-outline", None], - "ups.contacts": ["External Contacts", "", "mdi:information-outline", None], - "ups.efficiency": ["Efficiency", PERCENTAGE, "mdi:gauge", None], - "ups.power": ["Current Apparent Power", POWER_VOLT_AMPERE, "mdi:flash", None], - "ups.power.nominal": ["Nominal Power", POWER_VOLT_AMPERE, "mdi:flash", None], - "ups.realpower": [ - "Current Real Power", - POWER_WATT, - None, - DEVICE_CLASS_POWER, - ], - "ups.realpower.nominal": [ - "Nominal Real Power", - POWER_WATT, - None, - DEVICE_CLASS_POWER, - ], - "ups.beeper.status": ["Beeper Status", "", "mdi:information-outline", None], - "ups.type": ["UPS Type", "", "mdi:information-outline", None], - "ups.watchdog.status": ["Watchdog Status", "", "mdi:information-outline", None], - "ups.start.auto": ["Start on AC", "", "mdi:information-outline", None], - "ups.start.battery": ["Start on Battery", "", "mdi:information-outline", None], - "ups.start.reboot": ["Reboot on Battery", "", "mdi:information-outline", None], - "ups.shutdown": ["Shutdown Ability", "", "mdi:information-outline", None], - "battery.charge": [ - "Battery Charge", - PERCENTAGE, - None, - DEVICE_CLASS_BATTERY, - ], - "battery.charge.low": ["Low Battery Setpoint", PERCENTAGE, "mdi:gauge", None], - "battery.charge.restart": [ - "Minimum Battery to Start", - PERCENTAGE, - "mdi:gauge", - None, - ], - "battery.charge.warning": [ - "Warning Battery Setpoint", - PERCENTAGE, - "mdi:gauge", - None, - ], - "battery.charger.status": ["Charging Status", "", "mdi:information-outline", None], - "battery.voltage": [ - "Battery Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "battery.voltage.nominal": [ - "Nominal Battery Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "battery.voltage.low": [ - "Low Battery Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "battery.voltage.high": [ - "High Battery Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "battery.capacity": ["Battery Capacity", "Ah", "mdi:flash", None], - "battery.current": [ - "Battery Current", - ELECTRIC_CURRENT_AMPERE, - "mdi:flash", - None, - ], - "battery.current.total": [ - "Total Battery Current", - ELECTRIC_CURRENT_AMPERE, - "mdi:flash", - None, - ], - "battery.temperature": [ - "Battery Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "battery.runtime": ["Battery Runtime", TIME_SECONDS, "mdi:timer-outline", None], - "battery.runtime.low": [ - "Low Battery Runtime", - TIME_SECONDS, - "mdi:timer-outline", - None, - ], - "battery.runtime.restart": [ - "Minimum Battery Runtime to Start", - TIME_SECONDS, - "mdi:timer-outline", - None, - ], - "battery.alarm.threshold": [ - "Battery Alarm Threshold", - "", - "mdi:information-outline", - None, - ], - "battery.date": ["Battery Date", "", "mdi:calendar", None], - "battery.mfr.date": ["Battery Manuf. Date", "", "mdi:calendar", None], - "battery.packs": ["Number of Batteries", "", "mdi:information-outline", None], - "battery.packs.bad": [ - "Number of Bad Batteries", - "", - "mdi:information-outline", - None, - ], - "battery.type": ["Battery Chemistry", "", "mdi:information-outline", None], - "input.sensitivity": [ - "Input Power Sensitivity", - "", - "mdi:information-outline", - None, - ], - "input.transfer.low": [ - "Low Voltage Transfer", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "input.transfer.high": [ - "High Voltage Transfer", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "input.transfer.reason": [ - "Voltage Transfer Reason", - "", - "mdi:information-outline", - None, - ], - "input.voltage": [ - "Input Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "input.voltage.nominal": [ - "Nominal Input Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "input.frequency": ["Input Line Frequency", FREQUENCY_HERTZ, "mdi:flash", None], - "input.frequency.nominal": [ - "Nominal Input Line Frequency", - FREQUENCY_HERTZ, - "mdi:flash", - None, - ], - "input.frequency.status": [ - "Input Frequency Status", - "", - "mdi:information-outline", - None, - ], - "output.current": ["Output Current", ELECTRIC_CURRENT_AMPERE, "mdi:flash", None], - "output.current.nominal": [ - "Nominal Output Current", - ELECTRIC_CURRENT_AMPERE, - "mdi:flash", - None, - ], - "output.voltage": [ - "Output Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "output.voltage.nominal": [ - "Nominal Output Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "output.frequency": ["Output Frequency", FREQUENCY_HERTZ, "mdi:flash", None], - "output.frequency.nominal": [ - "Nominal Output Frequency", - FREQUENCY_HERTZ, - "mdi:flash", - None, - ], - "ambient.humidity": [ - "Ambient Humidity", - PERCENTAGE, - None, - DEVICE_CLASS_HUMIDITY, - ], - "ambient.temperature": [ - "Ambient Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], +SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { + "ups.status.display": SensorEntityDescription( + key="ups.status.display", + name="Status", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.status": SensorEntityDescription( + key="ups.status", + name="Status Data", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.alarm": SensorEntityDescription( + key="ups.alarm", + name="Alarms", + unit_of_measurement=None, + icon="mdi:alarm", + device_class=None, + state_class=None, + ), + "ups.temperature": SensorEntityDescription( + key="ups.temperature", + name="UPS Temperature", + unit_of_measurement=TEMP_CELSIUS, + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ups.load": SensorEntityDescription( + key="ups.load", + name="Load", + unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ups.load.high": SensorEntityDescription( + key="ups.load.high", + name="Overload Setting", + unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + device_class=None, + state_class=None, + ), + "ups.id": SensorEntityDescription( + key="ups.id", + name="System identifier", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.delay.start": SensorEntityDescription( + key="ups.delay.start", + name="Load Restart Delay", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + device_class=None, + state_class=None, + ), + "ups.delay.reboot": SensorEntityDescription( + key="ups.delay.reboot", + name="UPS Reboot Delay", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + device_class=None, + state_class=None, + ), + "ups.delay.shutdown": SensorEntityDescription( + key="ups.delay.shutdown", + name="UPS Shutdown Delay", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + device_class=None, + state_class=None, + ), + "ups.timer.start": SensorEntityDescription( + key="ups.timer.start", + name="Load Start Timer", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + device_class=None, + state_class=None, + ), + "ups.timer.reboot": SensorEntityDescription( + key="ups.timer.reboot", + name="Load Reboot Timer", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + device_class=None, + state_class=None, + ), + "ups.timer.shutdown": SensorEntityDescription( + key="ups.timer.shutdown", + name="Load Shutdown Timer", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + device_class=None, + state_class=None, + ), + "ups.test.interval": SensorEntityDescription( + key="ups.test.interval", + name="Self-Test Interval", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + device_class=None, + state_class=None, + ), + "ups.test.result": SensorEntityDescription( + key="ups.test.result", + name="Self-Test Result", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.test.date": SensorEntityDescription( + key="ups.test.date", + name="Self-Test Date", + unit_of_measurement=None, + icon="mdi:calendar", + device_class=None, + state_class=None, + ), + "ups.display.language": SensorEntityDescription( + key="ups.display.language", + name="Language", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.contacts": SensorEntityDescription( + key="ups.contacts", + name="External Contacts", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.efficiency": SensorEntityDescription( + key="ups.efficiency", + name="Efficiency", + unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ups.power": SensorEntityDescription( + key="ups.power", + name="Current Apparent Power", + unit_of_measurement=POWER_VOLT_AMPERE, + icon="mdi:flash", + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ups.power.nominal": SensorEntityDescription( + key="ups.power.nominal", + name="Nominal Power", + unit_of_measurement=POWER_VOLT_AMPERE, + icon="mdi:flash", + device_class=None, + state_class=None, + ), + "ups.realpower": SensorEntityDescription( + key="ups.realpower", + name="Current Real Power", + unit_of_measurement=POWER_WATT, + icon=None, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ups.realpower.nominal": SensorEntityDescription( + key="ups.realpower.nominal", + name="Nominal Real Power", + unit_of_measurement=POWER_WATT, + icon=None, + device_class=DEVICE_CLASS_POWER, + state_class=None, + ), + "ups.beeper.status": SensorEntityDescription( + key="ups.beeper.status", + name="Beeper Status", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.type": SensorEntityDescription( + key="ups.type", + name="UPS Type", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.watchdog.status": SensorEntityDescription( + key="ups.watchdog.status", + name="Watchdog Status", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.start.auto": SensorEntityDescription( + key="ups.start.auto", + name="Start on AC", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.start.battery": SensorEntityDescription( + key="ups.start.battery", + name="Start on Battery", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.start.reboot": SensorEntityDescription( + key="ups.start.reboot", + name="Reboot on Battery", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "ups.shutdown": SensorEntityDescription( + key="ups.shutdown", + name="Shutdown Ability", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "battery.charge": SensorEntityDescription( + key="battery.charge", + name="Battery Charge", + unit_of_measurement=PERCENTAGE, + icon=None, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + "battery.charge.low": SensorEntityDescription( + key="battery.charge.low", + name="Low Battery Setpoint", + unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + device_class=None, + state_class=None, + ), + "battery.charge.restart": SensorEntityDescription( + key="battery.charge.restart", + name="Minimum Battery to Start", + unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + device_class=None, + state_class=None, + ), + "battery.charge.warning": SensorEntityDescription( + key="battery.charge.warning", + name="Warning Battery Setpoint", + unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + device_class=None, + state_class=None, + ), + "battery.charger.status": SensorEntityDescription( + key="battery.charger.status", + name="Charging Status", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "battery.voltage": SensorEntityDescription( + key="battery.voltage", + name="Battery Voltage", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon=None, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "battery.voltage.nominal": SensorEntityDescription( + key="battery.voltage.nominal", + name="Nominal Battery Voltage", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon=None, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=None, + ), + "battery.voltage.low": SensorEntityDescription( + key="battery.voltage.low", + name="Low Battery Voltage", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon=None, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=None, + ), + "battery.voltage.high": SensorEntityDescription( + key="battery.voltage.high", + name="High Battery Voltage", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon=None, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=None, + ), + "battery.capacity": SensorEntityDescription( + key="battery.capacity", + name="Battery Capacity", + unit_of_measurement="Ah", + icon="mdi:flash", + device_class=None, + state_class=None, + ), + "battery.current": SensorEntityDescription( + key="battery.current", + name="Battery Current", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + "battery.current.total": SensorEntityDescription( + key="battery.current.total", + name="Total Battery Current", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + device_class=None, + state_class=None, + ), + "battery.temperature": SensorEntityDescription( + key="battery.temperature", + name="Battery Temperature", + unit_of_measurement=TEMP_CELSIUS, + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "battery.runtime": SensorEntityDescription( + key="battery.runtime", + name="Battery Runtime", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + device_class=None, + state_class=None, + ), + "battery.runtime.low": SensorEntityDescription( + key="battery.runtime.low", + name="Low Battery Runtime", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + device_class=None, + state_class=None, + ), + "battery.runtime.restart": SensorEntityDescription( + key="battery.runtime.restart", + name="Minimum Battery Runtime to Start", + unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + device_class=None, + state_class=None, + ), + "battery.alarm.threshold": SensorEntityDescription( + key="battery.alarm.threshold", + name="Battery Alarm Threshold", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "battery.date": SensorEntityDescription( + key="battery.date", + name="Battery Date", + unit_of_measurement=None, + icon="mdi:calendar", + device_class=None, + state_class=None, + ), + "battery.mfr.date": SensorEntityDescription( + key="battery.mfr.date", + name="Battery Manuf. Date", + unit_of_measurement=None, + icon="mdi:calendar", + device_class=None, + state_class=None, + ), + "battery.packs": SensorEntityDescription( + key="battery.packs", + name="Number of Batteries", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "battery.packs.bad": SensorEntityDescription( + key="battery.packs.bad", + name="Number of Bad Batteries", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "battery.type": SensorEntityDescription( + key="battery.type", + name="Battery Chemistry", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "input.sensitivity": SensorEntityDescription( + key="input.sensitivity", + name="Input Power Sensitivity", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "input.transfer.low": SensorEntityDescription( + key="input.transfer.low", + name="Low Voltage Transfer", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon=None, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=None, + ), + "input.transfer.high": SensorEntityDescription( + key="input.transfer.high", + name="High Voltage Transfer", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon=None, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=None, + ), + "input.transfer.reason": SensorEntityDescription( + key="input.transfer.reason", + name="Voltage Transfer Reason", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "input.voltage": SensorEntityDescription( + key="input.voltage", + name="Input Voltage", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon=None, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "input.voltage.nominal": SensorEntityDescription( + key="input.voltage.nominal", + name="Nominal Input Voltage", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon=None, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=None, + ), + "input.frequency": SensorEntityDescription( + key="input.frequency", + name="Input Line Frequency", + unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:flash", + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + "input.frequency.nominal": SensorEntityDescription( + key="input.frequency.nominal", + name="Nominal Input Line Frequency", + unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:flash", + device_class=None, + state_class=None, + ), + "input.frequency.status": SensorEntityDescription( + key="input.frequency.status", + name="Input Frequency Status", + unit_of_measurement=None, + icon="mdi:information-outline", + device_class=None, + state_class=None, + ), + "output.current": SensorEntityDescription( + key="output.current", + name="Output Current", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + "output.current.nominal": SensorEntityDescription( + key="output.current.nominal", + name="Nominal Output Current", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + device_class=None, + state_class=None, + ), + "output.voltage": SensorEntityDescription( + key="output.voltage", + name="Output Voltage", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon=None, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "output.voltage.nominal": SensorEntityDescription( + key="output.voltage.nominal", + name="Nominal Output Voltage", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon=None, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=None, + ), + "output.frequency": SensorEntityDescription( + key="output.frequency", + name="Output Frequency", + unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:flash", + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + "output.frequency.nominal": SensorEntityDescription( + key="output.frequency.nominal", + name="Nominal Output Frequency", + unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:flash", + device_class=None, + state_class=None, + ), + "ambient.humidity": SensorEntityDescription( + key="ambient.humidity", + name="Ambient Humidity", + unit_of_measurement=PERCENTAGE, + icon=None, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ambient.temperature": SensorEntityDescription( + key="ambient.temperature", + name="Ambient Temperature", + unit_of_measurement=TEMP_CELSIUS, + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), } STATE_TYPES = { @@ -299,8 +619,3 @@ STATE_TYPES = { "FSD": "Forced Shutdown", "ALARM": "Alarm", } - -SENSOR_NAME = 0 -SENSOR_UNIT = 1 -SENSOR_ICON = 2 -SENSOR_DEVICE_CLASS = 3 diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 1eb67e45aa5..456778c3ca5 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -1,9 +1,15 @@ """Provides a sensor to track various status aspects of a UPS.""" +from __future__ import annotations + import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.nut import PyNUTData +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ATTR_STATE, CONF_RESOURCES, STATE_UNKNOWN -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import ( COORDINATOR, @@ -16,11 +22,7 @@ from .const import ( PYNUT_MODEL, PYNUT_NAME, PYNUT_UNIQUE_ID, - SENSOR_DEVICE_CLASS, - SENSOR_ICON, - SENSOR_NAME, SENSOR_TYPES, - SENSOR_UNIT, STATE_TYPES, ) @@ -60,7 +62,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator, data, name.title(), - sensor_type, + SENSOR_TYPES[sensor_type], unique_id, manufacturer, model, @@ -82,18 +84,18 @@ class NUTSensor(CoordinatorEntity, SensorEntity): def __init__( self, - coordinator, - data, - name, - sensor_type, - unique_id, - manufacturer, - model, - firmware, - ): + coordinator: DataUpdateCoordinator, + data: PyNUTData, + name: str, + sensor_description: SensorEntityDescription, + unique_id: str, + manufacturer: str | None, + model: str | None, + firmware: str | None, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._type = sensor_type + self.entity_description = sensor_description self._manufacturer = manufacturer self._firmware = firmware self._model = model @@ -101,10 +103,7 @@ class NUTSensor(CoordinatorEntity, SensorEntity): self._data = data self._unique_id = unique_id - self._attr_device_class = SENSOR_TYPES[self._type][SENSOR_DEVICE_CLASS] - self._attr_icon = SENSOR_TYPES[self._type][SENSOR_ICON] - self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][SENSOR_NAME]}" - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][SENSOR_UNIT] + self._attr_name = f"{name} {sensor_description.name}" @property def device_info(self): @@ -128,16 +127,16 @@ class NUTSensor(CoordinatorEntity, SensorEntity): """Sensor Unique id.""" if not self._unique_id: return None - return f"{self._unique_id}_{self._type}" + return f"{self._unique_id}_{self.entity_description.key}" @property def state(self): """Return entity state from ups.""" if not self._data.status: return None - if self._type == KEY_STATUS_DISPLAY: + if self.entity_description.key == KEY_STATUS_DISPLAY: return _format_display_state(self._data.status) - return self._data.status.get(self._type) + return self._data.status.get(self.entity_description.key) @property def extra_state_attributes(self): From b1fc05413abf61449e2d12e4162332352b84c67c Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 11 Aug 2021 10:13:38 -0400 Subject: [PATCH 328/903] Bump notifications-android-tv to 0.1.3 (#54462) Co-authored-by: Martin Hjelmare --- homeassistant/components/nfandroidtv/__init__.py | 7 +------ homeassistant/components/nfandroidtv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 90a76c1c747..35aecdb6916 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1,6 +1,4 @@ """The NFAndroidTV integration.""" -import logging - from notifications_android_tv.notifications import ConnectError, Notifications from homeassistant.components.notify import DOMAIN as NOTIFY @@ -12,8 +10,6 @@ from homeassistant.helpers import discovery from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [NOTIFY] @@ -41,8 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await hass.async_add_executor_job(Notifications, host) except ConnectError as ex: - _LOGGER.warning("Failed to connect: %s", ex) - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady("Failed to connect") from ex hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { diff --git a/homeassistant/components/nfandroidtv/manifest.json b/homeassistant/components/nfandroidtv/manifest.json index 5516f144fd4..c1dea03aa09 100644 --- a/homeassistant/components/nfandroidtv/manifest.json +++ b/homeassistant/components/nfandroidtv/manifest.json @@ -2,7 +2,7 @@ "domain": "nfandroidtv", "name": "Notifications for Android TV / Fire TV", "documentation": "https://www.home-assistant.io/integrations/nfandroidtv", - "requirements": ["notifications-android-tv==0.1.2"], + "requirements": ["notifications-android-tv==0.1.3"], "codeowners": ["@tkdrob"], "config_flow": true, "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index 8fe9b1c6d57..76c2a9a1170 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1041,7 +1041,7 @@ niluclient==0.1.2 noaa-coops==0.1.8 # homeassistant.components.nfandroidtv -notifications-android-tv==0.1.2 +notifications-android-tv==0.1.3 # homeassistant.components.notify_events notify-events==1.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67caf01a0e4..cf1c915d751 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -582,7 +582,7 @@ nettigo-air-monitor==1.0.0 nexia==0.9.11 # homeassistant.components.nfandroidtv -notifications-android-tv==0.1.2 +notifications-android-tv==0.1.3 # homeassistant.components.notify_events notify-events==1.0.4 From cb26f334c35767b40a1f58cbe273a5e477a721c0 Mon Sep 17 00:00:00 2001 From: Phil Cole Date: Wed, 11 Aug 2021 15:34:36 +0100 Subject: [PATCH 329/903] Use pycarwings2 2.11 (#54424) --- homeassistant/components/nissan_leaf/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nissan_leaf/manifest.json b/homeassistant/components/nissan_leaf/manifest.json index 298343d2d8d..55cd28d59fa 100644 --- a/homeassistant/components/nissan_leaf/manifest.json +++ b/homeassistant/components/nissan_leaf/manifest.json @@ -2,7 +2,7 @@ "domain": "nissan_leaf", "name": "Nissan Leaf", "documentation": "https://www.home-assistant.io/integrations/nissan_leaf", - "requirements": ["pycarwings2==2.10"], + "requirements": ["pycarwings2==2.11"], "codeowners": ["@filcole"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 76c2a9a1170..bbf9e8344f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1345,7 +1345,7 @@ pyblackbird==0.5 pybotvac==0.0.22 # homeassistant.components.nissan_leaf -pycarwings2==2.10 +pycarwings2==2.11 # homeassistant.components.cloudflare pycfdns==1.2.1 From 13c34d646f5c4cb186b4170fb60630da2602229c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 11 Aug 2021 17:09:16 +0200 Subject: [PATCH 330/903] Remove empty currency from discovery info (#54478) --- homeassistant/components/api/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 0a11cf04651..a91d8540286 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -43,7 +43,6 @@ from homeassistant.helpers.system_info import async_get_system_info _LOGGER = logging.getLogger(__name__) ATTR_BASE_URL = "base_url" -ATTR_CURRENCY = "currency" ATTR_EXTERNAL_URL = "external_url" ATTR_INTERNAL_URL = "internal_url" ATTR_LOCATION_NAME = "location_name" @@ -196,7 +195,6 @@ class APIDiscoveryView(HomeAssistantView): # always needs authentication ATTR_REQUIRES_API_PASSWORD: True, ATTR_VERSION: __version__, - ATTR_CURRENCY: None, } with suppress(NoURLAvailableError): From 1e14b3a0ac0e50ed388362671c71afe7ea5d449a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Aug 2021 10:12:46 -0500 Subject: [PATCH 331/903] Ensure camera handles non-jpeg image sources correctly (#54474) --- homeassistant/components/camera/__init__.py | 3 +-- homeassistant/components/demo/camera.py | 13 +++++++--- homeassistant/components/demo/demo_0.png | Bin 0 -> 224227 bytes homeassistant/components/demo/demo_1.png | Bin 0 -> 232693 bytes homeassistant/components/demo/demo_2.png | Bin 0 -> 231077 bytes homeassistant/components/demo/demo_3.png | Bin 0 -> 232693 bytes tests/components/camera/test_init.py | 27 ++++++++++++++++++-- 7 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/demo/demo_0.png create mode 100644 homeassistant/components/demo/demo_1.png create mode 100644 homeassistant/components/demo/demo_2.png create mode 100644 homeassistant/components/demo/demo_3.png diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index c6cada2e3c9..14cd64df920 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -177,8 +177,7 @@ async def _async_get_image( if ( width is not None and height is not None - and "jpeg" in content_type - or "jpg" in content_type + and ("jpeg" in content_type or "jpg" in content_type) ): assert width is not None assert height is not None diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index b3f9b505aee..572a5bf331e 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -8,7 +8,12 @@ from homeassistant.components.camera import SUPPORT_ON_OFF, Camera async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Demo camera platform.""" - async_add_entities([DemoCamera("Demo camera")]) + async_add_entities( + [ + DemoCamera("Demo camera", "image/jpg"), + DemoCamera("Demo camera png", "image/png"), + ] + ) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -19,10 +24,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoCamera(Camera): """The representation of a Demo camera.""" - def __init__(self, name): + def __init__(self, name, content_type): """Initialize demo camera component.""" super().__init__() self._name = name + self.content_type = content_type self._motion_status = False self.is_streaming = True self._images_index = 0 @@ -32,7 +38,8 @@ class DemoCamera(Camera): ) -> bytes: """Return a faked still image response.""" self._images_index = (self._images_index + 1) % 4 - image_path = Path(__file__).parent / f"demo_{self._images_index}.jpg" + ext = "jpg" if self.content_type == "image/jpg" else "png" + image_path = Path(__file__).parent / f"demo_{self._images_index}.{ext}" return await self.hass.async_add_executor_job(image_path.read_bytes) diff --git a/homeassistant/components/demo/demo_0.png b/homeassistant/components/demo/demo_0.png new file mode 100644 index 0000000000000000000000000000000000000000..f45852e3b20f9a3b42238e89814ccdf8b1a63d37 GIT binary patch literal 224227 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⪼{8W_aM_Pcr2 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%0DQL$*01mV@ItaAp!n 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 q5bhI^wtpU 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

lmd1%xmQC~I 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

4VX* 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%0AgQxAGO~Y@M)*NuTm23usfbp&47cx`qV&1O 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 literal 0 HcmV?d00001 diff --git a/homeassistant/components/demo/demo_2.png b/homeassistant/components/demo/demo_2.png new file mode 100644 index 0000000000000000000000000000000000000000..97a8e49025d4a6f2b5914e1cf95773e7bace2817 GIT binary patch literal 231077 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°0WqA|FD$8;r9|O1eMIbSH*H zxFaRtPNo_N5KWz!N#CrNo2yId8ef(eTPkrd&YDS8&ufXGF`k^9{Pkb|^=L2*A^h8a z`)`xG&}OqqN?pkd;-5~IN!S`QrxG^J6gExM_Kli2&&-sp%T49{{OtMD6YXSLjVIZF zqCsb@h(M@pDi~po;|OQj;3Y#CU(r* zVqseD35U$GECs^vcR-k$=+@3K*~f@Tk>y0BsC#egH%2eq)4l=oJg=MeX0tIf@4dtz zBFNW)@J+xbydu|>-u&il(>5n1x~>jygu`uo-NK>Q6=A}ug2YW-cM^xg$zV3ghSgE_ za3R*8Un1I&M|nBw*4?75ODJ_=)9l{EJMZ7Ud*5X~VsIH%!x>utRr`yJldoCpDU@I? z_K;n=VO27zcLUau2J#LSTr3-&W4LUwCC`~kY?~lW-)`3T#uN90=?@+}ICdGgEd~_< z9%PNQrzFV(1%dT&# zlBctAQgsH=&1SQlFTk)UDl+Q())}KB2}afAyG=~2TQABj$$W>O$}I~zYRG+74XZ47 z3FW8VyjJ3x0kCskqODiWYPE^N*>tOrL6K}7#qEX7S8V!bYwVq#Itd@((@gZb>7Yq4DM6|b8glU4%Irr_;z&8hM-6EYK zge2WdMaui^)y8D1Id4$fw)6E&&bj2hl5pm>2_oVhzW&@2yXQ@0vzI!BFKsirL2ca$ z)V?KCNO^1Q`ns-z#VpS$_jTw>%ZkC+7R$D2{9t_d?)};1NZJ$IsMqrD@jIXV_|Jau z@sHiGxLRFYuC59{Iy|VP3Cl+p%|%lHXUuGVpstD-L*t1`SEA+=qo|>S_F}y{Uxm7J z0}|2Ya<%E2Zds42YBoB4aCn@>&~7%M%*Nn2^x<-Gx!kNr#rSY`)C^}>W#IBlVOZ3P z8#Z;$X^cH+p_-RD6Kqy}U9T+$@AIn2v&=w4j4`yGh?ZqpRTV~-5D7pGQ4j?Ujmh?j z)Xh`y?V%-VOr(DxQVl?FNM)E^-$`B9P1A{%NDS;^=$f`iq^u~7$dmyhkptsh<9OS= z{AaERSu}KQi`E|=6hHpay`TQ+(f#{ySO6k80^)?j7(hf&S2;&4AevOk030|^AOKe&XMxGMd>AipO;K67#TCG-1)1(}6GMS7 zln4n-Pyvw`$mn+{qwtdS!wucMTMvj^SrhJ-CON)RO|k)i`Xp}pl6Rm200V<{(l z&W9+@IRFw9Kvq+FSsY$(sdRD2-;Elbb870GGq9p49zJ}yUavp@^=EyIAAI;xJ{Tzg zF)q(e7cU+)_3F-ScF3G*h$MCFIlwT>qDH-;s)s2h)U^_Ry7v*PZ3L9PJ+oR8{Eou?Lp6WRk5dqO4vAeSMA^{Q>#zN&_43Nyy& zGJi{8zG2dNEldlD(R(wXwpVs8_VvZZ1(G^I=_SpIsw$v~nR5<>%#uAR5)tk?g%IN) zFE-$p^LlZ)8C5ePQtPGP`em!Bh!PQ!Lo|AMZfsxXX>t%r#D&D(HZV2S?v48`_QCKa z_*B5mtOV*}j2f2B#*Igl>ViD!@G(E*+OG=YT4h305W%VNO{a zGF`NnPtUHtyc~{)b%!q&%SE`HPTv`i@}uF=klAV=8)0Td5XAa<)0}M3it52V8<)Or z-UoNk!FmlR^EjRql;t%FXz!ZVyOGv}=EAD2FHS}yc<=ab7BKUhy!7>|SuB(v#L5vg-5Eu5S#&d=KMuo(DK1kUD{SL(HV1nP^BlQyr;-?g(rK0FgojaHa?VmP3+d1}L%|lz>tk^0KpX z2^s-0&&xb7XS2hu>ryS~ob%q#regyO(lavwwSB890EXz&?+h9l&>RUs)QFM1bKux= zXPFZ-5_N_Qm;qrIEsEu?cbJ*wNUO^2IkQ9iGQPETrw;=6qRZUs7bM6nWB76&f@V1)jyRs0S zr$mBEAZMyq&;zKjcY|>iHr8veU|_Jky!=dhpF2any1W>a6$BaN*=DnuOs465$aynE z#@Ac5^i7nKo&EY&k9#;Aj>qG!>z+P)=A4(Ea)fByELWS&rpmd~D40O?}+# zX@5(sDBU?jFhSYISE}o}-PC55=Or_Pnj#aT8387VB1+{J10pgSi0EFeo0&J8&E@6g zbUMxRyevzNVVghRz)?k{Z`(IMthnQdrXSnSFbTvr=9_xmEJPx*ow|kZ1(<^BYPA|w zWwO-hMT~@*^E@ZS7^BbfYBVI~F6dxbjqe;4$1@!jip;qU_3l?EC*IqK50Bsd*MG7( zznGsrm#9%W7s-&kK};hhfV}l1BUM|k>Zi|6;2E@`p-RSgrsLr_ry}-()1j}p))jR! z8I))1{&W*Y@4ffQCqEx&53Ww@=5(;Qnr{~CX1)sZb<>4TI_YYTjEJ5&Bd5e$K_t<& z8Ja``bUr9Xh*2S+02<{?-T__vE~V|Ms!5|qOuMeD+iu;|2eZ5AGjcux1_N@QybB@@ zDa{lmnJ>D|&}g6D?)iv@w__r=jV%FNTA2V_!AnhZYz{)J(laSx*R=D+BD3i7B2;Bn zl|_(FEfOU)LBa3#i?mh|KRK)rhJ2ZOv+KIN%o3W)d9@#Z0swNHJSDyh!fbn@z#dA0y`Ii)Oc{2giiFp+HT~A- z&A*^P_&W-x+Xb<$^nHfTIb>FAg9N3r8awRqGPC@z=%Q7H!+yJvtub z*=BL)@c5t{4!q07G@0-vYBTL6Rd)~L?Xhql8&V)F^Kv$ut=H?-YUQ(hd$5{yb#r-s z(YM{*su*};j4^g*JriX}-lEd)(lEB`I?8!68V(kVi{<>qv&YlBcYRd?A_6A_Hm1>( zml@2u%Qro{7-QSEi9bz9qViU4&;4NHOLxu`6;xH7%Mf63xmeDZ$@I=dC8~%RVxpgj zSro6#^MDNu^tN%g+s+>(Q$Y3(eY9v|gvi7tmlyLFS7(p<C z{PD;4@82UrwZNDRbt2!2o!wxx?FMyjkVLLmG9~vu(6sE6!C4~0DCjG+uRZZQ{S13f zx>2cLNI)qUZrgUV*@O`M*k>;5+klA3WM)CPzk-NN<6Gzjdq&LbjJd>xNh6P25k<3` zj09E9b~;u!qoD4Sb$IhY#UETC)VChcZ(4BnZQp0hTF^bqmWU9Qh={q<5G^2cG0st4 zzv=p}R|c(nKnT#DFHe8-r8_@+Ui$3GWICLUM$^f?;|IXB_g=W3c(Yey;qARTOq58X zEpR^=3?`Gwa6D?8PDC6Mf-bJ+XD?oOr0KBCn5^$mLgtVNGc#w78PxQC-)kr>A}2 zBeKS@F9g%VNZQ;f5bn>8#6wFh+tiKs-ZS$aQ|aZ+&}d-Z*V&ZR^;FHQ%5wIujnS=F zL1K)N90Oq;>fv}i84WYXUDt6r8BWHKxt_rtjK=TZ=i|wopoywgfzug3ADx_^o%Ne{ z?;Mqf2OR(+_TZ-0dqW3cK-)t=(||*MFgu!(2=Kt{77r*@SaenyD2d9tk-{q_Vddu9jb) z#jbBQbze8q_Av_S-X5CFsEK_agJKMQx7n88sARRTO{nvycAbFaF}^KmYkV?>s2-Vh@d~g9JF11zFb^9e>#{=@?i#;yy8d!A_#%)S(Sb0SL>CTjYcD9+B8ky z_v6VhO`Gg#X5Um1ZYLXeI24L)zuRou%d?BF?kX;roJoL04>VPyG%A{~BN2&FL~Nhh z?iq;Qdx_RG4I-vMn08SLa^CwiYgJX%D@klB?} z)gdAgZMtsJwwn;CkaD8evs7mlu(dN0p9>HijBiok{Kv z?;JjR`t`-tCEuA1#v}5XwIXJqMwUFPw>hMM#2EXCSw0#L4$8qW1VM9Zr36%^zU?GL z@0`mW5N+yaS=T40XTfYTot49Zh)CZvW^5Xp4uc#{#-%&He|*F?hyjaYh_I~6XNg2 z?Re8j*EH{M`nFrIR`u1QTW(_8fhZa9xxVLD6v@C8q=y=P zUQ9qDg~BliIXAjjmg9^asD$Nuae02V{6Ct(aCrC5{U3e&alsja$yWaUhMl^-oI-vR zrjLk00sssKgX6n*m+MU`O@>+4w_Rx34pK*fh@7c{VPd>!jOh7|DbIK9p#ouw=m5}n zn?YV2j>eni^7*5$ruUA=!%+s#)Pjf*1h9O!Sfj-GeE#_HV-dM`@7_E2-*1{mL`I{L z_x@Y6&lCM6?cUUeVZ`LW1e@i0IbT6Ab}oj{HhoHBVl)InU^7$eQyPzI0K}B^s>nH3 zRRA0e1_;|o=3J&Rq|`M9!el;`;?^rn?kz5=mdeT$KCkyGznP6D;Pypu8oHNM5uySi zWIij4=Vvb-KYr{EM&^9iF8s;|h#>@)U)LqJ3$D;EmSrcRt@pz|4Tn z5KWmNhV^3p{OK2ee|GYzt)BxfqirDcV9cLwkPuS=?7Cgkv(X8vbzVCeoX|N_DA%j?q-I}0tRaPmtUdP z$48-y^Z7iRPKzubl$FK)YO(Z1nYkjwJ_hp{6Op{>=Ijpf?~2MW=`tj z-sNfYGkZ|kvPO4#1+cwFeGETbMMaK!Ch0HFV-=@U_8qFpk9Zwr>9K%@ctbmJiod;yIR&=*tF5I ztQr)>c&v^jDl!3qnZyo5r$tc>hePk#lmMbYw^6!SEIvOy{q*F;+Agz|3#QG%Ln+HX zo4ITj%B<D}3EQWfK>91ll>VV1EgM|nA!7K&Tb zWdmp%hzh{vx<*8wF+cw8VvM75S&T{{6_q5ap^6D7-)ihJZ<_Y2M^B!eHQ)y&l}35& z;%2qUyd$O9MI_Xy$jqF8xKvdjB_tCtVH!K9A=s~nH}C8>9`O{x5Kuq>Nf=?%ES^9A z?UToUx12u)tATXS&>>I&K0CYmFaPBq?%#j-@WK0k_GdrK^E}2Vw~F1!n_}Th^UZi8 z9D48eD%6a$vrV-vaz>Q4Msg@TIyzb`7H4NKQWGSl%gg0*wOXB=oSdGX=9C@Hj*F~F zZ=Pk@{s2li3hZ&tZ$XfGeM6}#JG$xTnxt?l3~;0QM?^ullG_Hb&7)tLv-$(?5&m<( z(v}F(5^L0F+e47Ej6DYJq6I|Ds?4)TG|!nDW@BfpZCA5!8|zIljNuo*_{C&2_3S*j zpZxg8xyyk}X;<4!P9qsqMUHJ;UCpzLGcJbSRYatK2Bap8Oh9Q|w{6?B zZT6JW)F9k)l-{N+^JY?!C9^q1GiZ0-o{S_Si6fc_mX5QkD2T`xybpwk%t*=JR#nQf zG=skHnS8oh03xt`AiTy^ZunN*$U&c|H4~A7f%*v`951Uv= z42?FM*tFPhAT$)m86uM@s#u~E7?Bbf(`3$dPoIwl z`O%$&pmMRCKYwwiS& zrL01uc+R%te2BE`8Y;jZdi~Z#;WgRkCTlbuGF{gNme;0x?G@N?uPFIu)+i#Xh=Rc$ zGqkP4`0pI*q<_MD(k+#r-KtTbZ|p)f7y#8EFqtPq?m4qwdPLA@3>i}f0KE!$afd(^ z1Gwmd_rktgU(}nnckImRda?Px{(t{}jt+xo&VKmgj|n-E)Kyi%m|0X&%{$*}|B6-u zArX+;)^v9-E|bV|z%-psx~ADIFP}?4Da(U9cVC=cG>@NnRC@P8b}+H!GStEO%&rZD zbw8AY2!LuTZ$x4d>7^LGOE9zTxLi;I0*J9=fTLk~8D;(AIkHmhc{nPHaaoQg3Xtc{ zF#r(f$CEqnKm1Tcjt=iFmgjY|I6N8`g$EEK^z6mtwk4p^yh~$tF||(wL`+=7!e?mk z;>pGN({pFpYc9e-nv;$7oY z$9IdM_b=A#E{5nFRU=`)SS&R3kh@LS*UL7nV%V(Cp;W=!;(Fl;pYiq#}$Ai)N z)%xk##k!4GSIy{P7DjguW;5^&!qKzS_E%s2vOH+q;J7FD-e-B~oXeftO0g@7^|gm<|m$BXe1V91o z1bbrW#lWHmG?3N0``iEW*B}1i-GBe@|6hsWtPu7>VmrhE0YLXh{Y&%A*QB{uJj-sP zMtgBMc)xdiXK$lN1u1-(MfJ`9@8zG=a00=R( z5tVXM2T=u*;)%nXQThh5YX(YWgh^z7s}t;jR`qhTSsjhWMae*#$F8qqY}T=_JCwF> z1dTW+b54B2F-8LgkBC6YxEl!pP*futFcMj-aj5Elk2Xxd_gBKlRKl)UxlM~KKnhqp z%2gDNUG(Hc^y>8TZ~o?QG-zAbo6Z07!ykWqIGg~0iHJ%{UaqAw_x4@eHoLv0bLKHi zG7Z}Hs@jD}jz`l;y*Xd5R~J_o$CFWBX3$EI{sulwH}stW>~}?>filn9&HC!a`MQgc z7sK)4(ZO++71?;=oMZ1YUmVURAH4T|AH(5vc5(h(L{iR}KE8s!#eszxMNKs-2s=bz z28T$X5h3f^etvnrUM;fB4G(9Bv)O=0De7aY`-q5>l103wSNg451veQ?w?XUQ6^g(1 zgu21B@UHA)0OIj@I-MSzHJ5}oo{ZRM`EaDfv5$9WM|E94dG@5~JLjA*JLkruQ85_K z*Q-UpSBLth6SFh1Ag0dyA}`w-7R%-7^K)Hna%rL3UZ%G@P`qqHTmNN<{ zp&|jc&1Qaev0l%C8h{3%hh3?c6qJ)7OF|&O{`%|3k00N=caNEa{Cwi9I6c|7WT~QQyUUu4v7&#}m_y`(D zCWjp{`4n5noK4>%NQK&HJSkTO11m!>^AeD zeRfu4<-v674Ylvspc;-Jyz}rfEH~@r+2U$YWwWv{RqcXp`GMbzG=B5_*)^G8VZ9FW ztm}LuBCB=3u4U8fy7oRB93EEHFdtNI40)En_s)Z+@0`z9tF?#@hojsVVv>C32yeYA zLI|SUwuB)gD4-c2no3yCFV9|_*6Zc4Dkp>Scrc`x-*~HQd&0D&?Pb+^yq&E2#(4QX z%y-^mpO%8Zs;MdB)>Vr+PG)z;58ipN%*Z25t8!T6&%gT3Z@%~} zbeq9w(4)oJk4B^W@4P!+Ezee$n$zWSH7F|&SyX9bXe3zd8YFQLdTAjtfP%Fc8r2wW zdHyux`oXpFYEfu5VC<~8JCq6m3ZG7h(S;U^n?!4 z8yXrLIA9aX%uHhleF!~+vUlv6lrRE^NUL5*dr~DdMhbD^d|)4)F)P+rmjmBaM<11W z;aQ_7Idr}x#V&RP%9s~<(F8j=y*zob=r#AlSw6i#9331U+~JvepMU-IiO5;qFAG|& zyPVK5XT-U8h0BJSFTKypssbkG9J5Dqhy;jc=xCILhyX0L3muZuF0r;7)At5|#NfR- z6Gk9Uu-UAy&Y#Qrig*du04gL(#NXJtLq8?BVL_>L35>KlXk9{Q2p{|MK&n{NyJe zee_YDs8bTn%sWp&Ns8-6kKyg7trF01Fv@)q@YF@8%#)0<; zs9oF^f5|{%8zH`bEKK;%J{AJNzCxfj&TQG$PtRWjhIj7YA0HhBr6?wy ziiSRf5JHE|dI_h`mnY9J*H?8n)%mJ@e)jYipFe(bdJ)k9Q<%)0&(UWcQcun>6QZq0 zE3qJ>5uzb5QU*v-^R922rVm{(0iTsf8D}|UogwuAq=phrBdQT##Eh{AZPrh6}&|7I!1xTaYrTU3%}6{_vwmkLtSqJqALD4gd&fJ9NLB z+lw)(Dk{bpHAqec683#BB3;)dx$W(bnW?GW0#>Oe^@1%k0Ml5T5i_Gv===3@K0m)$ zlUw#75POM5mQ!Dcz9pXtn1GREOvYPy)6O0Nz(m0e4M7djOx09D|K|&YL@u>X2ZPaI zFg#ykjGEs*V_;~8DkcibXfDr&lRPwBw<~SI01P#1IKMjY{-L|LI6FUk`saWC=Rg1X z&p-O%$A^c9j-9B47})teM0PF6dHWfyV#wf-ioAp@*I33rfW~1-MtJ%J=9?9pW|PCJ zC_1Tk{XTpx7JkRdW-kV7*Rc=)awc#T6%;T-=+Po*KeMpyIGi;IKtWHzkw4uuhpj}EK4eQ|a2`IFD*{o=4c%5rk%uQ z(S*=M>0<0dXeG3(Wp7X%&K@Y1Pfo9%UvPO>Q&vvj0Aj80(N!Ntu9ZVJo<7mf0j|5m%x5W z3{g-7W56h_sWWwGyj}_-5jt|7DMQYgJpy}w&g8w%$uW~-_T(}mQs;L9E{@p~GqXoP zPyz%4CCnUp3rlI2U^P&4owcUHS)-JYCH11@4gTWAi>7IQuZS8NnAmo7eP7old8WNj zb(UokWV5N)^*Y9wI5m6qiPwGPZ@fQQqUZqcIy*`7TVjl{>pR5FdeyN91(%n7(`pPk zagljo*P<$@;cmnNZ_Yj?9-I;pP|~}R|9M%XW@v^8JRFS2<1swL5TYvG3Q;0S0sv4D z0lieCf~?4bZRT~WN&zzA*vqC_e)XiAFV3GofBx**vw!>ZzxeY%|MPd>d(W|BVgNEQ zGD<`Y)NL%h4U$cjjo5bUAhXZW%(M3gLA$QI=-S1)ALk*j3ZM+Oy#&1x3*mcKgs;Uy z0MHPgwr<;|soS=7-VbLJS5_K>_xW^s;9Ry4T~c>&uH(#{FafWsyyWeDs66KYI6IR^&r0 zn>_#J#o5V=uaWfQkA7TChU?Yp>7&QZ=8A(nzg(U#PK$}VH#@vHJbFkzFN?u+Sd1$y zoH*i~eUVSA`o((j_4!GNoq|jcrZCF6U0+@-%PP;a(R4ahaf^Aq?qsp7`$ZJ(5-DGW z9A}f!Y*5@sq4Q<^)#>Hat9A**vqP)~>U{6K5d(4{5Jgiqg{bP&RJ$pXp@FI-r_I%B z5n~inpJ!#67e&FzDiYX|fLR&?NfYjb3b`imfB*qqHZiQ1Yu)tNH2v9pJsghj9B1Tl z6Jonw<%b_ZUIgSO#9mEIyRO;Ps~4A7>*dwSqhFsq{ru|ugsgSsG^%4G1SRqrnK}b! z$^b~Jrf7y5DJZK^Iy}ivfnDNkAvr`&!>e43wq!~2%-*@oaYjTr4%*M)6U!u|eDf+*<=(9H_wtI6D!;ww-&UJ&?^eysXyBZSeQcU$u~;Bt z;tFifG!Y}b%@7-flCEAWeb;p%2onJSqx0k)F{)|T1q{J}5LU}M)a%i}Pp9Ke2-<5O zL)~p6porL3j7AeQiipn8z>rYQ)S~nu_Cb*U=hGS$6BI`{9F9k$36TpS#M`C&5OP3g zh=8ilk*O>FhzkfqU|{Za15zs(#Hg`u+D+58?Zw5#)924#ym;}i{_M{__~3)tY?kLG zyyoWf7Ic)snTeU1f}%KLAObVws+t}h-)pVwH!q$qHjM|LT|Y>=5ewlhf#+Ms!W%mv zI0KioMw!KPBnSi^%52r=h>60m>n!P^>8>mIy!RBiC)V!eVeg*RiHG%5n*+? z`1-ex&rU8h;C2GK7a;Z;Ase2%JR&iP_=zPkF= zuRce#cLsx;`Jei=D zea)*TdMe5?_pVwk`c<>(0xat;(-Nuh88EZsd{7;D9)})Ri}~toadNe~3_#-}H=abF zn;|g8ZF852$c)4w=~yG^R`kP6CG<_(_pxo3&bg|{vlIng(hV_C21uC^8367G802%a z-eG}gpvW||AXn$j)t$EQ^kP|8m&%q zh_O9?`kUCC69n(Tk@gZ$8`PxZz{n2KfsvUpu>zT~3aWV(j7nyp(t?3RQei*>AOI3G zT{-|lU_@fdQ!FIpEX#yIkcwg|-Jso^Tik#)+d?qGFXXo4A7COcO{}Joq~C8v$izfU zKmbTv{|h3b5CRZ5=MW&qSl9Jtvr*OIaA**c!F@oBD4hA`7PddB?=2FAPWooCs_WWu zfyl|f2@y>+bX_OX19EbA>HB;-sH$ptd0Dp|HqFJ=l_A9#O@Wz{Y$O07A}c5YdQ(N z(L0J!IlyMK`QnQ&t`_r)i;Jhvp8dsN{KY4qeDc8uKiF%L+#JMy%XKm{kyp?!tt z@^Uymx?^pv>rJy*uArHggWr|J+8ztVojA^xJA;M*6ai6C!J+~a8aQ%;JkOk42D@A@ zyC+YortYFlW_QNpF(U^J%t@ykRQ0y&v>gT15h)`e#3o*zUp;&FVzcSWtQw7G1?I64 z6EvhC?G39YOC7lVT>NdWp0`zmuZOYQu-xy;CcQx-FjPY{q^(fNre4+cI`ece9gfCD zSukW-ndL?-N}gTL4nyZ+uhJuFiEK(hwj9hg}ZQLja2q5z#dGVR3(aA0lJ|jO0|aJil}NAcii6Sg*UL4a-fSd{E`X zk|#bRpCO`oS61br1Xr)?rzbDY&gbh8xS06y#PXsM5ixXm(x6PD=Y-B90Z`xfutf#| zAew}>ZQHu>ITnR4t1`b5_fSkI#8o zZAhnmzcz?~y2JAkkZ&lNnyT%|GAR;#SKjhJM=V50Quu~Dp(RA@y3RP!s6ozoT-BcM2H0 zK6+nGW)FNZyINh8z`UJl*h}H~R)K2n-NN$C>XSG@+fiY`NMzK3h(Y?@t~K3ib-Bgao8u z4z_MOL4xND07OM~3j+613BVjkjEvkj;ql|IpFcZUFBgNN8kIv(i53u103?RI+(`@W zo#jQ%3=?S-?53865s=-c%_uuA@s`xE?+wG7Ja`SSo4b1ByC$~Ja&E>yq|6ruF|>8l z^|b?WzF@}4;c|8Q=*5?#X+Aq1=1#o!p88IA^5_xhoC({TFHiYfqP zF?#l9PNj|5W}^y4AD!3#^^4~S{rO_>bh*aqqj&G$eY9Nu{TE-i?Th)Fo$)F7Rpfu9vaVFx}ZU0gzer>%Ti5+iJWuT2Nm@`B4P|z+reLv&R4tbLuRI8 z!9tXnVFpNGNaP4JX6HB{qHzc6Ltj@RxOM^IDLVlxz&ZfiPaN8QxDG)3!~8 z#;F!I*lVXGZBqld&3^QINLSu+-U0wv1j)b|ih?n5AESiGfWvB_Dt#B`=U2=5N)V>A zBPyMXohrl_y+%U`O&!R=OZM^Gv#5`?p@Q`xbfIraEF;d4!NpeF=&YDdve`k`gD;1e z7eMEmKw>dQHD&MeGAl)cNA`|);wD>X%0gZAhr@42z_Z_;XUUtw~>`eo-QNn7~{`R-?#p+kT`qihO zeg4BAfBe&*{`A8SKfL$w-grFrnctBqw#~wAH3M6Nzo3BtnYWODf`|s^Jput>F&N#s zcNgmQ`t0fX)traZGB22uIXgLIGc^f{^m-Kh-puKn2YoVX2-cT87!C$K00y{NuC6YY zNX-06GF#ZH$C()t?wrJv!o8|yz5swe^o!*c2#f~9+!w3$>iMImzxnj@tE(&RgcLK( zva%#1c8u&8P@t^UX5E zW;B`|A0G?`!!FqR;DD?h3iL_)C8#N*Y$N1HX(Ld=7z)Js2C&&bPM{?RsenX9A15DLXrgI3<XOpzZawV%CW#r)cJ_`DlZG`aqwEa4R8=Kfa;eOVfWSn|?3sX25F;WB_6Xt#oKw(^ z=q2C+aGg|ZUcoPtbC4xbVUwz=#^Z64wEq2)Wyru@jvQ$hgbG9p6F;)}G zif2#OeV&oekeqX_bXiqZTvlnH?gC-bl*XHQPkOlJ?&aRt7DD@H?x_3@S)kwXLd}rK zsRcqFjmD$F5RJ48Sf$ELw?>&eo4s9PKsRoU0)V(4)Q~XCd|rB=XXuDZ>aR?iMq2{| z1!`h{a`xh4e)aXUNALXVoxlIZ-+%JSCqMbgPd@zc!v_x@93LN-S$WgRHo0QHg0{7x z2T|`mJJpfQ-DY)_@%#jV~3!8wU5mTLgR4TOGhQWo0)JzwD+f-%}gotRXEfp9-en+WJPE4&+8p!jn7 zN4sYcU(Z-?W7)joRv1D^lEmJ7KnOuq)e&Y{)^_#z<;B_AS-|y+7tf4jG98S@Rj>1| zYn|5&O;s#L0RaRcFeW3C*sq%_v2HZZX5%c+xeQRb+_Ph5GK(mnCe$GkWMB*uR46D~ zB#9!ZXa;3weIMGUuQ#z>OV_H3WAtS{@h;EvGKD~d%#1-XgFINIxFxbLm&>OoSEuLe zwUOo}l>>8G+$x;!XIXVuAqfD%R=!I_Vjr8PT{V3JU`3G)24$I*K}7qgDhf#3`MaBw zKMFT09Ya+_GX!I_T)`7N$nx|0MV@=ikNSw8J-W;tIH2*JkGvmsF|OJT`m87l@0|k$ z6u^KK5d|LO?M@Pxa1;68##^0S%7z2tK z;8tgq02q(~`#y$lMX=FMPx@`c`gynjB$G_br%KGuSMvL>AEutD+na#$+Tx!7{UGmbwwi>`LEUA)^6+WG@Y}p!Bicg0dCr6Q!n$vmqn(6Wl

zlj(3;8U(Ot5~GEZBB+{*fB~Z-2$CQeg*I%m94F&4_p040_7MdN_8Nj%BmhNI#7G1T ziowv#wAF~n+o|fiWQw`>qdX z^ZN91z3!l^D)V`d+$k$N=UGkq7*lICxu|X{Yy&f6h<&J=b`xXoz4JcHixL3ZK5id` z^Na{YX{7o(5aP|I4JbwfP+%kiU@-u22(jf!kXUeTIj(EzqTV|?I-JhxwtMmH*``P5 z3T8%#85uHdLSqPkDXJQx0u!Q1P*K^^w$m_<8JMC)+iF;2%2Y*7Rr{t%B_NnF5HY3E zZ)PEi%$ORDrdzfYV}>9MK!%J|cr87x5u(9M4T%`bz$K4UJy2Bn;@g_ul@+#6cu8 zF+~w`E*q4Cj66wa2Bx%CN8Z^Po9WB80(t|Y3E&LPqKJx+&v@hqgF^C*hCn5@tFCXN zv}U4eY6j9bb=_QDU7ehqeE#|8zx>Bve(=EupM3Jk`|rR1(MKO0-8~wQ$AiIukPt9f zAS47M!anwOyhLd4v81sY`K!EjXN=Mr0ul3W6}Uf5{cF-k<)+M96%Jv%@9hyV7A zU;gr!U;OshhttE@#OGfm&xA=0CYqvVMiKqH_zL^S{-F-3*0S%s!9i+na6=AP=l zVN;j6j38PB(=?PLW@=`Nf`OS;OhRPhEH5Nl-|NZqRlVu@)*KB6`LxIl<#~)i zU}Q?L<@7L-!>B|??4r4Oy?S=JxTr(#%TQK|t_KPVKuLfG)B1Cvv;vGNK+mH>iqTSTPh}EjAj=4 z2GR}SG)K7QH;U~Pt`&64`~))~CQ|@WAR@rrmxUjZE%#XH4UOsjrjumka(sOJ?z``n zWoh>NtXKB1C^Ej5aX#d$F^pQFnRI074SP!*AkIgRZj?GPZ_KQ-JpEUv51-Trmgkq-dvcUD3F z_X}*_@q!guO#w(%894WaCy&Sy)9fWB?IcLY^*(^S^lQ?sj9X}q)BqGBF%dch8e({TU-+lLoKm6f)AHMhS;lp>{ zdFS}g>X@?G(fvGM%{Ld%pP#L3W`9_f z&gCl9MfG@H69g`GKUAk?v|`{k8G z$Ogq|IGByfsiT1zTSSc+A|}Q^Ap<2?7KjuPja?t*#d7&wpS=DdQA>Na0bb{d!( z=L*`ZiKy#(Go#zb2@O+@=%LWlt1Tt++3%an{UGoeFuyVkYZPTCp~3_Sr5#WWAX zF3Yo_cSVpEUi0CDogk5l4TjYp{m~!2_uhN^F5Pz`5B{#0AG$N*W#=MJ#dJUP{_DqMZu(kmz50G-;+!uxKOP6KchOODO76B2ZH|PNo zEW&!VY3uHCe(~thqfbBm^x*j5-o1MteDJ}CAANZD?p?~+IX51TilPWzSJ!o2*UhFr zn%=#0@4@|t?+mI@no0m79?{(oe%MA`Jb%9I%sU-DViC~IW_@yU@(=&;5C7Z$_P_u2U;p*l z*_rCPtK-NI{`8YSeemGHcr@im?AYhN@Kr2h2qA=!3_`v4LE;uj*=3aJSA-bNOl+6& z(YF2z5^u)Bz2m&ewL1AW5^OEg;N}d*J{AJ%%?-TQ@O7S$oGLlzvMk&A*AoE<2%!Uj zrfaX33zzY|`}f!VRnu(%W1c&Mpc0AEA#F$)36WR{RWKq0IU;C!XnJqR1{$K70AX)I zLNYfqxamzc)4E9n@!6Ua~}y3T)_k?0#S{@6NKceoMwK2_trOU+lO8p=T$XwoU5=J z6Z-ANr|usZ2=1yPeI*2@f3lM81$HC;v$y_Y4V^kHq_pb1%(Y5u31_U65 z9iEQ+LT|fD#d;t>Z>l0-Bzx>O;ym#;3@A>>p z!dSchy6t-OB9=Kn8jXg-v9m2Mz&lCnJs^4GLs4SMUN;`ns0?uuMKmsU; z5il5usxvSkF#>XX{T=6=BaGLZdPkEn!mR-Mn*!l2)S&-?Uu0%TjtE86JC`}v8_6EW zBQn5m#2zpmJrV_ofC*SrL&qbap_xN2vZ^Y|;h^g3c6k~?7i-xF0s@jp?GlVuw{3fM zb>$wp;c)n?U;XOc_usvH_iiyNvn-oTCc|oAW)i|?vso^ePO}d``r)7c?BD$0hd-Rn zrs&8a0r}z4@#ylZS=N19uiM^ZHpo3Y0BL@Qd*CL5k?z%KcIcuiP}}zN_5#|_g*Yp# zj2#e}MAeiLZ`bs?Ue|T~=+Wc9|NFoH&;R*9|L6bwSI-|k0%pL70C%TH@4WNQI}aZY z%5rtNR+F4Eat`xcM4~E^rLEQVF|qYhB-@%&17wa<#F8yWxV_-~wj5xKl+r8X6VB01 z{>vT(WrzFfFd6xJ@0nRl_j~>&^ZK|0^gu%hZQqQF@oY9b{dyB(;~cTWzU^Y{ONVMv z*>hrUfSD>7k{}a%V+Wj}M0OM$7HBx z3L1eZnc<~ihQR5h+iaQ`wH%a#YJ|>rtr!?H?G};)05RR8Flet+Mw7=dkbwa(7=W5Z z)m|-n@85m!@P{AVKPc()`J)%lPfnkowCl}aloeU-owFE36&(R$G;wCOM<=$~3<7|} z)(nWYDG?>H7_hF5V63V!YHFh}ah{dvwhtYQOx4n`D4=S$)z#aw4Yr@9i?MW`2vkBe zAjd_Oji!Uk`Bg#=ZO1f9t0)G^a)0N}oj?84KmF4`{nO!aC@P5fdkln$BmEMIY}+*; zX3xyDZ7?GxlrkNvdQHLIZ>XE&TU*jKxqUU9GTnqiN06wZsY}luU1Xo3&!VPflYUYG zw&SZNTldIBc(ff4z~EIZXDhlb5zGkCqMBKKd_$Px0;*2{&|Va zBz=on>5W1+T4TJ;Tr=8Q4K0%=b(v7{VfH%2Y@Uda#*q5^mv0~_W{ zPC=q$pbWBcF5db^ZJG6(pe0<()w))!N0y!ac2rLr85tC2gdy!XFnH>&?qiNPQvRte$PggcP zm{l_#WifURGX{r7fN;5)zqov{Jb(Q8Z@>QZi^qTekH7k>zy80@o<2iQgb_#i(2w7H z|A&A2$-h3Vj_b=NGtPY$O$8)fBGXncMpelnIp=ftdV=x_HFRr`i}VsRZTGGHki*Te zhlq~fOj1X(H?B8#V>Nx<_j6=jTV}e^YccjHkP|Y*7%aw}>OY8vZ9sQy01*t3Lf;bl0P5LwLBD!&c0ON@N3+Vh ztIM zVnnsX1e6e&m61XSZL>g-@o36dzfgk1~BG#i=FYxcb-dJuh$`TG4^{cP3K%$7DbUK$G(J)(@s|1447|hDWm=U^Z*tU z+`Pr-eGbF%cs83{U0s<1JJ|WcOV>traCCI=Z~o1n{jdM)zyHyXK4wNU-41WOxw64) z2 zcYy>Xmbq%&Q@0bDHoboV6d({`8)5{M%r{CCUqq2;y4UHoDB8!iUv9J(Mnz|kv8zJV zbz94@=wsMz^AxigQ4vujfM)^gHJXS8sM@^r&Vgex1W-Uj+UoiNqFUUx%n5 zz3+=Y)D{~JO^m(7wl@Ye+V*(BMT~-B(ar0qp{bugxx9SRcqt0UG1j8=`ES4Y>g%tc zJbn80qbEQ8=}$lU=%eG~yG4;P;$(K%AJ3a}30>cED6-6ml?;&>ys5-KB)y_r{TILK zZl}5--PJ712GwXVs3wz1=KbmA;^N{e$9ymtRAmK*5Ug$5%|(5&ynJ%{_}SUXSHJy- z7iU*rJw5&OH@`i|6S#9Zx*aK=`BpW?`z&% zGkXTaDXzksxVgr2mqjS_8hJMSAOTHR4ItO_x)?d8r||NQlXP3 zSQ0Z(HQ5B>_al7}qL@k7hRr%tn+}VE@#w?5$HP&fU332Q@yYYYU03_USHnV?M1n{} zGa@CT8xpm*E}{J*NjlNaxw>9&jq9A-!vL!KifSOf{i)nYx%O+rdtVgA@$s>9Zn0Rj zZ7ZT#=8K|G)uW@Mpa1;l|KUIUhd=wXKbwpv|8%?#e|LD_HKF?DrT)_T7gg0Du?gL} zsTsq>uj}&^Le`=rNL|MQ8Ke2Ejm`hw42H{!>E1VJ3|lv0W$;vONQHITd?S6?wnp* zTKT^ijwr&*s-BGG(8O^vkaU2R&z&FDn`KvTdTw2TjyNQW7rO5n2m|!Veh@&-gd98X z95S=dODYB~ba||$Z=~CVzKh*DXiy0fBY;70!CY@X^jS2t9=lGOw(DYG&e)V(?$+z| z=U@D0ytJQ-FKIcHfLvT=vE=*gpg!Qlj3HY z(EVP!VUJ*>6FeQPo|%y{m%9;<$5S|4^idUIy|`*N?docMadGwZX1kuLSXij$prO41uT*Z?Ud<+OjYtX211MogUZM?VNu84n20?h>5fmZ*R{PF3vXJi zh)CPEh?wVj3LYY2KuF&w5GLS@0`^TKtBrFoq)CbD+jR_^v-6YBe)V6Ue*Hz%W;iag zq3gq~F4i69S+__T}#V4P9@*n@>fBeh8{L8y{?~015I_E?L>Gu}@ZWg0AS9^NsuZ$X- zs6`1OY}zKyS{JRcDY`4>@GXe zyb<<`t1E7rE(8@>w>7U_H!qhKfd{)?yloJsClZ_h5i%J90d~&E7@D?rMreiv6wzpZ zSRi=dn;UNnq9cm^VL_v>pKGriWUtWtAlXlu0imfvkW6u$mBTn}*Q-#sSh;-Qm~w!) zof-QsuLxn+ZxBUfV<1IxsDxg#9KARla_p^do%Eq^irB?=6Pmi|Be()+pil@75YZf< zuRLXa?6qqHNX&8sqA2p>;%u>AeeuYG7Xd7r6jkrnsu-MhC5zr9%g*T4VA?CHt; zVt#gZe)i(x$?4O_Cy&pTXPtJFyOl4>o*FFU0GxMJ<-W|bd$R{W`rrp2yz}m`te`hX z;lTSYR+0TA<=gyt?-+%vK{i}a<@9sSS=tG}E8e$SB{r>DwyJ=DOeM^hi*XK5A z+qtgppiB)at%SPo5U7z5lm+5G1n!|xGs`%OD1u6Tgczd+10csH*2k`wzVAC}o2HLq zs7{-%bs^5{jqAEijJXMmb}|u# zn<&AK@I(I`0qomk`rq;iy`^ioJ-mV$nwqI<-}impSkt)-kILD`t=gv5PPrn^d^C)) z=kH1{)|gDl093b{24dh44VZ`!%wa}O0EYxI&m+ZN$|hEg??a1@@+m_R+t><&T8Pvt z8(7;&2ho~E4+`KQsG}S<-{y?IIh_0UgD;I>CZVY`R)L7#=S)6vQqn&Au8r`U*wkwkKUt5CJ*~(M%Oo34k!U9H0U2l3Z1dBI%PcqWnP) z8osUxnV!n)BnZwjgWiCQP*tVvv5UF*!>n2jCmU%Op|d_x?uqmp^T#)FQh|(M3zz_; zj0hqz0{|MCMIa`oMC!E|GsAq64MHYTDjH>AzDJ6pQ7v#LegI~S(L!es0Te(XT13ZY zvZ*&s-(8$9zWnmb|Mri+xO*^t@7}#1JbYMYzFw_oh5zG^KPumUXW%k})Y<{;S~S&vGDaa?VkIG89L|oGbi7Fj0eD?#Z5Z`c{rVXeCOzJ zm@!!4@a*z=*KQ1gb8fFl0w(M-VpiSSnxWA*ZtDGd2qCm>DudOq3GvN}@aAVs zfpCw$q`^uMp@e2LE2A=+BP7R;!AZg*7;!h{1lxw8M?zsV&a=oAR6>lBz!-;|u{q~Z z!8^*c;lZ?bT!)@WX*{G+sd>ig#KP9#o`{`KV-mD6 zfskmc7>EqlK8j{}o=+x|JTLo3{=lQ){;+;yn#O3N5<*}g>4U`R#7f`+_~UB2lxEXh zG;K!(qWeC()umL~cE`cab4hqdqu-9a?o>dXps_RH+<9~YW*7xR#SSz^#d73^hZ>7a z+Fmw*4T`hGqeMc=ovNxxr`?OTy*z*MrFWkVhWCz-Mr9Sd_Res)S}b#uAKt%LI2S^% zs`$M&Qri6c11|kDL^Cqm?SVG*%k}DNF+U5fMl%HR4n6x4is_)54gCNG`)CRrF{4pF zyZ_(^@4ffq2gmOW{1}5{%>YoNDL6#E)yVfYMB#fuI(On1Z)+nMCOOzR2J@I3WN}$n0AQi_4i3w5TI87tX^1F`dcA5khCoCK4Q6!d z)2V{00V*NCy^eo{RS?2fFgnlkSG3f(ANH+YE^lGt+`NDkpyhE6O3As_-_@;C=3SCs&&Yc*$OHgv-(Fo1Vp0>FuimdMe_ z9uV1kVl={?3o?R&8MwdyFMk)&0-7LLij#n35a?Q)G0U=}qoeoUd#@;pe-TkIQPghE zLGN2EeZTHw-Nwzp7g{%6(@Kaa#s4F)= zJ~Zq4^71lwUi$8MP+{!uj|TUKgW+sqroD=(eh-X$kL0B7noc+Ga-FX3 z{AyX(NrWkb%%dwZH{`)|IGm2aZ&XF13Tc)Pj;43tfA8b>-~G6RAsSl*L`US3%)jH@ zp#G=e@iUAFox6mk{QP|hD_Ue60vs@vnnC>O(Zi7n94Bu46-=L5X5sB*e z+wa>ONK|^B4hN$mhi-``S53X#tlPe4pSzq?RX`A#2>~?SjeWd{QE$78dkyIHyi)G6 zuievBTza3~!?y3#+_}lvdPS%`aj6h-v)O$0)mMM_cYmjHORg=_S>=j1+H zH#6ev40=2Hn<99LHAnnv`ZZ!v22m=ygPKM$hSzs#T78@r6EGP_P&7nB$)Y$5o>X14 zoPYJ`tFz6EwgMlZ7(qVqFmOEJ;Vd7GA+ z5kXZfnhG(18X6ei#u^RZ&?>$mqIJ1Q#_p%hMr4$>?|+?Zh+^V&66OW<`LR z^S~Yyo$S5dND&ZOb}6U=sDj2&<{5!?-3l=PA$upPU5HE$m=a;_Ev>E{eyV5ysOg^> zWDwfewS!`Gs9deJQzu0h!hF51+qR{WqJe;dx32HKT|-2;n+jvM2WDPRASAiGCMBap z^C`;`7!i%O${6BjpMADmE?>-F{P7?E@xzA?$Ad|p z=ZIA0g>w!aJLmQ@Ioqjl=X5v7YHF$$V&>=`SxHl8r)UF&fO+zK z07E85RsaJNP*7uZ!C?UFLtlD!h%e61o-ZyhFD{mTIDKc>5JZhdV-t0d0TDO?N61ND zfK1%Z>3b`r4tswHL?BfN0SL9=b*3G}NDxfbfp-p2BdQIu4NGvL>Lq6$O%8KI0Ek4K zJIwuFb9f(F(mk(JSxJ~euPKn4n7Mw_?2Zq*`Ip{0R7-hE7eh4}b!{sl(v1em&1act z<*%1$ue4-dMjoy$xONAK-i)KkxCgc>?DYDOR+`63iQ8U-Amt_+U;B!xndMZdbj+NS zPv0dl1+t*TkU2lhs+MdUtU`GI$6R|_jkby)j zhovfrsB{*3jlIRFD$uVxAH4K|)CiajESjpu*lx6beE#%{CtrQ^-aGFfJ#ZOo6y6Sy zY*V={j^J3q-q<`#;Eb6`)ILT{W0nTe2KCTLSS%JNr)OV1`m$*@MG4h0=EKU3N-T*= z&MKnJDH{ZoRxPP_88Kt=dZ%wfOZ z&)Y6tv9|$HuU3l0+kWBB5F?3Wzw*)O+cU+!F{w8I1xqs1NY3XjYKS5)bINYO#rtYu zhwOuinE(Mn{=Ok^M<8N47L!;qZIgYw>Drje#Fu#-uha*>6Se4le;S!&cYG)7D=~HC zfRoyD8V38$J+S?~-S>18{_*`5(zpM;s%pCZb-i`#_4>(^C(CyE_|em&gTwpx??1fv zU^<$E4c&xjkgOaM-JIS+UAeg{c5uy0YinQprN_2M z!}pz)eK1SWYrF16Ua9%^JzILYiG8i;%Isa?Ju{<8H14C1??LJ78NzOUFL85s{fu)Tp?Wo(n>pDbY?}!M^ z8t5!%2In63;X~kytf3x>r zUy>YGo-lZYnTt>P^D6ba$#oo z-hD8S$jGWn00jadA36>Xm{pY->EUi>SN#N-Fp(kT9XHf)yH{m{ecPaHJXovm@X0n? z`<0Q{H{L!*kW7;%<_tSh1wrJt@0z~LW?@62Df&iq^p^vDw-zh5@d_To%%*ejwIl0W z2VhC2DFIvQ3g;XmgW~*je*XL%03JPf{QmpzKYsYmWHMQ=*Uq{9{r$2m%`7XthYr!K#p@YS(fGA{{H^{zOVcnuU82-lmk<`-IJXM z3VnBVe*XN^&wukz|8n}f&lWGwF2@IL-;HK_7E{uw1>sHRkC}-X5eaO|K>|?2A?_Gu zC3RE)hmP4pvcBmWlAu^PHex_R_N8;)7v#~ibK5}Z2ulZ!^Z8(}ri`26yRk1vD7a2^^B~RmTHlm%rZFIl=NH;?wib)Z%stZJmsa4gjWgyWOxRFmKGgLHC zv}_e(U_=Z6A;qi?gF|wC9sDvvI{)$YBW9c6Zf9@bLJLJxN(q@VDIzzh+ZfsyvX$MI zW9N{rJsfUwV_wOxcAsj_1x2Dr=6yOMNOBDT`A(!h1sRbXf>?}^fJaqTmgRD}j%`mw zCS(CppE~PLU!29RPY>gGJkA!-&bbgm+qR#dKd-N3?t0kfo_Uh4ze>j}7ly^eXypaa;^GR`5*%(|}IM!Q__g>2uVnu|gh)FmiC z{_X5KOCRluoo~Itb`b|r4N3a-{QUWgi5Cdq#MD)Z^De>ut)&L9h=UGb0fon)B{DY8#m< zxvwadUg(>K5D1u}`pUb~_kG{@eO>zvDq&~0l4ceK4DQW3bj8pcf~qDJ zBg}02p$Rk$!gD0dLfv4SvELSKvIXqyPRNFml-{~DR1k$MhHSv3Ac|%pP3%L8viAv&Kxtjk||=;&^LYM%ESGABlD$8DQZ#?>0{`+Zr!y+RM&L|;N&QH zO|F0p|L*b!H&HTh&Smda^lsg)R~PdafB5WQ|HnW6;eY>f_55^xaz0+A7y!GdAv!S@ zGf`3H(xGn7Xl7)}L#`Y<2*^+ovKvS~+q3>FAvy&|qe)1xHVeow8uQ*1$2FEERTZ*B z=e2N*WQfUNYdfQQErn<|Z9s5iu8gmO9PL#W6as*eM?gttUo3o=w>{5o2ppF3 zwi4f9*7I|99zwI6pFBHz_F0(E4;k;w#nM>z5y z?mhN}^FY`qwjo~=Q-T{BQCmMS3B8$l?)%|#dNy_ntdyQ z@atBDH{t28hegUjVIpQE@0=rYOw7dS{YF^^XxMp1Up^Sy)$IRDbmCyNf~Y3UI2spC zvs$m$MNvEU`Q3JIqvnPs4I(k7XaYANvRkw;AkqenM36CfZ-&`!uld@JU z|8P7VAwn00nboZC`=;*zpc>V6S!Y|Kfn2devGCrz+$LmofN0S7eYA?PoMtw zlh1znn@|4f=krfql$}fzT_8~Bqhc2!B%jRVX8FOec*?Tms>q(AI{0Sm_P+q2sKm&S z8zY2X-Tt0)UY)C)M~9>RDHSA+$QNjsX=_G_CU0OcbjC{l-$o^*om^c&=pm+m|mIT4S#R4ZFT0FFGsA=Xq2zSIm!Vq z*Llo-7yom^TlMA#9=bh5+n{^nUTBz~pPWDcG%hcvo{vY>v@n*SK1ESdH+4Q3F1p0) zG>*JtFUbT{%@B|o2oMbA+p;jerQ$*J!#v6qb`HH{k|h%i3_*1B2FSU78oXJP<=Ll! zNw4C(@s!tX{N3fV^3&Qp*S$ZU>>U*&a$qrC-@45W%(?t_(3=k+?>!Y|Vand8<)V$@ z+3NhfU$$eQS=mE?S{n}w3pKEU0!nVLm@p_i^@ShT)vR(;w2IXgSQ?6A0yRWMWJ5rt zEPHE)x2c}1=vxN7w|icCire_GQQGve9l8`nZLg zfm<@xE7ya>V4`NooJ4?ZG^ynztyZhOy+h#4?w29!HznGz`Y0Q}BB%mrGF8aHX+)w7 z_RB{b-VA>EkK0pbdm6k-yaW8>gi(ZOljh>!1CUa>S}vNtD{5zEQKWF3wYhHx>DX-v z4Li1=qRDx7o(+aT{#}D*%q%6%56v^FX-aA4_wOG+ym#k*Rg4+fQ{jO@!4g11NXX_K zcs6EZLVD-UBX9iaC!ak3{Iid~_~?f}{rJwk<8oACSyffV&gEU~z4v9YTL`|=awyOf zfM|++-*ur~uTNgQJbQVv{N<;g|NfJcPrg_`KkJ{LPcHidEM~rXVb&Q^5bXkVkqt_6 zrWW$4uR@NCy7G0E5~sr3#tC;51$8h?&H0U*GbEzImpBcMh{;m{#i;Bd2pTvK$qbPJ zv;Rr%yJqemBHlJqc|8i|jN9dHnUlxs$85w%V1`7V$PsfkQcOZTM0k?{0+4}QDa36Q zj4@_iqLfl>yX_v{rS$2}@VO|8x~{i@ZQIhkIai=9eCG9}_>H`=SN!wx=zF=mJbn4> z^7MISwtsLmt4mHzY#YxIV_!mD`npU8O=C@80v6K~d zz%<(cXPlu82|C(~UzBBiKkoU5cyK(b z*hx~^7?9?Z_?m69r9EZJJYfRtC8f}xoSc06>8H!swWLrxt6kf-VzGBFq82>?=^#A_ z2&$l9VykuAtV6esxF&8bs)7oza>7AYg zBudGgY+l*TXPe1oklHo_f!hQ5D_Wyp8?2v05td~MsD0lLY{H!lqH2n`!Ix!3x4d3L zZHEr9%Tb|g7hSY33*K+Iwzu!CNRHMx=)`21rIgZgz3O8qYPZ{f-NxIu(lyisp=XLi@G{EI2gl(nG46Sw=~S` z?EFNN^liJmoG<6|v*#~=_nY5-_UWh7-(Q?QeI71XwO_Y++}^r)5;qF79x zJur6wTQCPYEQ+G6svzjR--;dQK&TtaK+ZXa5g4;898bo~Y@N6Qu_g(UOp#E?2+7d zgse?Ol5Thlng1A4et*tQHe&Wg?!xD@z-?anT#zp8(mrk!>(&>Sm-Ekmk&&1zo)Q`dDy$+IH^l`MLM8~cv8WV+tc4NK5&NpD-*Y{>l5&0pzo`0(zm z$S}KU;pX62bV&@UB`GJq#PVggSbX|V2=S`A)N%KyJgS^y@h&+quRe-M8^dC;Sa)rf z11`!EMY(dZYgVyY#PvnD`2823{Nd`8-}Ya~A$JpNeAiYWU<`c?*81qYA@(NV2}Yxt zY27!lzS7VC@NAL}_T3@1WF3(3EBax(6Xo61XLn^Y@>|UgZ>iHJ14&dwVAaf5^NWj%r19y~r<2JzMXRccopt@%c~w&)PD#9H1XRMzIycp1 z3Z`2UxA#6LD1(ULtLtZDIf32~5=5drbLF}&_xBFxXP1j`Rn8_n_8}M;urIQHU*7xx zkW7)lJfSD15Ypa&ECFO^l$H%BoOMFpMgmZoJJk%J;e;#2bA~ z92#2N<&=jt`j!d+Z!024=##`mAz3Qxl8kkoMm#+@IC^mReqGj~4UW95(&}{OlV_`@ z^`S)Hwb#--dd3kwue*VSt#j}(3$why5>CfV!SrxHVnF5as z$EIz(JcMbF4$CnYJWs2FYxYHm0b2j^^7M3h1qZc|Gh#AOOj5YlvN{9+uM7qVs0gZ% zT}sqUYcBv0fM8rAK!PIo2Q1~sw;ihhg6=%uR}xjq+PY?h2nIY5`OSSvqkQWn)`f1d zSe&1q3&K_30FZ)-su>8F8k(8J0S>;AOB~qo#i-c8N0FHwab8<*%-mI#yn_KOabteH zjo$jI7UB)Jnr#GC0OwpVmA*?&mzG!Y@*KML`tlrBYab)@y>*Q%T}fQ9lh^_fJG%3-g& zKRTQhd#)fd1Y`gtVj?bG0YoWEijARl>;AHtKbxOkw2RBIe0g>H^874SlnO|!35V_< zB6^}u(yO+A4qZX#mnWYUqX|!Rq8v(W0fcE&$-N?~39m^s>`Y+jt!9T`9te?ar-sR8 z_bs%IBwvoRfE_xJs&;))RY2=v*EEfSoS&axU0p6$%e{jUA~KUf+ErZ3tLi$Fy_q7s z;;j9pm8cFsfxv`CQ8-Us*DaUJvY49|DO%>7Wz}y0)uA3zG%ye|BMhm?-+)8U2JrUk z6aUCU{A2#}en@gwxkCK#gmLlMaFQ1rtMaZ^Z}!^tMz)hT72V_VW1Z!=uL!_U_F_Q}4>LuaJqoFRDrqL}b;hm+RGevsiYkm-Dk1 z%kzu0UPHf@uGJ96g*fX;A`G7CjKsMUufvepfE2+wawV?k>*r_B4(j9m;%I74jZhT~ zg9dn$uiNX*z~PSyZxsk{y+7YlAbf=sdkqRRFf%nVve9T{s41o8vRQSj5DxO0E{eM@ zgV(h7A=GX3;y1&BH|nFlc0FRCM7i@RzTm2IWmz`O`T6;osdrqbh|U$xIn^`-7~Eu; znzKBuXp~Y|uh%g~=YW~X-i`?Iw(Z=n`JUUMb@n{owLTaC`FSzCy#e+1f?w}Oe5a^U zpgbVEmZf#x6-BXJE}PZ5SvIT1dVY45LK=-mM@L7QGQZVv95ig(7TeC&UD?Njs&ZI% z&BEFyHWHR;{$k!vMyuaHdo~>jqYjc%8k#PxTL_88qzkd_WWG!(X^7f|I@y$pF*=lb z3el;eDJn!r3AiY#(P(n~AAb6y|Mp+@|HBVHYv;fC^{>u9f7&dspm0TU#i$s&DmwDS zOk`r|7E4G()J^YQ;hGVTZ^XBJZ-H>T{kAAg@BO4M5IF}zA~r%m&Jk{VQV%s5!Zr|Y z2HV3U&q@%llyL*vbJGd5EmqwOvbXq~S{!a8`mpVe4r&X&Xxnyv^6c{IQ|h`Z#!7YS zeL0x`c1^!*udX_FFdH)vMd2b(q*6?Psdp)Qa#JRDBVVW^bb_EFp%W+QyhAkAWT2>o zJX|j@{F%KFnFuXKizXoE2?|OHjrb%k`;$+W&FXA%@o?|n(PV#8PwtG4%d!N3Ro^sC zvuf7o=jRs}7oRRp+SDy#vxL57?R|$2+D>9(MY?v zZu&WSoQ-u6b^mMC0z6o=&|+eyJ2-o}4*<-LpW^hJq? zF$Hu;1VAPtS6A2sdd1ZA=8xO1n#uJHiFtRaf9dBnBG@4Rl?b?WW$8uY#pRg_kLtY? zG20?$gBi?b^%=adA(fX@YUr-6uG+S({p1Zv$tHf^Haz=w1Br7k1;`?tecx;0Rbtz= zneU&+f;$2!-!RH zW+}#N@hvk`t;kUnYkF+Lh{`?hBo!bjiCNdkStE?g^0|3dMjhO+3_`L#mWm~^6Qh_w z%@L7;i>@>rb5Q_Ugn~nqlt3c@U|H`U9>4SG=)t}F|Ih#W!T;^=;JpWM_IyQY-mT9+ z{WLVq4<3D3ly<)C=h}iA5rJaV0S&*KB5iN-aQ@qM-j4_j`lktm+eea(vZ|_@OeVA0 ztV7Kep#khxgx7FY+8kuLJ1;j1KHbT--q-=#wNH9oA9nj~uYkB-L1`0_^zGv0#rfx- z#;ePNsyry23q~Q335DLertj88Rb^x><~JlJLQx%5=OuVw_(ViF@s)Ur2}87Hh{)g^ zDLBC>21$hgna~;PpIIOzbb!DheM%8}ai#h~DnjJ0>7`qzwOsbiCzmgd$NSUq-GBT>T14RTtbK_(L*XRc&!6N8vOs8G-B`P^gNTVJ#NNo5 z5g9~;nYuL135R5rpKHeVt%cMsNA_#@AX*m3%xY@}N@zvli_$ggrY+W^(bND_N@lDk zVBTy4M$R;6GXU%RUb-$5oC=XQ#WF=^BYrhL|iVHFJHdI!i$E})6>51H``x1 z^9xn%?Xju5Hh~*Rtq~D?$8{m|K)uV9)1(8DdbL_rzC;6uZaf~>d)4vb@xj4?b1ozB zv$<8SE;fyBF%u?$6fx>JY5@*gNtP5r5F+%6038!^1SJC&!zfOn1ZDsPi%g!`8-j{6 z@Tv@|pp8TW;$pOad^COh;PAaC_dojJ_~HG@-~AY#9K)>GukQZ1a51Gt-<+JEji#kI zD?5hPQs_-;W>1b365N7gy5ZsVM>y1en-$^fbz#s5Kv{%-gSJ;Qd%YsGot{2y_!W5j z)MsxzAMYux4rxA4MAi?0%k}e(n&N&P@srd0YVItI=Q-PS10Le+>S<4vvV0B(1kvP z(5DzNt}8P{D=>Kr-U7xzB9KMV3;+r8RM<={SymiE1dot1TU$Vhg+>nh)P&WtT_8g} z9x0*~;BRWGd?gEl-jb)K^kso?n~Y|FY(~%A@3 zq%6xkkTbCD7y|LtdE2hN-z#jqFZIh*V#sY?Vo*&8RMo|FI_cUgW}Hr^D%5phI7x+v ziZCxR+t}v{hVgjI+1<1y?e+M#z2;qZ#5X{?eq{?WA4E;lym;}#yaSZW%gYeLjY*1q zS%m(|K)9_4;Wew?@2(<5K--X%^RZbJ#opfD!NEb{eC4ajXj;2!GMW@cfk{OK4H(JP zOqAFm;9|X6^lh~_eemR+aTn$MGNxDn*ML0;88Nz;ZAp?6LPA5#qS~2^tPThP8%1vh znhewc0~jIus@BnHJlnhT?xP1k_~6bD-`{`lozdgFus?-^u@=@@SI+83qsJ2Z`D$*z z!OP`5)Xq7c*0s+i6JapPsz$H_LLRsQIzUI%ENfvCk0&Hd4CIIi#ev zEB`GS_u4E<8es_w=%vohE8{D6qKkL|@{PYKVKmAcUIe+=D zzX&f*i)8J3*`~mN6##-ED1m_q4MS+$L>p8`ju}E!Fa-0isK+B3SEK#e?D)`q@X`M9 z(cLEx_uqZsA0NTJ8SK^I8B`*gdWxv*+`Eq-Hb48Bg8lBdzwSdcO`eeevQB7Bu`Kw_ z71u`eJ@1IEP|JHp}8Thy;b+QHX_MM z5b_XH^#&c{8X#csy4uAy3TEgtB6f=vgfy+nN$HyT#pUUF=sM9jnI4xEQUWEgvTQ{b z7RPE6E23yfCJc!j)#Jhyz8=|VqA7OG3OcFlqIRPZx2^We(3M%^O~~~69Q|_(glb4e z8Lum%QH4aDWAE~jN@Avj=0O#biE3=M7nQ;nuJEdst>a<=Kq#Qml2Ae>WB?HYq5*G6 zK%fBG$OkiMl<0cehsr>T3aCxrTrMu#b}hAlWgOMlMSFfTF4vbxso6lw-XJXy-efbk zX<-q6Hdxr;VaURy!w_Oznb8#RdkZ%Z<`TEw6-&P>ZHfLbNNm~}xY&N@l_wH;wMFR#l z0f|CDyY51?*J& z(k}VR5SY zJUJ?ogcy<^jXwV9!)4nn7gtZ`=iR(%RKR(^H}Sr7k~VbJR~H6vK#{VOcOqi?u3Muw z29!HRTh6(dSvKiO&S5R7nQl;{8L6CZ99x@l-rxf#0~K6f!Iy8?fm^Nj{7)SZs@z#V zpMg9%V%DhJUh6J=yinhCEAZTF2q_TYdO2S$mOB4@! z99K;7>gv2}*Qr}5xJE3+4xNvT^FAz+^kwaj?v>NY;pEODcFVKpv0dppmM-0|C#)ff zuCDqNg-H*=v03mMN5Cz7%>(^AZJA!X@u8>*kpt&L8`+>NJVcffMMPBql`AF!RZ&%1 zH>prHLk{FUIuQDU5*t5+-$M1|$f(AfY^#%8&Efw2@692_y%TRP@CCqCGu1 zJAF9$!K^O(_1bR&;jl2aeFAy&jPTY9@0K%{Znf^dvBk2!AZ)a7U-e9Q<9O`m^L=$H zmW`Q{Gh&1S%o#*>82Y|&USc9rmBa{Ws!4>|kj%`n!@TvlNFgDzh!MFM#rra)q-x9_ zAl*{u7Lt^?$b@=C2LQ}i*EKuP)S2`}QB=+|6^Ja!hJz?St>II+y1?YIa1IGfld2*R zg0c>qJy$S9$jnTfNK`~qWHax4LIah8aMQfcL~3G11V>6n2Dyp8iNU!wEl?UFfVqq% zR6tNOQ3XUKhLJ1o930-icbr0Zc6nA;v#YDQYGQ}^L_%aqdQGg}mb$}Zxka=cS%;x5 zr6|rJ0%e)iw;ENvtr)R6O_)D1cwa`4)TgQ()jWoPqTmY;gZLndC=w&Gg5+K7iAmGd z<$0IV(fvEefBA#*@dFGGp4{8#WjB9z0{^e^7oR+>up0Ziy;@7#Sl_1DgD(Jp9An{> z&=r0(nM`KW(RAWQRe3n8?j4RE99MS_iv20nB|g{#LSR$};z%6=K>-vTdh!H-3g`)( zrm27bFFtI%_W$zFzkBh;i}RB^B~7Y30==^sximA>Yz|rb8;xPQ=WRS9?dJu~=P@K^oRpI?_Q7K40y!Yg5<9997zqB%UfVto z^MHsWBHD>vyTx>oJw108UREpGM8Uz@ev?1FH9SvI$OAa-q+522PTCob%_#_d&#A+g z460whfNyMT_sU(uj9@V{w80>B034IuEw_$^$A>te0WmzHs!ORFjzz7Tx z29aMvG(vLO?i`HBdETh)@1Bicv!r{ami_)~!JS+Uw>5OWe(xL)Gea{~vTK6YHd<_j zl*KfpSX9M$I+`6F;L!x49ykv|v*Ty$#aZ$GyASR>I-2ay&o7&+OX(Xh5hO%p=eY3b zoiB^gWL%9$bTGlv^SFZP2&OemE2um;22Fry1V*?~HG{2*sqUl~L{yaJlP6D_uKnWW z^Jf=lSC^OdI4Z~_G@)764kx1ulCCf2 zZPWX^d&PtM#r-?Vli*n_xY8B%$eK2^9d`XHsyQ<6qxV*pR*!5vN@aCb*5=3;o~k-V zZP%+3a59@7mSWv1G@Uu4Nr8Y7>`U0zf2tP|<=e%~-uu3fF-9U{qs{TUu?G?n5e6Qd zS9fGEH&GDiIzoPh^x|z1GI79Cse*OCxLjR!X)S&aMWKCjZ~0pzINNQ6`dccecyH(18Ijn-W>^(*i8RtNoPR23yjh%xpVC8Tm~L-EQT zMnt4aF-8Cbr$mMj4SGpfj5bQAoCmGM3{2AX2n5K=AczL6=nN@;?rqtZJFJM?zrh{U zmqE5i+zHZ}u^eWO99T3$1x=y~Mvlol=HVb2km;b`ESSBHQvQB|;#P?!LzeQ1nkB!t zLnSZ-H9|nR##M?b5s`OM(z8<2n3-}x7`MVxvmZUK?Ti$FvCQ$!Nud* z9x|QHFF$?x{OQ%%`N?S|dsvq&W@c>JbbUuAnl{NdUdO^eWgvV-+i*9Ay_zZG^)UbS zSKNt$M8kZ+3>XGska2%!sWXgDarwC?vXw>>ud69vL8+TQI1LS}XmRYXN><939Z zNy3ovpUB9xVOFaGU6ahRl}RR6-`bkE<@5*^N{>|ecHLgBFB)xyqH+Q#cF$kC*=c`0 z9KQ8^c-y}>cvHjwtMk)WV@XE9A)96QT~-Z}3`yAC@FUzltk*db+ikLoAl-cZ^$zz< zL>Z0CdQ>d?rt2D;$*^;cGLLf`W4B|yC>jw>$&e5^e+XwT!a#e==D>)UOhFM12?+^J zz=(|r*$KoPr7|%CF#EjbgHCr^vNuOIW?5o)u%XYErG%hp2%_4@5L7Dfi2a~?L)k?^ z@<&0kFX>%gFQlEbIKu;J1Kz~}t@jP~?_1kHSr$Au$UQ?w^4>F1E(bvrvh9IEzIO-! zgsMtR>|M8RS6wqXEDr7-meWxpXq`Z8R_8B&^YWMf^W`t^96va)_kJ>Y$cOvIql#na zJOdRu7HvM~#>qj|5Xm938EJlK4j?$X9-nPzcm|ODbisfD!Nd|FI!_9A_wMmefBKWB zr_aN`{;!MWBA^^TyhmzV?8xq3vkVs4k&@pK3hhr52)D!(Ro&$|Z4oQi5`dVEpzMt$ z0yA!ni~;}bio#dA&?#w3DaI}NQnJ!@28%nOe69@uMY9|r z33Rt7L-q|5wPd}=!WS4Vw0)x%4OKwipo;q%oA8gT2m+Gtq&rsRLdfwnIdM z!P3cIspoa*GoYaXa?sGm5JWwq5Fi=u^jbH1-1wEn-zy7j3rgC4v8lYj+k==kISp>8 z^?=}s2{2F5Kn=kLcaY86AV38`CPQdq--donut7Emgm## zM4v1srSW5$d-NEs8Z>B#pOCIEI|R=g+*$lk|n{`$;2$FDQk8!6p}0r(j|!@ zU>6;emcE(G+Vl|IjK^bfs})EU#UT}JO6dN)0^zRXeC}_Xsfd^m*f1F#ydH^($Pn0W z!smvw)NTy1H*+PrB~$y7aTkIyb1;)EPhnqbBKU1pgl~C{*;~#C-_R+!4v9IaIfe{c=O2Nu< zoi;@`kx90)%R;^exl@|YO%en#>!ZY!6pAhK&TP|q{i;LsTCDs=IS4t<=E@9o_!`P3(#<-*NuUs+Vy=&o55Egwzm0v%&k#aJV}o+=fH_Q%S77Vgs-ZKU=c= zFwz@5V9l7e)!z2Ms)Oyz7T&UTVp{d>_V^&8TRAE z9t2Gx#6lxB>*n&jYnL@qU6&fWMQB^nQBky#)~hZkhmjP-3<#nm**8)Xnne*XL$H*! z#0?OE&|-=zeb@PDLSUZPkc!m36Q>hbO{uy{%dVG{v@~L+Titv9M6?RKX~~uY;jZcK z#?*KlY=$M5;}gMFd?kCA({2<5qgyMaTOx-u9^CB=LD8W@0J0r9vDfMn_%20|Z+ka> zll_c`O2rH_Cl!p4k&p?Uvp`8j3=BvCRkz8s?u<_L8}FAtdPZn( z83+kCkCnDhK?h+9%1(PQOjbb+3=E8kGgDF}bulQ|;oW=HWa=3R?Bep}{PSPK)n|{U z?C;lok6pZ&pWD^t!M%rfxELXd@z7*7m7yJhplAsq?AW{G#y7Rt;;**}M3y}PRX`f# z&Vlm&RwZUtW#Yr>{*Qn7qqEf_{`{ZU>(G<3yF1O-#W3XK8{N)=I4gH2Fawi39VYb2 z%uMvg%glmj2_abGzSZSbcXc6MyEmOVN1;uNW|cs>uDK{F2dWgvv&%lDssNZwRkdIm zB;iz5G>uwSKwW-%lIU@wfV^hNAZv3AASRTV-oAOj*sl|R)i@Jlu}5t+eGtLHlZ zAfgQ~ZgWhN<Xx-r1HX68k-2?na-BCnFF*HI2R3yM$O?;hv`Sq{gs`+%w{rPIG zp55-~?SY4O{&^SA0eeY<04Z2eCQf&>_qKb%wlu}CMJRqqi17lMG0~M1LV=poSLdrd089t8an`6dC z@5eSuF&q66+7OosHOByitE$QC_e1y{a51)1 zFbs*(FkJ_7)`wO@v%z?4)xJ5@zy?|leC8(gyt?z<}4dN?;WJ5AY=H&6Sv*{fGV zB6jRH#uT}n$bhJ!?%nh;6k*Ln3&>Cf%uMnFP)C|XQA6ru*8>0}#TeU_bX|iQi`0j+ zQ#m_(P>Y zbcDP}Ak{%#KMeD*Sf{F_S+WaFh)O_!`zP(o5fgUkUnXNIQqr-I8clZY@oGf@E| zK!l>*{noGJ<((1O)h>|z(EvJj@7BSoLu(L>Iv1->?jP|AzZ`jA! zNSYE-7Ff!^%%Kqq0!pT-5j(!tzH%IIBTDNH?8$a*Ckb28>YSDhJtf>o)T*VFymML9 zUy!oRO&0ji!9!JFNl>@oSk*8%+=4N)^L{|Mh-I~aB!L*5EBd}~ny#w$ilXe}6`**R zjUY&*ckE(}IYHe1YE_M~FH4{2c_}4kpFarSmdav61O(bxt&y1+2!bz)iWm&~l)A`) znZdkicJoO|Bch?pT>`zd<+IaI_8k2EkKcdvgO83LKk8C+g~wp37NfLHoUhySRdcm& zn-EpvYPAqaBN@w0kzW)GM@&>!sqr%pjMW z3N%0*Ozf>XA8$Q7PZ60BDJ6}0;^YBKVOIc3B+(6~9vRrNDLPMLVxUFg0AR5^_XUn; zML7XAFoW4>JoDPwWgR=$9`hDCMRJFT-r2^ za9~g%5h9Q=scOpMkX-~yU_PeOyQ%mO?mqe1$A5W#v1qSWToqWC>L{T_&_NR*#qTZo z_}Z;e0{}=W8l-L8#d^iDKev8pfo|HInQ?E9w(1yLq6H$BRh7H^IqYpy8_D>9oy=ml zk(0YR1dy&>Z8XNHNhHJ=BO$6<*LCe`9YasCM+sFICdKGcJw4!Jb+ueA)=`zq3Vo3P z5}Ir+4(vwGk9OUdeidLM#0Cy{khS*=rArbdDwTdzjrPVtW7f@p95>R*Z)gH< zch_t$=1xhkQ z6$&9On(lJdUaXeuKFlu{r>Cb)*Q!~N*v!vUyBvA<=;A@AQeb+s%f4+isnAt%Up*`|(R8 z&h1;>q&MVbci2rt#7qQ)CRqVIgrMlqZ6I%(WwSM=cHWOi(|WXjmEh^i1zx78WwOy^ zG#!m5-j@uHhkEWUND6ind-duF1MHQShM^E5Wql>0*=+Xs(W8$({P3Uu?U$Ow0}yl4 zglfQuYG3D`f3Ja%HVU625>%S5U9MK(d5LjzvS-N$Kqdqr?_PIR%_raWcVUuDM?DP50=DKA&D9EUB6x~U9&C`3Xw|WfXjOavpYpy2l?XU ze0jA*D$41elx2@7Uu=k+LwE7(CFnOOaXX5g*HgD^dx60kcYqX$=J1&% zmR?cBy={iKHHXXXh3%=d{hnN64h$COoKulZv>k9X*==n17j09)JU+OAt@uXI(RYnyJh>iUrSKCar3QtFfRL1NO7LP{bE zN!0)p6$ph4$P_{IMIywwiYWq!k_C(4xvuN7EWP*4hKM>$FyU2p3fNap{_IV!TfhHX znpo2yNk9OWX*T)2`xH?vsbocaF(r+rSdmYn(Q6kef0j~ z<-1+?qF{hxgBgWvr2_f6LWQ(1V&oB_;4TQ|1vJwg9+5W!YPIjM@NDX8Cw+%YjT zZa_5PZUEl4>nW({Msp@lc=Ih|zV;VX1RI8;2)Hq!wt?BAA}PicV(J6rtkDpX#8tC6 zzkG!0a59-ybrr(Y^njgPtrzpf`Mi;YqoX4~JLu4L2+Ei&m4>^y9NJCP)@uc^R~tN9 z6%!#ihjkH_7`i0EN>@*-iEkH{S`%dhdlSbQ|2%x_cf=y`d-z>%RAy(b+HN(n>fYgl z2geUfs-zQ4q^8WQVj%<~!j15MNZ}QPNDgD$+U_fofUS1DO2)))Atd=X!luN;o6fqh zk>J^wLJa1Jx81sH!aGoIljQV#BVM0xKINfW9@H`OhOl>|3VgjB%z>~die2UFZ?>+o zUAXtYZ~N^CD!am}D1a&m68M5+jAv&jO*`M;A3c0aPm%l0ds~7O_@Fzd{$;TgmT#af%7O_5B-^Z>GecvxT=%X}Evs`t{ z)q2%-P21bl2t@EzhE!Z_V2BL76*PkDo*EG{AsVqu63mpmbB_AZCzYBJJpw=o0Rde-d+^?m zEMA4x%f-dI7|r&N-?{tn2b2B#0N#=U-Vm7DiG^=@s$8S%3;+}r0S!!53vvudh{-8{ z5#XK4!4Kd6@WXeW{O;3F=If>y*WMM1q^j30L)$yDwdea&1wz}7dQ}jU0jsQ|h zscHMga<*3;7v)i1c_MB*Yx>3I#rf%3i*>m-tMA^2@w7#XnX0J?hmpx3<$yPrA#BP- z%nl%kC_qGFupP!zRjKE`ldg@m=ka)2S)#r{)E$A!YfAVHsKuW_ApD*J^YsLLV;!mn zh9!=h6pCVUFgYr*o}Vsb6e}!hjC~JDLkQk`E`2l=Ft67>e7n7)+u+o@J7q)pwXI@tQ8l)A3V9-rHH-gaeQ@4M!A z$em&5+_nMxjRRr64@FTB#oVp(o+XiUNHomSio!eRmdp9+>8UH_$@}ja4L_{x2xHItw2M0gb_gBTy3qHLyWzYbd!vGUHOf*fMFIFvLzw*|YZ` zU`^9BO;bvakPk>4~(g-PmE> zEV}2b=OLtWuVf_?VZ$u%Zj9(1c~=7EFyOu4h5Ud={;xyCZl zfvav{X5Kj>>ifPNon=&1T^m4$Mx?u2O1gXKP>_(3kZzFfZs`W;P`bOjYX}h#0qJgG z=$h}o-~3}O?qZm^=iKwu-UdPyqSOh{hFG3G?zA}7o-G)@&$++GPFc!hBHax}UyXH^ zWxGDTDFpKQTCDGwm9`JdLryZ?EOzDh0gX3H8C%lZXhh@H7JEuXIcEBh_QH&LIieLo z&U7g0V>em^&bqHvOK)pDhZh536e}aHf`U0PhDmS9YRmgZh^<@Eo&>ggbhP{DFm!Ny z^G|wOR*x(rH$zE7S>~{A#k+(^Tf<4k^1>o>W6NZIg~Lb5Hw*pDnUhscT{;{_^VlTE zV#96b=1n0~`kk(Y6->(GK}z#($Ma4MD}Qv@dS*#)t_K$$T4Vn;tiL7wcQz|oMt;vjXr`{I?$#-G zko_eSO9JaJf*=wih=76=p#lqm7*9S0L2HPn8@AIpBl);B4C^_LVVrjTF^#^kbM1oL z#P10v#>yQyxu7e=|3h{TF*E zqB3Tg5@eHrG&+g3oPMB<4W~54!P}$5mL#$`bA;5a7G0PkE&B57H2NF z=XMW<+ZR;$R5!GUIfr3Qy2tpe=Hm67ZktT)q^`gyZtmI^n)6Jsk8X#{xRb8DgyL1gtrxb9`sUg@J z`0p;)D$ApTXfs3+M+Bi3EIOM)s!B3wkat+JAcczM$?UU+?Vf5-j`EQho*`$= zL(lnRUKc>3Cd4C>op0EpI5zom=J6g0o

lmd1%xmQC~I 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�`OcM7bTl+nQ@UXMFxh8CU?)UbE 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 m-uOIb*7w-WbB@tDW&aNmc92q}Diap~0000 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

4VX* zk4OJtK)h#{{Pc;gjtyvPcKiKiYl)wooPGZM)3f<;W*tDH1Av_rz!J+}>x%UbDMUT^ zGMY?}PtWe|mTxa^#-r)4em$USI6MYhRa{kWIJ$oGy58^G{l0FyNs$kn%dIiSAd9M! zs3s8rQtf3=qVh{Z@jxz%8W)@G?Rp0`JN@kQ$I=??RZ!u^j*7) zem@?SIES^0V3a_0{d_ z`l@byUQ~m^8VP>492VKx>Cv!ooAu)A@?G@)_-Hm5lx*z*2t|Lp<<7|A|+L1 zPyvxP+GM6#{&YM&sb=0s_MD?Jf=0zhLI8=FJL3qgaK=>RPNHv{x~i%?&s8xgF!dT^ zMfgQdb^%2uK#U>)V69cvi;IhIzy4;kU7w{itVY$*;Cg!*x>#f-Gi&d=dmsLaz^ZY# zZQs7x-Coy}D8tM|f9mis6UO^iHN^c|M`0TGF1GwzN=*)#n<)T1GE_s1P~!WLi4^{{d-X54|Rk;ozV1<#%GL? zM;10etq1==9-yLK-R!noOmZ|k`sD1%)3fuT%M=p#0$S`doPn79U+G##O+be?C&Xac z9LoG2#UDjPe?*Fh zfPf;>C(|$>5`sd#-(Ox{zI}7iwmyUeK%=UfPmhkq^FcAF>s?P41yB`5Q9Kmk^q?B~ zbCCIu_D%XTp;7$tN*g5rnqKAWB^fZ*Ri(@GLFI~xapiV%S=YBAY&R=kZ`Z5ay{odx z(R}{ocr+ay9nI&%vTN(8ttF;@>ls89kkAlsoABzp*I$15WxcH(kjaN3x!84}nahho zHFee4=0j7AY*w;$$>czs00NMTL1G1z!-3!9W`YE4q;2=h)%9|D0nkn+xp6s}aDDyS zW!Y#tJD<;*u3Z@zIC7ZKcdUMpn>I#>05MuNoDIu*d%L`A zoI4tq=d)>*naO0_wfoDrZa51OrZ9Ih#x|@cOnH*z(l01LLwQ^0C~u+ z6w)q1C(LSvIH{yQE!-F|=D=S@A_yUb6xmv`E-0h}IrF^{V^8b*D?VHzVvOkt)j$YL zF@(ixxmj+;lks#kKAO*~X?4B1NHJDLVaX=#6o~uUrO*?eukKdgesgzy!0XJRnyV?j1v!XDLl~NyF018P{Z-0qs5I+{M*+;ndn!28P-f!C-vX{hw&R9*d zS*~`g^=T9|k7km;VNRjh+O7$HeYd^XUQKR>(`wWg!Y0JJ*#UsY)HHkN+;}u(=C^O& z1ymIU{0km{j~%Q6YJjxYSt=?}UJagp^7&%1S}s;sm$$>= z7|D@^%$TgIW=HeMWU`t}>cw)uTyA%(ZM)AjQtWy^L%#RrR8<1}rP(rj@3&3c#W~kA^@9=o{ZddQJ+iau<)Ne?*4Tbnbk?D& zbpHDK`sIu7Zk8(|jG}-voz7>oqp~ccPss;Kz0m<`UxWdeKiW>oN3Y8-xgi8lNGgD2 zkV%zeN<_vP<1&NntjVgX>gq|?t=oFtHv4Y1*{$2vy181eOkuwG_2>WgF9j1@j))4{ zGpZFtMKCF?@9ti``0nE3YBHYBW@p2Em_Y_XO*YAkvKWp`K2UOzi~uLXq^@r^l86eD z5-O?o7bOCyNK{c#X}is0dDZM!3Sl(NKl$wZ^G~0|v|nCb@9WiQG^^WAV>c`_i2m;8 zVph<2SQMG{ZSYOYWRK@Zb?4t+Uff+@O)U+_NBL}EZQXVuV;W~}Hmb<7hVJt1n~QgE zMAGru+0oI_a&>2n(R<_B{@V2(Iz366sJF2Y5Cv2Q$TRC3ppdZk)I}Rm6S0DXM4CX5 z7?WU18bXRu1X4(`4Q&)5XVg$kDunMPqrcb#rG4<3q2BBwO6oJqe*N3uJpb&A(P*S7 zu?yC?GB21pMbZPhhW(!aaJ^W5`|5SQo($k6x8Agy8apDyf(DbJX;3M8hHBcfpB)PE z=W}Wv=oLWpqiE_+1Eev=rmlU{POIUwr%ylq z3XmXysI?Y>31KpsoF1S2;SYB=*VkvKCq7a##&A>sSFq_%ABhKf4g_nx<(Kd467gqRVaZQI4w-B(|I_50ud-g`3`j074YDhGqXU_fM=x&Nt1*auP-idZ*C;Plk?}FJp00Eh7lQ!$!ADL zk%eqRjKB&cfSngKnD_v)9rC>e9=TPBXiB0!H2ZoTVj~)+Mft@S&wu-mzaqlje%-aZ zg-=Pru{oK~x_axIdb3_;l!~KCTQ_acs<6ki<1WOT#ZBGpE?>WSV)A^H&#Edrt_*2v z_Lx!^+wFG!_RXt$x0y`Fr>CdW>2$kWBh&lqpo;yK(npQX;n+DYWR?_h4*`Q00yO9-P|r#+>P?Q%v_;@Aj*J@q)ED_@okr7 z*{B-kSr$Wh07~$lK%t@lApGMGz(Ld-!$Wyf6=v2XszQK8mgjlC+HIPB9lVDs`{0QF zEI~iY$yu)a|ffB4%Kikx$S_BPh|NY@FW>cMBJXX&}M3xYZ(X zmq`~J-(KEay}5eRh1wPd8TMEEUAGsTI`%iatDWrBi64jEG>CM9z^7oM^$7>aL*|@J z7p^^Ple*-)0-?p^vF+D+Dxgt~vCzZsN`gSf^mZdYMlz;9AQd602mlc?8xkgAR`W0m z0%8a;&+|4NIFr3II5PvNCLshO5_HiA)3;p`3=$D_T^H)mZuh0j|Kam5e*49*M`fv! zh)5|uP#FUVAOIt>`S{H6Gm*20rZ&p zbIW<*viu~^NAu(5?bYJ;PJ`Kn5LcUlHMwDCD{PS2VlSCbavvEZ5|c^^HpVERWEcQK zN{GnT;Jv%_2V2;rL@EdgL5K{n@d>lt;bo^uMo{!2WkbV|q+Ed~WR#@Mw zqlN$=2!sw%99jT~NrDvYnDU6(5VXmm<7%Fb_n3CG5N;Ni%U6GVwRlt9Et)PBU|UF? z_GAx**xGy;!Q9^6UfyhKSJfxO%VIb$kB{vv?S#ptM8r0L_^4=IBx)r^gxp#MKmpgE z@^ausx7->eYs=8qHdEnvd3}3xTPre})@I$wX*r*a0O&WLKMipg_JoMNPwZ}2-re1; z>rFPuT~im9h1I8>c4;&$e)IfS*Vorg8^8YJx5MGh+1Z(it%!7OyV&oS&E;;li!q)Y z&*t;_csNo;&|W7k)*6rm5)BcE5@L!`0TQaBK8T1=ffOp2uWql`H`kwk{&`3mwPV$c zNg`NvBmhz)38duE`KI39EvK2Yij~cB#Ja968SuhFiYR)Rf$X6S;0N^e`$p#@@o?}X z96&^LgfRxoC>j%}DkPPspFV#Qbv~LHW$offO}E~SC*yojL1;48ZHx?*$f`G%^0bbx zzF96VcfWmhJj+gXSGuC;bQ5tqEF1_+GyQST4A%CTF z)og4RA$SN$W5Ps2s9=<^BmzRD12aJo1yso|yEX3a>bvFUrlhhgi^zVb?cMsSZuhI* z^6mATS646herFs3>8=iSXl!95Pn)!f98As;QULEYN)jLigVq_g#yJOF3Vm$u;l2UE z_qv~-1(5!5DcB3@LFplK>W3g|j~LyD^{Hq&B>Kjvk`ih3!TY9O?{b$tIXgW$IV#J7 ziBRQlF;e~5t$={@`FuW~FBXf-%gZNEo*d1O6t(l6fUvQI%+~Q>oacEqD2KD7=IXB9 zuhVu@cWoCE1!1EmVMfUqW36*c03aHfEdvA}0fBpZ9RS~(fASAzrvA&^xEMN3XxJcg zNZR<=c46CwVlq2^`fNBm2DY31ZoRL;xZ!YkF!00oDH$IV=J&%Q5Go-8!6O@S1fv>b z(yne^zk2)4SKlq}Ho482dB1M}psFh8oQg;SL-aU#`ELou{DqhL2VatfKJuptDdPG!?HxBG2ZAQB=84%Ft1+e&07We^{>UofeR_tXh@=#Vz!(GAbzRpqP4AD#sJV58%K!+g0hx@) zWxwt+##rLyL(?>in_Gv;WrZ;o6?zNY>2z$Zy}Z8KZo_WBdi|z(;JBrfz{Ski$#68E z&qvk3AVEy`_JVToy}P$*l#ibzh^UZ42)*u251s5ia~ONWMj%!0HD_eJ_f6A?h&^l! z0rbPO)|C2*jG!o5n!d*Ay^^&a;U;!h$L^rJ^^i1D${)IoajNEd!kJ@sd7QvN$VTISB| z1SN7v6oC*G7#KAXDnJAfhJNNhyh0Bt;QGbl<$3;OJRc7RWgX+i;_934{;=I|x9x7( zuGhNijHis0bct<9o=XF{w%UmmTcV;ib<>HDF#?k%YuH-Hlp9L;VDTlOkM7{_Q-?pI z{{G=c@WFQSv6JULnAI0Wd9d>!?RN(H2x=2%MpXr|Y$W=2Uw2){3QtasfAht!o}8TK zWC)Z{8B~7Z3-;j3{rF2!$+P_F)2BB#H;cvM-Me>#!N56Zj7d=u36YcuOhhco;W(Rg z>G(;rUhM8}yX9@zY`exspQLF^jMj3|hl3gd;NBa95RvKOE$!_DAG9bx7j=K7^e5J|irl*{h(rM- zvcV^VCiwRDYVqpZcZ-`Lsuy=Af+#C8aqTerE zD?ffd-upbD0Q8n<2ms0&V<1#!r%9tXmPX|uSA)y_YTFtd9_P@S>2a48w<(O9CO0-G zBZT`D7xn7h-Rfq!*iFY$(7xVXx3R4kjbuks&VbEmJah(;C8+fF?@U%r!tlcv+#hr? z#*`8vGPC!wuIswqIY$Y|Ig{s^bFS+GA{BXQW6>X!j5I7On95DOSuU5`+)byBvpfcg zu|vc`ndgN=lkz2RHk-Ds+qUgprHnB{S7uo@olcLYGXS6{BH`eF-usgroPql!B>mW= zjw&J&gKxV!gg|8a%vkA>Q$WFc$3mto%Zzbd=lA=D6r6MYSjqa~!1uwIA0(jtWzguo z*#oln4pfx841s%xL!cBSsEWyLmRS)MOk@ebDEF!Wm}0J=|~?M>|vIUY&7?_O?SAHAE-WP*a%`^E3x{{Gv` z-|gzHha|c9p#+=6YS1V~RZ?=QMuP-lQ6~wK3MAA3iTcDHHXfr2W`cdQ)93Lj!Uw{J zpPu}FcyB!}gb&u5_ebC1mpcF>@}61qz|Uc3LnIO%83Nq<;OhodKY#k{(@#J7EJTYvPfY1O)f)s_A2>>M{lMTw@ zz)i+sckI{O-R){~xAyyXQ!llPrtZo-n+(b#cZ{f_~=%5L`jv)Qxfc6tQlHcfrCT-BXFIXNkc!h3Iw$+P@@Z~pN!!Wf(S zoLu1E%0m>8F(b@NSFc{be2c=Pe8^yYXxp~UY(5?giHyVq04WMR`nw(tItcg+ zFa3TdYxDr69{%gY_oz=E2^kG2rbsDS<8sbMSM}!Ay4yx}r|0EB)41AZju8bYH zx_w!1UaVJF>s=#J&OX^mw%)s&#ZHM!qx)@hw@*dEPjffPGKwma6agY2BznMtA8%?^ z;gExV&|7z1*Ven(`N9zh7$#?#P*aG}2gxC|{$M)U=*9K-R?sOs$hr{s>YBw zurjI6rd8%(G|ZZ&>EolVwa&RwF=Xb!U{E;QG>yc_{R;h&ac=+A_acgWhS+;LOOHn< z08Nr4NQj!`z#otl^$?H*2tb%XEtAbNYl`5tYdo{DY#<3GWzqw6_R$mC_oKZV=i?Z- z`(StcOB$vgdhLV22o160tZiFFK*p|X+O{6?(B@VUQVPluIH3Zdz1!@*{N~MS5uP1Y zhO6Ku1d&7l7KwwTT~oJRH=0eWsuIu`)8L|eS(lYFx|AL)$}$u2#^r^ zC>!(B&-*W42%!tg2_(f7dkgLa0D|!zIb=j4P-0RdA}upPU9VSP-+XO$_TBCEDs8`A zyj-W7t?rXaoH;2mSqT(<^45W>0)w!!L}o&#Aflx)oWK$y8Dn}8ZtzKc3Mn2&4a7dT zE5V1(XZITC4~N1JYT*Yo(Vi`KucUcsNs=nQuYV5@1|kDSl*4T*+IDrl-jc|CI{U{j zzBoQU8W{%wD$x)TXp#i<_kq_4Da*3iY?kNw_4W13moLk*9FNDEdJEA$Ru6qYasfp^ z;>?cc`FO1Jwwl-F?W)<-yPJ2-ep~N$b=|Znj6zvCXDm_gjdWiIdKNh<^a-v%>`VG9 zsK{-Ii(RvBT6NWMesuE5r-PGI$g+^qVzXUs*35izasmLG%?1(sLiq0jM|jvw^pOHY zqeAW^FgSDhc%=^|4YHo znh5EaeuUo_Nco^16A>uHC;|j|*)X+j`@in4zFFOx3eS&^wP_XM!_aj>V;|+iL~0Bpq}cix z$YEY&F0-TYupd1U(ONsORiAJqAxelu+}rgtkwIc2jDn&{K>Aa>e1Zp02|-e#gX5Bb z65bO-4gv(Qnai?VBzhkR*n)wWND|*`Pd^YY{}m7VdkjPSLr_IPfQgJUgk+on#Ja9K z-x$YPZipxZrJjP1u2HzSs$X9>NoG;Tm)AA4i;|D3Y8azeN^OkWrbz%rQQ9nP+ZF_n zKDzvQeDFS2`uh|Q?#hG!kb034K5&SrUsn+kk@@rW;=e>8RAA5~XjP&J^*U?nTP6e) z1R_vYB~U;tjWHOVzFpnyul-k-uXG5FYov;;Nf86NBu2rZ7>UXE^aex}RqS*21Q58D zE~+FILKFcNNrH%wx~{jR>t|7=9~t=m-LB*z+~g|Gukw>V=!;BAyP)$Tgk^or2 z2ftr!QrDdv9sl~X&z~F}7kR+|0+H!{gDeTi{H;qMk*Wqwj66M>AD^9G-`#%o?Kd{h zKL7mlVOfDB=8W!*=MxAhf-;h(zKrBWW}GVqsf*3=bhlYAuddd&SIg~Y-!2Phip);( z%o$^d566lEh=2$(Jr1LIfJ6`>3P|YoG47(*yd0iBIeYrNn9U$_fUx)Na=nPrPsg+Q zY_h&xESF1T%w#fo@NoKxYaL_%JRRJk3>yOo09_rw`ReOG{Ez>2bA1h}jKoBSY1Mf~ z8dW1}t;l`&bm|k!;i0Me8hgw56Bdb$33wf@pKZ%bp2k4LlPo21|En%j!ZWUBeB)iLFD+P2MR z@BE6|Zcb%Y4GmjXYy@Qb1IZ7$pISaSlm+Xdf0-_ph(F zQX=_De1#Eu);jkMh)BXivy1@9)Z1VqQ(26f?W(D}(D`1EYXC_>4++e0-(dIm7%26` zFMU6e;$I?|dw?GSfC1BCe?giA1T7n%+NRzSK{Y72H;(TGV*s6)>+9yl%gwGH<>lu= zrr*9?t%{2;J{bJX@2O#es?4KawANrSf5nU5|?%-hb+I7gZ0)>4Q+P zPnJCd>==u+FxDwtZ&%JbH(){X z5Tb?{y{{vSq9GqUn_D`tjTHbm+Wv0y7-JVxNC?abq)5Pos3Pfu8E#hcQ0PN_~x5$s;Yw1xiwiP(K$!R42l9Gnh`5V z1nD5E#GKhyITz@LRng4K$+(&gw>LN4dYOE)_kmL~#JP1vp69^QFW=E5e4onHvt17S z1=J>ZAA@HygTZV#oj!R!eewyE1)z4mTdkJ+-F8rwrzi8$n!6Y`o6TS_c>gTHPwfar zgbX7>>VuYmQc~Y_w^ui>zWe6->XHHSB16#7HjE~v#B7VA$gJajQ>0{w_0bi3sDgh- zi00!aB=oQsd^98Wsjg%hA|_99o!VFR?VH8LcXEw0!1F52vrf6Gp|hw(_V%*Axw^W( zxO_)xKOKj&X?HyDZ1vsMyO&`NlhMh^5t(rvGIk_gp$oIPYV~SaV|i-NM}sjD+8wn?7*q1;eZl@ypc8nCQG&Ec+VdgWMqhk${pFu-eH_n@PFeE1wJxr&h{mQE z6eZi-);`3fSzhKv6@#Rx1}(@(>Cun47Y6}ljL~}^Qardh(&1TF1Ul>>2zt|%*jv+D zYqIorD}-PaP(V|!T*t#;EAgJTE-EU30+2{G6CX9D1f1niLg!=P7>V0PRg+qc8brM+ z8x#PkB`~rikdz1wqoAOO5)&v&>f5BKz=W0zePjcVKdV>zU`YR%ULFrvT7viAc%Wdt z-yaftZIbtpQdN|nJbn7?$&*1@SvCqv2d@NCNd)@X>i%yx5d!r~yS_jk9UXn~#TPwm z=IZLoWZ;~us$r#N;R;4(1SQliP=-tp0Dw>cl?jQ2j5TJOXVd8@o}F#(t~PhK^?L0W zcR@j8B1E=eh?&?po4~`5<=uOUUGQxT$*Nu_n{E}^c>8@02DQ-q9Vi)_PgfG-~ayQci(L`t8q0bvW$fxY46g<%+^|C zEF_TxAZm<>>K}Eo-%G>#d1RSiymZR<{-%4RRDv37oMV8vkNdlJd9}N_37cKkjjNJo zt}|(ynq5~fcDvi{dbwP_ySQB6t(wyj9nb8uvwc-9cinZ^-o<8G%*)w?A#Zm|L!tps z%Hz0seYahrHsyGjm4yWe0!jvc%;@J~IV2)P*0LcY5djp@*fh;%v#IO4D#wPMXxCSM zV+w5d{^|rcSTlEZ)9dr!+g6KgYl~+tp&R%=7Pwn{x_c65h6EJ?@$cB$Iz?nIL{Cx^x zL=Tvd#3HB?CBTC#U;ko11Z{VIOJo2ja+`(-z=rd#CCu7>5&!}OBO!)}hy+O!B+~u| ziAdM)=i1b&YVXw}Ajpzf6pes}lo9}wRS^kZRTUB>Aw%>&K3;o-ft4fy0uve)R6#)gKe!PUw5K&mOuY#>GoPNGe)idC-+c4UYPI_0lTTs? zudd$|MR9t1IvS0faX|tX5K_{pjvXN>Xiz02L_ucY5=_SVcsM@e?H4zj zDj%Lq&k8q0aD;-8GRI8XleR_WeF6o1sL*I7WMBkDRWl+6SgkkTeEFvrUw_r@>OnEg zT&0jHF#vAsy4~)_wL9)5s@7q_GZ?9!1`W}vA<>lB2DRFa6`{Z&9hfYf`!0YGMprsEg_h+6AF zDFG0Zqbm9coEQmMV#2PCdG0iosQKdRuE4r!wq1(BM`x;>oup1vKuu_vj7z{#6eXpU zma98N91VufS!WCYv@!IM@;!Iv;daCy>9hI>Xbu_WHkm{ont;FRt(6Z=Crri{h!Fy-u%;j9+%J{`U6Y(9Xw`t%g7_ z8I?>BL+1!Y10W=F00RAB-Or`{)XV0fo)J+@so(pe0wW!!Yfu9y2p~d++Th(NkCJxn zjxw$$19a4;h{y+eE&>37q404sk$xmH_Tyd8kN+HFjS=c2w&i}90_=mne*EXyJ|hrF z58Px5!0J=%UzVx>%6*);^ybqEh!j)~13dsRXMLO2&&Y=lG%77C7$noTIYcNz@bRG# zZt0Zczs zbim&YOoMxNDC2Ayv(9+5+aHE5Cce;;-;+^+x7Y`43CcH z=VzzSo($)+WL!)jjEK11Zda=n01Su2s;VAS(LRv4yl*x1J?PSZ;@Bv>rvcY>d-3M& z%a~LBgpx!P^n^ES?FTW!`b#=s{j8?`;qw^9T7F2A)_am6QXi0#?ynT} z9FwluhJcZ!+lIH-?-uKYnPm}U3~jgBT?6oR+;q)$z5e#Q@3!^!?(M71dOaVRqV0A* zv~9Dfo14u}2`1BVHJfN=S_x<|F$UDSgmPB$)2UB-=b;Gusb(|EEbt-BiwyxrNRWh? zd-cmhsb)4sUS(MZ9tlnG>H7Nm?c29cpME-;RLo3<`ti{+GZQMLq)pR&^~bNi{pvdu zw(K&SEiP_nN7K{ulksuoikz7`iTxq$lLt}hlMO^tjQ6)heRp@)lbj#sv%?*whi>Cx zN<3hn2;TdYEGYGSbuHtPKz05nAq zVFVFLAqXn6F_~Lm-&|c?m1Q{^9JI6Edp6u(-9s&T@TT}kI2zs8L`DScQF3HNU}K0R z#NBp{Afs_vj`Bb?xiN@cZ1;cs`ugiXZR%ZB=FbZEybV}ro|Q9+E^T(3roFsfyqgxA zU9H4LHMB0*7^EM#8DsjX-D+QG`f>1n7W_DqYNr~W<&v<~0!n2_}gpRb02R0$-C zs$c>DRANy02vZip-E)3`SX3jD0tQTUh`Bl_o2iex#KXyTpyA!GoI%0db2veP1W}O^ zsBI$#Apt{dh!Ya^xcrYDA0Ine9v!0x|D^te6^TC}pdY?W8Xq2*p5GIE8`>`J>S0-( zOs1bdd3JgxVnw z2RMj*j0#|l3&CGrTzvcOx39l@8M{b`&N*i5y+=V%?j_qr{sR=kkKV?Z12GV# z6k<|DB|;)bkPt%ueuzX-ZJPaZx!l(q$t_j3N!{J*3Qej8)orZnwT4p#el zXP#z7sV;yf0Wby#QHV4!prRy^zL!s;*5!lIcrcvQm)~(llHmI0?yIl9IXQjyh53Yy z71G1#U@~Sz;FSD!yItI@7FSF4nsY(G+pG2MY_wnPkLv09lPASwfWVe8rJ%?zqsYk2 zy=fFPrx=&Z<<-@d_nrv*>;5pZh@|_tcRZMEJl+^8^~wuLfXGA`G#N<;37KjP2_v8( zNLXa$sGRzM@7`X%xw!NiszFw7cS!{aEn6l~RUeXIA~r;Hw_09ZT^$`AMM=H1Ac|Nt zM5On*wGZv!$0i|6N^*#>$DYKZ04kCaIM?p>`^`Fuw=BR($qQKT?c2BEkAK*`dI=$% z81qzM+V}v%17nKepw;zed40Lw|LM(}*sTXenO7s@^42F!LZnRQu>r;7kDk8U_ZcD* zLg;Z(_z6-Hf#L!Aa9NfW`A-;VeDvJ=D;>Q?ny8eN6JY3-biGkD*pGO}#RH6>8WbX+ z2muf#sZ{mRjHL&aOD}g(g5E*9|Mx;sA}IB7E%%240YKh)X+s~T+T+fg1bRWY0*K;+ z;nrjJ+#_}BJtBW7e%goG9sc}a-SYlRL==rFrIZ4IMTOA$&^0BS)6wMV@#%Cl%#aW? zrG!b)8u*X&AtFdG|{(h+tzj4wxltu2IblLAg0Z=S#Q>9vqPItrln&OI=`vgy>C5;5SNo_ zZim(BNy_qNQ#&@*Xi99N#>HZ>SS(_UqtR$Q9y79l5FE_ydP2N&?tOmpM>xZpgaOg8 ziZ1W&UcUJ5;@!KpZZexQ5Rr*7rYM#jQm^fngF(jk`3wIMKEA*2VNgy{B^|aV{oxSB z*KHl6@8K3zSuAf~zkZop8J-@wa^g6r{6vRYGvMa--H|c#r%#`qouAESDTJG<+H6-q z7;PD;@k+O=)s5Dnt6@-8Ha|T+9t;K%Wak?zAeN&A59DlGK}bVl9!G=AW_7pNZc|y} zm`w&kUW9-M0F@{~q6Z2wiHLP>JenWP&t6|T)ky3@kT-8HzWVB$!La!Bv$H5ErG&^L zg2>i#mK9l66s~e6>$Yu@0HXC?9GzuYlkeY$M}yK0(hVXVqI83F3eqjoT>~Ts(x}7` z5ReW5$uZ(bcbAh*q+41TgXjJ~yx=XzG49>h^}Vjo=REUu%^MJ~$@Z##*Pa^(5oR7; zer}iVOYsYrYEv$GkXo}EAkiDiA?ves0Y1zBbpSr0zz=KZPHF7r86T4srm|D`dRZ#K z&d8Msp&({(eMZruWi3xTg0%6zA1<=FV|^H2^i~KjBpF*1uvAu(97y0Jf|MvdVh5J* zNiTC#z(lZm5)8+M3*$_!M6RY^(_kE_*q>fdaPWnRJnUP%9#j`yK zWwxAD_k)k;h8ZUVdQ6TlY5Gg}l9f$aH(!%kq+L_33%~TBztS#q${;zgr`Iqd6pbvS z`U>2TD4F1ia0pO}I4QJh$V?;v<*ssLWkXO4!JcxDK6Q)V&B>Rv%=}z%qK_=KSV{cSnCJ zJzX2Bq+KE)06KGv%jt1(J?jyr7@~gmM%n#m0wgUxz2X0V0Kc3VbOQKFT%p0sfq~Y+ z))m#57x4N6!SOIR<=OzsFaG!`*ph3JaEp^`Gb)h(b*zhT3*&V zN{Qh16_AnBdO;lHRBOLP@3;eg^d^Z1m%yihj(l1(jMms+F0uDQK$5wvQx!6r)-d6v3zI3Bw@_CuMahe;` zV=dlMs+@$MOPqd^HMuLO!yI$;IHnFtU0RZ%8oBIS^2$=a#PDE-~=#dHa8Df zkN=eId6_a~NJ0O%h1;pfE^Th48TL?9EE!s%_}zG6EB^!rhL`vJ_ zS`8v+e6f`&wL)V!E1w=$Fc8QA-yS^@Uk=2aZH;?(-)vu9nW>5$-1h%59P(qWBv|1+ zH`LE`0PRM6EZbNQktn2hRMyFWtbV;uG>V{YF2`%pA6c224bd?!jH{24d}KNr#sp6)Ztic888SSZb0mI$ow<{4+7XHt)!OXn&>z9>{4RC&xieG9kIdT5W z@&HcOCk^O+bgh>$_kz>sg5$UnE_wb!ao=qk!hL9R1x+VbeIyEWcbsL&-@}%@y{<2R zknn%I?8bN0*V9kb5SJDt$Zkv&j1jsH@39)Rw)T_?wUK~fB71_TfIpZz>gLkN~_!5%U>Z6}_ z_!csJNjl6C*?_2K1aE8-SrmnlR<0g7zlkiCy@&iokpo;!{l{J;9m+L1*LD|gaGGy)vg zi7>^aR6=Xd8Tt>dAQ1-24SC`d&x`N>SKiK4loZ0~STscGev%WBpflGFyW)>Gk zQ*F~~%2`^o(v#m_@GqZBCtl-ZDA)WmW~uwDsk9ROLt*k(eW3D>ia|6$<7>K`VWVQ5 z%Ip0k9lZCb&hwLNmll~iGk@6yn>abHt@Gi z7ZXzDJaHcaD&~i|gYB6iL-g$2x-km$q%4<=n24p=qCJB$rDKg=4Z$aOim>^ff7d^2 z<;l$0Z^&b=^Zci$(tsH_K$gX^;XOC?N3L5%XOj@%1Q!0)@Y8)#XIqQf_|@j-Ca_v-0^)Z3|g={4hH^DjO#IpIHom1q?mk%CQB0^TfYqtf&Sbw?gP+!TK3SS!D(O zJ)9ruS=$d2>#m~8nAx9z3}#nume4M?-gF$NoW zz}lQqws6bz?K4^i{7gk4!V2tuK1_V9h~16|v1l;1bdh-J>L?aak`aN875?|#Ad88K zjv)zRW5f8mCQ7qiWB!@Bxh~zN*Uhj3cr}3}^#0EW;p%FKrq&6?PZ`a`$$~GzVV$9V z9}o*i5z0ofG25ei^Yf+6vQOMp{Ss=`@KuP3MTY=;J+$Wkmx*!Vp017#L(_v=QJ#2G ztRzi`+g`#wBx;k5L?Qu5B^H5i2c3czc&#Y2)v4#jo1e3TpoFha_S;VCZH3JaFvv8$ z!n>bqU1+aG36!;j#Z_9Axa^l{0oT|6uqOBMwgx;>C=4k0ftD%#Rk}T*6*qS$6vcLB z6u@zxN0~4{ z1hor49c%jaaOZmJn5MVqde7(oLXJ`=Q^lcy4-S2d^WtU%4?;$R4!mdZ}3&@J|axDc;|?f3na3<&(CaroTT^(184GK0MBz{f#6tyENd5 ziVOQ0TZySeF6llJ6RH7hq+$v(Q#!QlN;(Om+(`ne4!yF4gb&AQ06lBM1$&N@QcmXu z6Y0+R&E%vmS@Me=(gQ^Y4vC@7P-H8>WvDs0oq3;pHThOkwCp3yLktRl!1AEHcr==X z9v^_|Fwz`_6s#Ar-3;|wWqZSoJc63S{V!Q2^;QYlDI|g7W8+Vf*8ZlkG&|)79{(zb>#7k`pz&f9>QP4*P$++F52asAISkoDQBp_sZ`;tAaxW?N0AO5*IIVHr*>QDt9uqjY6DsF;YG**B z<+uY^3-HjKZ4@=s&enRO^$8L~cnH=ima5n@@qbx3ZRC=U?_B5Gh*+!t+%`T4LtGkJ z+Dd0-n<$zz_DN8jDN z9zSwudlxI6E11I5llMPaLjhIhF3wTR(m~*IAmHdZYO{^H1U64?-l!q^4<)+{LWVRa z@MKa&2k+c?a1`krN~9!q_TRJ60wV5vzrpHP%@#?A6ea6aK^H z`OBw)EZKgrb4&2GOJ2PBskyDJoXl7*J-X_=H}cT?l3dw(S*TfRy1I)#RUH{lexx)T z*^;*tda*wI1XZ{co2V)!R$EQniL66*!EcWz5B6?|EYbUGiw`?wJ!o&#y?yYts&kgB zK9-(}G_^DvBO4tdzdEa^s0BwUwqhE;aLxMJ)pk&$rCxb{pXapm)Asem+xERU)3upk*A4)!6wiK%=s zwb+kIkXbkW^sk@e>~wXqRMpsdE=myfBw3Ut1V)RPa_f^<{n=i&ut~{=h7nr-T@zPZ ztaaO(Ig$_dV3AE_nMt~FioCfxn;e%0AgQxAGO~Y@M)*NuTm23usfbp&47cx`qV&1O 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 literal 0 HcmV?d00001 diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index f4267de234c..bb3f76e0d1b 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -118,10 +118,33 @@ async def test_get_image_from_camera_with_width_height_scaled(hass, image_mock_u ) assert mock_camera.called - assert image.content_type == "image/jpeg" + assert image.content_type == "image/jpg" assert image.content == EMPTY_8_6_JPEG +async def test_get_image_from_camera_not_jpeg(hass, image_mock_url): + """Grab an image from camera entity that we cannot scale.""" + + turbo_jpeg = mock_turbo_jpeg( + first_width=16, first_height=12, second_width=300, second_height=200 + ) + with patch( + "homeassistant.components.camera.img_util.TurboJPEGSingleton.instance", + return_value=turbo_jpeg, + ), patch( + "homeassistant.components.demo.camera.Path.read_bytes", + autospec=True, + return_value=b"png", + ) as mock_camera: + image = await camera.async_get_image( + hass, "camera.demo_camera_png", width=4, height=3 + ) + + assert mock_camera.called + assert image.content_type == "image/png" + assert image.content == b"png" + + async def test_get_stream_source_from_camera(hass, mock_camera): """Fetch stream source from camera entity.""" @@ -200,7 +223,7 @@ async def test_websocket_camera_thumbnail(hass, hass_ws_client, mock_camera): assert msg["id"] == 5 assert msg["type"] == TYPE_RESULT assert msg["success"] - assert msg["result"]["content_type"] == "image/jpeg" + assert msg["result"]["content_type"] == "image/jpg" assert msg["result"]["content"] == base64.b64encode(b"Test").decode("utf-8") From 028a3d3e53e1652b0425cdd2eacfbab4b4846b51 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 11 Aug 2021 17:19:02 +0200 Subject: [PATCH 332/903] Complete mysensors sensor coverage (#54471) --- .coveragerc | 1 - tests/components/mysensors/conftest.py | 93 ++++++++++-- tests/components/mysensors/test_sensor.py | 136 +++++++++++++++++- .../mysensors/distance_sensor_state.json | 22 +++ .../mysensors/energy_sensor_state.json | 21 +++ .../mysensors/sound_sensor_state.json | 21 +++ .../mysensors/temperature_sensor_state.json | 21 +++ 7 files changed, 301 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/mysensors/distance_sensor_state.json create mode 100644 tests/fixtures/mysensors/energy_sensor_state.json create mode 100644 tests/fixtures/mysensors/sound_sensor_state.json create mode 100644 tests/fixtures/mysensors/temperature_sensor_state.json diff --git a/.coveragerc b/.coveragerc index 8088bbece78..bf51d5ad594 100644 --- a/.coveragerc +++ b/.coveragerc @@ -666,7 +666,6 @@ omit = homeassistant/components/mysensors/helpers.py homeassistant/components/mysensors/light.py homeassistant/components/mysensors/notify.py - homeassistant/components/mysensors/sensor.py homeassistant/components/mysensors/switch.py homeassistant/components/mystrom/binary_sensor.py homeassistant/components/mystrom/light.py diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 49c32301442..1843e495801 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -3,13 +3,14 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Generator import json -from typing import Any +from typing import Any, Callable from unittest.mock import MagicMock, patch from mysensors.persistence import MySensorsJSONDecoder from mysensors.sensor import Sensor import pytest +from homeassistant.components.device_tracker.legacy import Device from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.mysensors import CONF_VERSION, DEFAULT_BAUD_RATE from homeassistant.components.mysensors.const import ( @@ -27,14 +28,14 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture(autouse=True) -def device_tracker_storage(mock_device_tracker_conf): +def device_tracker_storage(mock_device_tracker_conf: list[Device]) -> list[Device]: """Mock out device tracker known devices storage.""" devices = mock_device_tracker_conf return devices @pytest.fixture(name="mqtt") -def mock_mqtt_fixture(hass) -> None: +def mock_mqtt_fixture(hass: HomeAssistant) -> None: """Mock the MQTT integration.""" hass.config.components.add(MQTT_DOMAIN) @@ -75,14 +76,14 @@ def mock_gateway_features( ) -> None: """Mock the gateway features.""" - async def mock_start_persistence(): + async def mock_start_persistence() -> None: """Load nodes from via persistence.""" gateway = transport_class.call_args[0][0] gateway.sensors.update(nodes) tasks.start_persistence.side_effect = mock_start_persistence - async def mock_start(): + async def mock_start() -> None: """Mock the start method.""" gateway = transport_class.call_args[0][0] gateway.on_conn_made(gateway) @@ -97,7 +98,7 @@ def transport_fixture(serial_transport: MagicMock) -> MagicMock: @pytest.fixture(name="serial_entry") -async def serial_entry_fixture(hass) -> MockConfigEntry: +async def serial_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: """Create a config entry for a serial gateway.""" entry = MockConfigEntry( domain=DOMAIN, @@ -120,15 +121,25 @@ def config_entry_fixture(serial_entry: MockConfigEntry) -> MockConfigEntry: @pytest.fixture async def integration( hass: HomeAssistant, transport: MagicMock, config_entry: MockConfigEntry -) -> AsyncGenerator[MockConfigEntry, None]: +) -> AsyncGenerator[tuple[MockConfigEntry, Callable[[str], None]], None]: """Set up the mysensors integration with a config entry.""" device = config_entry.data[CONF_DEVICE] config: dict[str, Any] = {DOMAIN: {CONF_GATEWAYS: [{CONF_DEVICE: device}]}} config_entry.add_to_hass(hass) + + def receive_message(message_string: str) -> None: + """Receive a message with the transport. + + The message_string parameter is a string in the MySensors message format. + """ + gateway = transport.call_args[0][0] + # node_id;child_id;command;ack;type;payload\n + gateway.logic(message_string) + with patch("homeassistant.components.mysensors.device.UPDATE_DELAY", new=0): await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() - yield config_entry + yield config_entry, receive_message def load_nodes_state(fixture_path: str) -> dict: @@ -151,7 +162,7 @@ def gps_sensor_state_fixture() -> dict: @pytest.fixture -def gps_sensor(gateway_nodes, gps_sensor_state) -> Sensor: +def gps_sensor(gateway_nodes: dict[int, Sensor], gps_sensor_state: dict) -> Sensor: """Load the gps sensor.""" nodes = update_gateway_nodes(gateway_nodes, gps_sensor_state) node = nodes[1] @@ -165,8 +176,70 @@ def power_sensor_state_fixture() -> dict: @pytest.fixture -def power_sensor(gateway_nodes, power_sensor_state) -> Sensor: +def power_sensor(gateway_nodes: dict[int, Sensor], power_sensor_state: dict) -> Sensor: """Load the power sensor.""" nodes = update_gateway_nodes(gateway_nodes, power_sensor_state) node = nodes[1] return node + + +@pytest.fixture(name="energy_sensor_state", scope="session") +def energy_sensor_state_fixture() -> dict: + """Load the energy sensor state.""" + return load_nodes_state("mysensors/energy_sensor_state.json") + + +@pytest.fixture +def energy_sensor( + gateway_nodes: dict[int, Sensor], energy_sensor_state: dict +) -> Sensor: + """Load the energy sensor.""" + nodes = update_gateway_nodes(gateway_nodes, energy_sensor_state) + node = nodes[1] + return node + + +@pytest.fixture(name="sound_sensor_state", scope="session") +def sound_sensor_state_fixture() -> dict: + """Load the sound sensor state.""" + return load_nodes_state("mysensors/sound_sensor_state.json") + + +@pytest.fixture +def sound_sensor(gateway_nodes: dict[int, Sensor], sound_sensor_state: dict) -> Sensor: + """Load the sound sensor.""" + nodes = update_gateway_nodes(gateway_nodes, sound_sensor_state) + node = nodes[1] + return node + + +@pytest.fixture(name="distance_sensor_state", scope="session") +def distance_sensor_state_fixture() -> dict: + """Load the distance sensor state.""" + return load_nodes_state("mysensors/distance_sensor_state.json") + + +@pytest.fixture +def distance_sensor( + gateway_nodes: dict[int, Sensor], distance_sensor_state: dict +) -> Sensor: + """Load the distance sensor.""" + nodes = update_gateway_nodes(gateway_nodes, distance_sensor_state) + node = nodes[1] + return node + + +@pytest.fixture(name="temperature_sensor_state", scope="session") +def temperature_sensor_state_fixture() -> dict: + """Load the temperature sensor state.""" + return load_nodes_state("mysensors/temperature_sensor_state.json") + + +@pytest.fixture +def temperature_sensor( + gateway_nodes: dict[int, Sensor], temperature_sensor_state: dict +) -> Sensor: + """Load the temperature sensor.""" + nodes = update_gateway_nodes(gateway_nodes, temperature_sensor_state) + node = nodes[1] + return node diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 6edddc68592..880226ced60 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -1,31 +1,161 @@ """Provide tests for mysensors sensor platform.""" +from __future__ import annotations +from typing import Callable -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from mysensors.sensor import Sensor +import pytest + +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, POWER_WATT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, ) +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utc_from_timestamp +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem + +from tests.common import MockConfigEntry -async def test_gps_sensor(hass, gps_sensor, integration): +async def test_gps_sensor( + hass: HomeAssistant, + gps_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], +) -> None: """Test a gps sensor.""" entity_id = "sensor.gps_sensor_1_1" + _, receive_message = integration state = hass.states.get(entity_id) + assert state assert state.state == "40.741894,-73.989311,12" + altitude = 0 + new_coords = "40.782,-73.965" + message_string = f"1;1;1;0;49;{new_coords},{altitude}\n" -async def test_power_sensor(hass, power_sensor, integration): + receive_message(message_string) + # the integration adds multiple jobs to do the update currently + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == f"{new_coords},{altitude}" + + +async def test_power_sensor( + hass: HomeAssistant, + power_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], +) -> None: """Test a power sensor.""" entity_id = "sensor.power_sensor_1_1" state = hass.states.get(entity_id) + assert state assert state.state == "1200" assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert ATTR_LAST_RESET not in state.attributes + + +async def test_energy_sensor( + hass: HomeAssistant, + energy_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], +) -> None: + """Test an energy sensor.""" + entity_id = "sensor.energy_sensor_1_1" + + state = hass.states.get(entity_id) + + assert state + assert state.state == "18000" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_LAST_RESET] == utc_from_timestamp(0).isoformat() + + +async def test_sound_sensor( + hass: HomeAssistant, + sound_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], +) -> None: + """Test a sound sensor.""" + entity_id = "sensor.sound_sensor_1_1" + + state = hass.states.get(entity_id) + + assert state + assert state.state == "10" + assert state.attributes[ATTR_ICON] == "mdi:volume-high" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "dB" + + +async def test_distance_sensor( + hass: HomeAssistant, + distance_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], +) -> None: + """Test a distance sensor.""" + entity_id = "sensor.distance_sensor_1_1" + + state = hass.states.get(entity_id) + + assert state + assert state.state == "15" + assert state.attributes[ATTR_ICON] == "mdi:ruler" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "cm" + + +@pytest.mark.parametrize( + "unit_system, unit", + [(METRIC_SYSTEM, TEMP_CELSIUS), (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT)], +) +async def test_temperature_sensor( + hass: HomeAssistant, + temperature_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], + unit_system: UnitSystem, + unit: str, +) -> None: + """Test a temperature sensor.""" + entity_id = "sensor.temperature_sensor_1_1" + hass.config.units = unit_system + _, receive_message = integration + temperature = "22.0" + message_string = f"1;1;1;0;0;{temperature}\n" + + receive_message(message_string) + # the integration adds multiple jobs to do the update currently + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == temperature + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == unit + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT diff --git a/tests/fixtures/mysensors/distance_sensor_state.json b/tests/fixtures/mysensors/distance_sensor_state.json new file mode 100644 index 00000000000..ff8b246b880 --- /dev/null +++ b/tests/fixtures/mysensors/distance_sensor_state.json @@ -0,0 +1,22 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 15, + "description": "", + "values": { + "13": "15", + "43": "cm" + } + } + }, + "type": 17, + "sketch_name": "Distance Sensor", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/fixtures/mysensors/energy_sensor_state.json b/tests/fixtures/mysensors/energy_sensor_state.json new file mode 100644 index 00000000000..063083c9c1e --- /dev/null +++ b/tests/fixtures/mysensors/energy_sensor_state.json @@ -0,0 +1,21 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 13, + "description": "", + "values": { + "18": "18000" + } + } + }, + "type": 17, + "sketch_name": "Energy Sensor", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/fixtures/mysensors/sound_sensor_state.json b/tests/fixtures/mysensors/sound_sensor_state.json new file mode 100644 index 00000000000..35651243250 --- /dev/null +++ b/tests/fixtures/mysensors/sound_sensor_state.json @@ -0,0 +1,21 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 33, + "description": "", + "values": { + "37": "10" + } + } + }, + "type": 17, + "sketch_name": "Sound Sensor", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/fixtures/mysensors/temperature_sensor_state.json b/tests/fixtures/mysensors/temperature_sensor_state.json new file mode 100644 index 00000000000..4367be6a3cd --- /dev/null +++ b/tests/fixtures/mysensors/temperature_sensor_state.json @@ -0,0 +1,21 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 6, + "description": "", + "values": { + "0": "20.0" + } + } + }, + "type": 17, + "sketch_name": "Temperature Sensor", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} From 6f70302901b16764da8160bfd8faa104a3d402cb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 11 Aug 2021 17:57:56 +0200 Subject: [PATCH 333/903] Fix arlo platform schema (#54470) --- homeassistant/components/arlo/sensor.py | 30 +++++++++++++++++++------ 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index d17668ae721..cc08cd133e4 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -1,4 +1,6 @@ """Sensor support for Netgear Arlo IP cameras.""" +from __future__ import annotations + from dataclasses import replace import logging @@ -28,11 +30,21 @@ from . import ATTRIBUTION, DATA_ARLO, DEFAULT_BRAND, SIGNAL_UPDATE_ARLO _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = ( - SensorEntityDescription(key="last_capture", name="Last", icon="mdi:run-fast"), - SensorEntityDescription(key="total_cameras", name="Arlo Cameras", icon="mdi:video"), +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key="captured_today", name="Captured Today", icon="mdi:file-video" + key="last_capture", + name="Last", + icon="mdi:run-fast", + ), + SensorEntityDescription( + key="total_cameras", + name="Arlo Cameras", + icon="mdi:video", + ), + SensorEntityDescription( + key="captured_today", + name="Captured Today", + icon="mdi:file-video", ), SensorEntityDescription( key="battery_level", @@ -41,7 +53,9 @@ SENSOR_TYPES = ( device_class=DEVICE_CLASS_BATTERY, ), SensorEntityDescription( - key="signal_strength", name="Signal Strength", icon="mdi:signal" + key="signal_strength", + name="Signal Strength", + icon="mdi:signal", ), SensorEntityDescription( key="temperature", @@ -63,10 +77,12 @@ SENSOR_TYPES = ( ), ) +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Required(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) From d6483f2f36ab3febe9565d25209dca63cb09d0e5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 11 Aug 2021 18:01:45 +0200 Subject: [PATCH 334/903] Upgrade isort to 5.9.3 (#54481) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b31a9cef116..38ba2a503af 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: - --configfile=tests/bandit.yaml files: ^(homeassistant|script|tests)/.+\.py$ - repo: https://github.com/PyCQA/isort - rev: 5.8.0 + rev: 5.9.3 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 19d55b1255c..e89785c25a8 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -7,7 +7,7 @@ flake8-comprehensions==3.5.0 flake8-docstrings==1.6.0 flake8-noqa==1.1.0 flake8==3.9.2 -isort==5.8.0 +isort==5.9.3 mccabe==0.6.1 pycodestyle==2.7.0 pydocstyle==6.0.0 From 98a4e6a7e8bf29fc8e17be57423ad590300dc177 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 11 Aug 2021 09:12:49 -0700 Subject: [PATCH 335/903] Fix possible unhandled IQVIA exception with allergy outlook data (#54477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- homeassistant/components/iqvia/sensor.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 0ff236a8f79..5762e4a3888 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -104,7 +104,7 @@ class ForecastSensor(IQVIAEntity): @callback def update_from_latest_data(self): """Update the sensor.""" - if not self.coordinator.data: + if not self.available: return data = self.coordinator.data.get("Location", {}) @@ -120,6 +120,7 @@ class ForecastSensor(IQVIAEntity): if i["minimum"] <= average <= i["maximum"] ] + self._attr_state = average self._attr_extra_state_attributes.update( { ATTR_CITY: data["City"].title(), @@ -134,6 +135,10 @@ class ForecastSensor(IQVIAEntity): outlook_coordinator = self.hass.data[DOMAIN][DATA_COORDINATOR][ self._entry.entry_id ][TYPE_ALLERGY_OUTLOOK] + + if not outlook_coordinator.last_update_success: + return + self._attr_extra_state_attributes[ ATTR_OUTLOOK ] = outlook_coordinator.data.get("Outlook") @@ -141,8 +146,6 @@ class ForecastSensor(IQVIAEntity): ATTR_SEASON ] = outlook_coordinator.data.get("Season") - self._attr_state = average - class IndexSensor(IQVIAEntity): """Define sensor related to indices.""" From f020d65416177e4647a846ec017f441fe08c0696 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 11 Aug 2021 18:49:56 +0200 Subject: [PATCH 336/903] Add battery support to energy (#54432) --- homeassistant/components/energy/data.py | 19 ++++++++++++++++++- tests/components/energy/test_websocket_api.py | 5 +++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index c053dea4741..9196694953a 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -79,7 +79,16 @@ class SolarSourceType(TypedDict): config_entry_solar_forecast: list[str] | None -SourceType = Union[GridSourceType, SolarSourceType] +class BatterySourceType(TypedDict): + """Dictionary holding the source of battery storage.""" + + type: Literal["battery"] + + stat_energy_from: str + stat_energy_to: str + + +SourceType = Union[GridSourceType, SolarSourceType, BatterySourceType] class DeviceConsumption(TypedDict): @@ -177,6 +186,13 @@ SOLAR_SOURCE_SCHEMA = vol.Schema( vol.Optional("config_entry_solar_forecast"): vol.Any([str], None), } ) +BATTERY_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "battery", + vol.Required("stat_energy_from"): str, + vol.Required("stat_energy_to"): str, + } +) def check_type_limits(value: list[SourceType]) -> list[SourceType]: @@ -197,6 +213,7 @@ ENERGY_SOURCE_SCHEMA = vol.All( { "grid": GRID_SOURCE_SCHEMA, "solar": SOLAR_SOURCE_SCHEMA, + "battery": BATTERY_SOURCE_SCHEMA, }, ) ] diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index a14a8d0986e..60ac82108bc 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -104,6 +104,11 @@ async def test_save_preferences(hass, hass_ws_client, hass_storage) -> None: "stat_energy_from": "my_solar_production", "config_entry_solar_forecast": ["predicted_config_entry"], }, + { + "type": "battery", + "stat_energy_from": "my_battery_draining", + "stat_energy_to": "my_battery_charging", + }, ], "device_consumption": [{"stat_consumption": "some_device_usage"}], } From 41f3c2766c41fc79b269de83baea62dd0d18a9b9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Aug 2021 18:57:12 +0200 Subject: [PATCH 337/903] Move temperature conversions to entity base class (2/8) (#54468) --- homeassistant/components/daikin/sensor.py | 8 +- .../components/danfoss_air/sensor.py | 4 +- homeassistant/components/darksky/sensor.py | 6 +- homeassistant/components/deconz/sensor.py | 14 +- homeassistant/components/delijn/sensor.py | 2 +- homeassistant/components/deluge/sensor.py | 4 +- homeassistant/components/demo/sensor.py | 4 +- homeassistant/components/derivative/sensor.py | 4 +- .../components/deutsche_bahn/sensor.py | 2 +- .../components/devolo_home_control/sensor.py | 8 +- homeassistant/components/dexcom/sensor.py | 6 +- homeassistant/components/dht/sensor.py | 4 +- homeassistant/components/discogs/sensor.py | 4 +- homeassistant/components/dnsip/sensor.py | 4 +- homeassistant/components/dovado/sensor.py | 4 +- homeassistant/components/dsmr/sensor.py | 4 +- .../components/dsmr_reader/definitions.py | 130 +++++++++--------- .../components/dsmr_reader/sensor.py | 4 +- .../components/dte_energy_bridge/sensor.py | 4 +- .../components/dublin_bus_transport/sensor.py | 4 +- .../components/dwd_weather_warnings/sensor.py | 2 +- homeassistant/components/dweet/sensor.py | 4 +- homeassistant/components/dyson/sensor.py | 18 +-- homeassistant/components/eafm/sensor.py | 4 +- homeassistant/components/ebox/sensor.py | 28 ++-- homeassistant/components/ebusd/sensor.py | 4 +- .../components/ecoal_boiler/sensor.py | 4 +- homeassistant/components/ecobee/sensor.py | 4 +- homeassistant/components/econet/sensor.py | 4 +- .../eddystone_temperature/sensor.py | 4 +- homeassistant/components/edl21/sensor.py | 4 +- homeassistant/components/efergy/sensor.py | 4 +- .../components/eight_sleep/sensor.py | 12 +- homeassistant/components/eliqonline/sensor.py | 4 +- homeassistant/components/elkm1/sensor.py | 6 +- homeassistant/components/emoncms/sensor.py | 4 +- homeassistant/components/emonitor/sensor.py | 4 +- homeassistant/components/energy/sensor.py | 6 +- homeassistant/components/enocean/sensor.py | 4 +- .../components/enphase_envoy/const.py | 18 +-- .../components/enphase_envoy/sensor.py | 2 +- .../entur_public_transport/sensor.py | 4 +- .../components/environment_canada/sensor.py | 4 +- homeassistant/components/envirophat/sensor.py | 4 +- homeassistant/components/envisalink/sensor.py | 2 +- .../components/epsonworkforce/sensor.py | 14 +- homeassistant/components/esphome/sensor.py | 6 +- homeassistant/components/essent/sensor.py | 4 +- homeassistant/components/etherscan/sensor.py | 4 +- homeassistant/components/ezviz/sensor.py | 2 +- 50 files changed, 207 insertions(+), 205 deletions(-) diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 3bfc0a3926c..0defa633387 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -86,7 +86,7 @@ class DaikinSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" raise NotImplementedError @@ -101,7 +101,7 @@ class DaikinSensor(SensorEntity): return self._sensor.get(CONF_ICON) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._sensor[CONF_UNIT_OF_MEASUREMENT] @@ -119,7 +119,7 @@ class DaikinClimateSensor(DaikinSensor): """Representation of a Climate Sensor.""" @property - def state(self): + def native_value(self): """Return the internal state of the sensor.""" if self._device_attribute == ATTR_INSIDE_TEMPERATURE: return self._api.device.inside_temperature @@ -141,7 +141,7 @@ class DaikinPowerSensor(DaikinSensor): """Representation of a power/energy consumption sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._device_attribute == ATTR_TOTAL_POWER: return round(self._api.device.current_total_power_consumption, 2) diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index 792a95e8ac4..25db56a1624 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -100,12 +100,12 @@ class DanfossAir(SensorEntity): return self._device_class @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index 058969d96f9..e73d9b2e1be 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -574,12 +574,12 @@ class DarkSkySensor(SensorEntity): return f"{self.client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement @@ -730,7 +730,7 @@ class DarkSkyAlertSensor(SensorEntity): return f"{self.client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 9282f2d26cc..a741a2d37c1 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -160,7 +160,9 @@ class DeconzSensor(DeconzDevice, SensorEntity): self._attr_device_class = DEVICE_CLASS.get(type(self._device)) self._attr_icon = ICON.get(type(self._device)) self._attr_state_class = STATE_CLASS.get(type(self._device)) - self._attr_unit_of_measurement = UNIT_OF_MEASUREMENT.get(type(self._device)) + self._attr_native_unit_of_measurement = UNIT_OF_MEASUREMENT.get( + type(self._device) + ) if device.type in Consumption.ZHATYPE: self._attr_last_reset = dt_util.utc_from_timestamp(0) @@ -173,7 +175,7 @@ class DeconzSensor(DeconzDevice, SensorEntity): super().async_update_callback(force_update=force_update) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.state @@ -217,7 +219,7 @@ class DeconzTemperature(DeconzDevice, SensorEntity): _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS TYPE = DOMAIN @@ -240,7 +242,7 @@ class DeconzTemperature(DeconzDevice, SensorEntity): super().async_update_callback(force_update=force_update) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.secondary_temperature @@ -250,7 +252,7 @@ class DeconzBattery(DeconzDevice, SensorEntity): _attr_device_class = DEVICE_CLASS_BATTERY _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE TYPE = DOMAIN @@ -284,7 +286,7 @@ class DeconzBattery(DeconzDevice, SensorEntity): return f"{self.serial}-battery" @property - def state(self): + def native_value(self): """Return the state of the battery.""" return self._device.battery diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index b105ff5ff7b..395c2d93dff 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -112,7 +112,7 @@ class DeLijnPublicTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 0c79e6f835e..63c9645dac4 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -87,7 +87,7 @@ class DelugeSensor(SensorEntity): return f"{self.client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -97,7 +97,7 @@ class DelugeSensor(SensorEntity): return self._available @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 488c34be983..21ec8e1d391 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -120,10 +120,10 @@ class DemoSensor(SensorEntity): """Initialize the sensor.""" self._attr_device_class = device_class self._attr_name = name - self._attr_state = state + self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_native_value = state self._attr_state_class = state_class self._attr_unique_id = unique_id - self._attr_unit_of_measurement = unit_of_measurement self._attr_device_info = { "identifiers": {(DOMAIN, unique_id)}, diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index c8b639a1db1..45f5db57a90 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -196,12 +196,12 @@ class DerivativeSensor(RestoreEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return round(self._state, self._round_digits) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/deutsche_bahn/sensor.py b/homeassistant/components/deutsche_bahn/sensor.py index 33fd9a8224f..34711a9a2d7 100644 --- a/homeassistant/components/deutsche_bahn/sensor.py +++ b/homeassistant/components/deutsche_bahn/sensor.py @@ -59,7 +59,7 @@ class DeutscheBahnSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the departure time of the next train.""" return self._state diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 7cb8cc8e837..5c8bed7818b 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -78,7 +78,7 @@ class DevoloMultiLevelDeviceEntity(DevoloDeviceEntity, SensorEntity): """Abstract representation of a multi level sensor within devolo Home Control.""" @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self._value @@ -106,7 +106,7 @@ class DevoloGenericMultiLevelDeviceEntity(DevoloMultiLevelDeviceEntity): self._attr_device_class = DEVICE_CLASS_MAPPING.get( self._multi_level_sensor_property.sensor_type ) - self._attr_unit_of_measurement = self._multi_level_sensor_property.unit + self._attr_native_unit_of_measurement = self._multi_level_sensor_property.unit self._value = self._multi_level_sensor_property.value @@ -132,7 +132,7 @@ class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): ) self._attr_device_class = DEVICE_CLASS_MAPPING.get("battery") - self._attr_unit_of_measurement = PERCENTAGE + self._attr_native_unit_of_measurement = PERCENTAGE self._value = device_instance.battery_level @@ -157,7 +157,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): self._sensor_type = consumption self._attr_device_class = DEVICE_CLASS_MAPPING.get(consumption) - self._attr_unit_of_measurement = getattr( + self._attr_native_unit_of_measurement = getattr( device_instance.consumption_property[element_uid], f"{consumption}_unit" ) diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index 730a1824e1a..316f36e3630 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -42,12 +42,12 @@ class DexcomGlucoseValueSensor(CoordinatorEntity, SensorEntity): return GLUCOSE_VALUE_ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of the device.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.coordinator.data: return getattr(self.coordinator.data, self._attribute_unit_of_measurement) @@ -82,7 +82,7 @@ class DexcomGlucoseTrendSensor(CoordinatorEntity, SensorEntity): return GLUCOSE_TREND_ICON[0] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.coordinator.data: return self.coordinator.data.trend_description diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py index 1300c165b37..d81d12f33cf 100644 --- a/homeassistant/components/dht/sensor.py +++ b/homeassistant/components/dht/sensor.py @@ -134,12 +134,12 @@ class DHTSensor(SensorEntity): return f"{self.client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index 81beec0e60e..3d90956a2b5 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -105,7 +105,7 @@ class DiscogsSensor(SensorEntity): return f"{self._name} {SENSORS[self._type]['name']}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -115,7 +115,7 @@ class DiscogsSensor(SensorEntity): return SENSORS[self._type]["icon"] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return SENSORS[self._type]["unit_of_measurement"] diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 2fb0e30da90..a429d336379 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -79,6 +79,6 @@ class WanIpSensor(SensorEntity): response = None if response: - self._attr_state = response[0].host + self._attr_native_value = response[0].host else: - self._attr_state = None + self._attr_native_value = None diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index e7b3dbdd363..46f4c34cc31 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -89,7 +89,7 @@ class DovadoSensor(SensorEntity): return f"{self._data.name} {SENSORS[self._sensor][1]}" @property - def state(self): + def native_value(self): """Return the sensor state.""" return self._state @@ -99,7 +99,7 @@ class DovadoSensor(SensorEntity): return SENSORS[self._sensor][3] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return SENSORS[self._sensor][2] diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index faff62ddeb4..ae3fb6b01a4 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -238,7 +238,7 @@ class DSMREntity(SensorEntity): return attr @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of sensor, if available, translate if needed.""" value = self.get_dsmr_object_attr("value") if value is None: @@ -258,7 +258,7 @@ class DSMREntity(SensorEntity): return None @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" return self.get_dsmr_object_attr("unit") diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 1a46f86132b..a5fc2b8147a 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -50,7 +50,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/reading/electricity_delivered_1", name="Low tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -58,7 +58,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/reading/electricity_returned_1", name="Low tariff returned", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -66,7 +66,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/reading/electricity_delivered_2", name="High tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -74,7 +74,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/reading/electricity_returned_2", name="High tariff returned", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -82,14 +82,14 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/reading/electricity_currently_delivered", name="Current power usage", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_currently_returned", name="Current power return", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -97,7 +97,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power usage L1", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -105,7 +105,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power usage L2", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -113,7 +113,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power usage L3", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -121,7 +121,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power return L1", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -129,7 +129,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power return L2", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -137,7 +137,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power return L3", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -145,7 +145,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Gas meter usage", entity_registry_enabled_default=False, icon="mdi:fire", - unit_of_measurement=VOLUME_CUBIC_METERS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -154,7 +154,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current voltage L1", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -162,7 +162,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current voltage L2", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -170,7 +170,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current voltage L3", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -178,7 +178,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Phase power current L1", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -186,7 +186,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Phase power current L2", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -194,7 +194,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Phase power current L3", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -207,7 +207,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/consumption/gas/delivered", name="Gas usage", icon="mdi:fire", - unit_of_measurement=VOLUME_CUBIC_METERS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -215,7 +215,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/consumption/gas/currently_delivered", name="Current gas usage", icon="mdi:fire", - unit_of_measurement=VOLUME_CUBIC_METERS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -228,7 +228,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/electricity1", name="Low tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -236,7 +236,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/electricity2", name="High tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -244,7 +244,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/electricity1_returned", name="Low tariff return", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -252,7 +252,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/electricity2_returned", name="High tariff return", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -260,7 +260,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/electricity_merged", name="Power usage total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -268,7 +268,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/electricity_returned_merged", name="Power return total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt_util.utc_from_timestamp(0), ), @@ -276,73 +276,73 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/electricity1_cost", name="Low tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2_cost", name="High tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_cost_merged", name="Power total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/gas", name="Gas usage", icon="mdi:counter", - unit_of_measurement=VOLUME_CUBIC_METERS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/gas_cost", name="Gas cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/total_cost", name="Total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", name="Low tariff delivered price", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", name="High tariff delivered price", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", name="Low tariff returned price", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", name="High tariff returned price", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_gas", name="Gas price", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/fixed_cost", name="Current day fixed cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/dsmr_version", @@ -415,156 +415,156 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/current-month/electricity1", name="Current month low tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2", name="Current month high tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1_returned", name="Current month low tariff returned", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2_returned", name="Current month high tariff returned", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_merged", name="Current month power usage total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_returned_merged", name="Current month power return total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1_cost", name="Current month low tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2_cost", name="Current month high tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_cost_merged", name="Current month power total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/gas", name="Current month gas usage", icon="mdi:counter", - unit_of_measurement=VOLUME_CUBIC_METERS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/gas_cost", name="Current month gas cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/fixed_cost", name="Current month fixed cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/total_cost", name="Current month total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1", name="Current year low tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2", name="Current year high tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1_returned", name="Current year low tariff returned", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2_returned", name="Current year high tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_merged", name="Current year power usage total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_returned_merged", name="Current year power returned total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1_cost", name="Current year low tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2_cost", name="Current year high tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_cost_merged", name="Current year power total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/gas", name="Current year gas usage", icon="mdi:counter", - unit_of_measurement=VOLUME_CUBIC_METERS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/gas_cost", name="Current year gas cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/fixed_cost", name="Current year fixed cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/total_cost", name="Current year total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), ) diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 39356db46b5..84947ec41f1 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -33,9 +33,9 @@ class DSMRSensor(SensorEntity): def message_received(message): """Handle new MQTT messages.""" if self.entity_description.state is not None: - self._attr_state = self.entity_description.state(message.payload) + self._attr_native_value = self.entity_description.state(message.payload) else: - self._attr_state = message.payload + self._attr_native_value = message.payload self.async_write_ha_state() diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py index 4e095955818..5b08e8e142c 100644 --- a/homeassistant/components/dte_energy_bridge/sensor.py +++ b/homeassistant/components/dte_energy_bridge/sensor.py @@ -66,12 +66,12 @@ class DteEnergyBridgeSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index dbe1d10b553..b7daf661e63 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -82,7 +82,7 @@ class DublinPublicTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -105,7 +105,7 @@ class DublinPublicTransportSensor(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 428ed3ab427..2668e573b7c 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -112,7 +112,7 @@ class DwdWeatherWarningsSensor(SensorEntity): self._attr_name = f"{name} {description.name}" @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.entity_description.key == CURRENT_WARNING_SENSOR: return self._api.api.current_warning_level diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py index f1243cd5407..3d980b34d00 100644 --- a/homeassistant/components/dweet/sensor.py +++ b/homeassistant/components/dweet/sensor.py @@ -73,12 +73,12 @@ class DweetSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state.""" return self._state diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py index cff4b8f5501..be83a7e4373 100644 --- a/homeassistant/components/dyson/sensor.py +++ b/homeassistant/components/dyson/sensor.py @@ -129,7 +129,7 @@ class DysonSensor(DysonEntity, SensorEntity): return f"{self._device.serial}-{self._sensor_type}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -152,7 +152,7 @@ class DysonFilterLifeSensor(DysonSensor): super().__init__(device, "filter_life") @property - def state(self): + def native_value(self): """Return filter life in hours.""" return int(self._device.state.filter_life) @@ -165,7 +165,7 @@ class DysonCarbonFilterLifeSensor(DysonSensor): super().__init__(device, "carbon_filter_state") @property - def state(self): + def native_value(self): """Return filter life remaining in percent.""" return int(self._device.state.carbon_filter_state) @@ -178,7 +178,7 @@ class DysonHepaFilterLifeSensor(DysonSensor): super().__init__(device, f"{filter_type}_filter_state") @property - def state(self): + def native_value(self): """Return filter life remaining in percent.""" return int(self._device.state.hepa_filter_state) @@ -191,7 +191,7 @@ class DysonDustSensor(DysonSensor): super().__init__(device, "dust") @property - def state(self): + def native_value(self): """Return Dust value.""" return self._device.environmental_state.dust @@ -204,7 +204,7 @@ class DysonHumiditySensor(DysonSensor): super().__init__(device, "humidity") @property - def state(self): + def native_value(self): """Return Humidity value.""" if self._device.environmental_state.humidity == 0: return STATE_OFF @@ -220,7 +220,7 @@ class DysonTemperatureSensor(DysonSensor): self._unit = unit @property - def state(self): + def native_value(self): """Return Temperature value.""" temperature_kelvin = self._device.environmental_state.temperature if temperature_kelvin == 0: @@ -230,7 +230,7 @@ class DysonTemperatureSensor(DysonSensor): return float(f"{(temperature_kelvin * 9 / 5 - 459.67):.1f}") @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit @@ -243,6 +243,6 @@ class DysonAirQualitySensor(DysonSensor): super().__init__(device, "air_quality") @property - def state(self): + def native_value(self): """Return Air Quality value.""" return int(self._device.environmental_state.volatil_organic_compounds) diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index b3d726f9cd3..bc2158e4db8 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -149,7 +149,7 @@ class Measurement(CoordinatorEntity, SensorEntity): return True @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return units for the sensor.""" measure = self.coordinator.data["measures"][self.key] if "unit" not in measure: @@ -162,6 +162,6 @@ class Measurement(CoordinatorEntity, SensorEntity): return {ATTR_ATTRIBUTION: self.attribution} @property - def state(self): + def native_value(self): """Return the current sensor value.""" return self.coordinator.data["measures"][self.key]["latestReading"]["value"] diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index e98dea45929..3c43dd36130 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -45,79 +45,79 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="usage", name="Usage", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", ), SensorEntityDescription( key="balance", name="Balance", - unit_of_measurement=PRICE, + native_unit_of_measurement=PRICE, icon="mdi:cash-usd", ), SensorEntityDescription( key="limit", name="Data limit", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="days_left", name="Days left", - unit_of_measurement=TIME_DAYS, + native_unit_of_measurement=TIME_DAYS, icon="mdi:calendar-today", ), SensorEntityDescription( key="before_offpeak_download", name="Download before offpeak", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="before_offpeak_upload", name="Upload before offpeak", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:upload", ), SensorEntityDescription( key="before_offpeak_total", name="Total before offpeak", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="offpeak_download", name="Offpeak download", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="offpeak_upload", name="Offpeak Upload", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:upload", ), SensorEntityDescription( key="offpeak_total", name="Offpeak Total", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="download", name="Download", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="upload", name="Upload", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:upload", ), SensorEntityDescription( key="total", name="Total", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), ) @@ -179,7 +179,7 @@ class EBoxSensor(SensorEntity): """Get the latest data from EBox and update the state.""" await self.ebox_data.async_update() if self.entity_description.key in self.ebox_data.data: - self._attr_state = round( + self._attr_native_value = round( self.ebox_data.data[self.entity_description.key], 2 ) diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py index abd9620130d..dcfd4ec7eef 100644 --- a/homeassistant/components/ebusd/sensor.py +++ b/homeassistant/components/ebusd/sensor.py @@ -56,7 +56,7 @@ class EbusdSensor(SensorEntity): return f"{self._client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -94,7 +94,7 @@ class EbusdSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/ecoal_boiler/sensor.py b/homeassistant/components/ecoal_boiler/sensor.py index 9a2fbdd9b87..d9689631280 100644 --- a/homeassistant/components/ecoal_boiler/sensor.py +++ b/homeassistant/components/ecoal_boiler/sensor.py @@ -33,7 +33,7 @@ class EcoalTempSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -43,7 +43,7 @@ class EcoalTempSensor(SensorEntity): return DEVICE_CLASS_TEMPERATURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return TEMP_CELSIUS diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 97f9fe6eae0..eb72f667b5f 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -107,7 +107,7 @@ class EcobeeSensor(SensorEntity): return None @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._state in ( ECOBEE_STATE_CALIBRATING, @@ -122,7 +122,7 @@ class EcobeeSensor(SensorEntity): return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index 0dfe8df7fb3..bbcf54003e8 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -82,7 +82,7 @@ class EcoNetSensor(EcoNetEntity, SensorEntity): self._device_name = device_name @property - def state(self): + def native_value(self): """Return sensors state.""" value = getattr(self._econet, SENSOR_NAMES_TO_ATTRIBUTES[self._device_name]) if isinstance(value, float): @@ -90,7 +90,7 @@ class EcoNetSensor(EcoNetEntity, SensorEntity): return value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" unit_of_measurement = SENSOR_NAMES_TO_UNIT_OF_MEASUREMENT[self._device_name] if self._device_name == POWER_USAGE_TODAY: diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 9adb7665753..1eee0b47272 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -114,7 +114,7 @@ class EddystoneTemp(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.temperature @@ -124,7 +124,7 @@ class EddystoneTemp(SensorEntity): return DEVICE_CLASS_TEMPERATURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return TEMP_CELSIUS diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index a00f77efa0b..407f5902198 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -301,7 +301,7 @@ class EDL21Entity(SensorEntity): return self._name @property - def state(self) -> str: + def native_value(self) -> str: """Return the value of the last received telegram.""" return self._telegram.get("value") @@ -315,7 +315,7 @@ class EDL21Entity(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._telegram.get("unit") diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 6e2ac1c01c7..391aca7b4af 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -120,12 +120,12 @@ class EfergySensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index 01413ceaec0..df0d7882491 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -101,12 +101,12 @@ class EightHeatSensor(EightSleepHeatEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return PERCENTAGE @@ -157,12 +157,12 @@ class EightUserSensor(EightSleepUserEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" if ( "current_sleep" in self._sensor @@ -316,7 +316,7 @@ class EightRoomSensor(EightSleepUserEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -333,7 +333,7 @@ class EightRoomSensor(EightSleepUserEntity, SensorEntity): self._state = None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" if self._units == "si": return TEMP_CELSIUS diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py index 253913b3779..ecd6e4ad4bb 100644 --- a/homeassistant/components/eliqonline/sensor.py +++ b/homeassistant/components/eliqonline/sensor.py @@ -78,12 +78,12 @@ class EliqSensor(SensorEntity): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return UNIT_OF_MEASUREMENT @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 8f26af545b7..30fe87103c7 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -77,7 +77,7 @@ class ElkSensor(ElkAttachedEntity, SensorEntity): self._state = None @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -127,7 +127,7 @@ class ElkKeypad(ElkSensor): return self._temperature_unit @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._temperature_unit @@ -250,7 +250,7 @@ class ElkZone(ElkSensor): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self._element.definition == ZoneType.TEMPERATURE.value: return self._temperature_unit diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index bfc86db387e..5180275b528 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -162,12 +162,12 @@ class EmonCmsSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index 1dca3f2d89d..1d699b42473 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -38,7 +38,7 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): """Representation of an Emonitor power sensor entity.""" _attr_device_class = DEVICE_CLASS_POWER - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT def __init__(self, coordinator: DataUpdateCoordinator, channel_number: int) -> None: """Initialize the channel sensor.""" @@ -73,7 +73,7 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): return attr_val @property - def state(self) -> StateType: + def native_value(self) -> StateType: """State of the sensor.""" return self._paired_attr("inst_power") diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index e974035cbd6..ccf1a0d7b34 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -164,7 +164,7 @@ class EnergyCostSensor(SensorEntity): def _reset(self, energy_state: State) -> None: """Reset the cost sensor.""" - self._attr_state = 0.0 + self._attr_native_value = 0.0 self._cur_value = 0.0 self._attr_last_reset = dt_util.utcnow() self._last_energy_sensor_state = energy_state @@ -231,7 +231,7 @@ class EnergyCostSensor(SensorEntity): # Update with newly incurred cost old_energy_value = float(self._last_energy_sensor_state.state) self._cur_value += (energy - old_energy_value) * energy_price - self._attr_state = round(self._cur_value, 2) + self._attr_native_value = round(self._cur_value, 2) self._last_energy_sensor_state = energy_state @@ -281,6 +281,6 @@ class EnergyCostSensor(SensorEntity): self._flow = flow @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the units of measurement.""" return self.hass.config.currency diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 1814efb9c87..ef7fe242092 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -130,12 +130,12 @@ class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): return self._device_class @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 9f87a821787..1d0dfba8990 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -20,27 +20,27 @@ SENSORS = ( SensorEntityDescription( key="production", name="Current Power Production", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="daily_production", name="Today's Energy Production", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, ), SensorEntityDescription( key="seven_days_production", name="Last Seven Days Energy Production", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, ), SensorEntityDescription( key="lifetime_production", name="Lifetime Energy Production", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, last_reset=dt.utc_from_timestamp(0), @@ -48,27 +48,27 @@ SENSORS = ( SensorEntityDescription( key="consumption", name="Current Power Consumption", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="daily_consumption", name="Today's Energy Consumption", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, ), SensorEntityDescription( key="seven_days_consumption", name="Last Seven Days Energy Consumption", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, ), SensorEntityDescription( key="lifetime_consumption", name="Lifetime Energy Consumption", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, last_reset=dt.utc_from_timestamp(0), @@ -76,7 +76,7 @@ SENSORS = ( SensorEntityDescription( key="inverters", name="Inverter", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, state_class=STATE_CLASS_MEASUREMENT, ), ) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 3af5cd1ec0c..9bf4073847e 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -132,7 +132,7 @@ class Envoy(CoordinatorEntity, SensorEntity): return f"{self._device_serial_number}_{self.entity_description.key}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.entity_description.key != "inverters": value = self.coordinator.data.get(self.entity_description.key) diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index 0852f95bd99..cad8a49884f 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -168,7 +168,7 @@ class EnturPublicTransportSensor(SensorEntity): return self._name @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return self._state @@ -180,7 +180,7 @@ class EnturPublicTransportSensor(SensorEntity): return self._attributes @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 2c16eca9ea1..3690703d8d2 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -91,7 +91,7 @@ class ECSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -101,7 +101,7 @@ class ECSensor(SensorEntity): return self._attr @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return self._unit diff --git a/homeassistant/components/envirophat/sensor.py b/homeassistant/components/envirophat/sensor.py index 9bca552326a..a41b1678faa 100644 --- a/homeassistant/components/envirophat/sensor.py +++ b/homeassistant/components/envirophat/sensor.py @@ -88,7 +88,7 @@ class EnvirophatSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -103,7 +103,7 @@ class EnvirophatSensor(SensorEntity): return SENSOR_TYPES[self.type][2] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py index 6fd7f32c6fe..88aa7fa988c 100644 --- a/homeassistant/components/envisalink/sensor.py +++ b/homeassistant/components/envisalink/sensor.py @@ -61,7 +61,7 @@ class EnvisalinkSensor(EnvisalinkDevice, SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the overall state.""" return self._info["status"]["alpha"] diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py index 2f483b9fcbf..285f2fc83e7 100644 --- a/homeassistant/components/epsonworkforce/sensor.py +++ b/homeassistant/components/epsonworkforce/sensor.py @@ -20,37 +20,37 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="black", name="Ink level Black", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="photoblack", name="Ink level Photoblack", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="magenta", name="Ink level Magenta", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="cyan", name="Ink level Cyan", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="yellow", name="Ink level Yellow", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="clean", name="Cleaning level", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), ) MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] @@ -92,7 +92,7 @@ class EpsonPrinterCartridge(SensorEntity): self.entity_description = description @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._api.getSensorValue(self.entity_description.key) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 6a2b51498f0..3e8fbc19a4d 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -161,7 +161,7 @@ class EsphomeSensor( return self._static_info.force_update @esphome_state_property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" if math.isnan(self._state.state): return None @@ -172,7 +172,7 @@ class EsphomeSensor( return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" if not self._static_info.unit_of_measurement: return None @@ -202,7 +202,7 @@ class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEn return self._static_info.icon @esphome_state_property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" if self._state.missing_state: return None diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py index f0dc70d7be4..42a4c1c399b 100644 --- a/homeassistant/components/essent/sensor.py +++ b/homeassistant/components/essent/sensor.py @@ -104,12 +104,12 @@ class EssentMeter(SensorEntity): return f"Essent {self._type} ({self._tariff})" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self._unit.lower() == "kwh": return ENERGY_KILO_WATT_HOUR diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py index 1b10cc39fe1..b1ec3cddb0c 100644 --- a/homeassistant/components/etherscan/sensor.py +++ b/homeassistant/components/etherscan/sensor.py @@ -59,12 +59,12 @@ class EtherscanSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index 4e81ef6a6a7..42283b52d35 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -66,7 +66,7 @@ class EzvizSensor(CoordinatorEntity, Entity): return self._name @property - def state(self) -> int | str: + def native_value(self) -> int | str: """Return the state of the sensor.""" return self.coordinator.data[self._idx][self._name] From 94a264afaf5a989a88b66172b7c6dd44d8ff6e16 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Aug 2021 18:57:50 +0200 Subject: [PATCH 338/903] Move temperature conversions to entity base class (7/8) (#54482) --- homeassistant/components/starline/sensor.py | 4 +- .../components/starlingbank/sensor.py | 4 +- homeassistant/components/startca/sensor.py | 4 +- homeassistant/components/statistics/sensor.py | 4 +- .../components/steam_online/sensor.py | 2 +- .../components/streamlabswater/sensor.py | 8 +-- homeassistant/components/subaru/sensor.py | 4 +- homeassistant/components/suez_water/sensor.py | 4 +- .../components/supervisord/sensor.py | 2 +- .../components/surepetcare/sensor.py | 6 +-- .../swiss_hydrological_data/sensor.py | 4 +- .../swiss_public_transport/sensor.py | 2 +- .../components/switcher_kis/sensor.py | 4 +- homeassistant/components/syncthing/sensor.py | 2 +- homeassistant/components/syncthru/sensor.py | 12 ++--- .../components/synology_dsm/sensor.py | 8 +-- .../components/system_bridge/sensor.py | 28 +++++----- .../components/systemmonitor/sensor.py | 4 +- homeassistant/components/tado/sensor.py | 8 +-- homeassistant/components/tahoma/sensor.py | 4 +- .../components/tank_utility/sensor.py | 4 +- .../components/tankerkoenig/sensor.py | 4 +- homeassistant/components/tasmota/sensor.py | 4 +- homeassistant/components/tautulli/sensor.py | 4 +- homeassistant/components/tcp/sensor.py | 4 +- homeassistant/components/ted5000/sensor.py | 4 +- .../components/tellduslive/sensor.py | 4 +- homeassistant/components/tellstick/sensor.py | 4 +- homeassistant/components/temper/sensor.py | 4 +- homeassistant/components/template/sensor.py | 8 +-- homeassistant/components/tesla/sensor.py | 4 +- .../components/thermoworks_smoke/sensor.py | 4 +- .../components/thethingsnetwork/sensor.py | 4 +- .../components/thinkingcleaner/sensor.py | 4 +- homeassistant/components/tibber/sensor.py | 54 +++++++++---------- homeassistant/components/time_date/sensor.py | 2 +- homeassistant/components/tmb/sensor.py | 4 +- homeassistant/components/tof/sensor.py | 4 +- homeassistant/components/toon/sensor.py | 4 +- homeassistant/components/torque/sensor.py | 4 +- homeassistant/components/tradfri/sensor.py | 4 +- .../components/trafikverket_train/sensor.py | 2 +- .../trafikverket_weatherstation/sensor.py | 4 +- .../components/transmission/sensor.py | 6 +-- .../components/transport_nsw/sensor.py | 4 +- homeassistant/components/travisci/sensor.py | 4 +- .../components/twentemilieu/sensor.py | 2 +- homeassistant/components/twitch/sensor.py | 2 +- .../components/uk_transport/sensor.py | 4 +- homeassistant/components/unifi/sensor.py | 8 +-- homeassistant/components/upnp/sensor.py | 8 +-- homeassistant/components/uptime/sensor.py | 2 +- homeassistant/components/uscis/sensor.py | 2 +- .../components/utility_meter/sensor.py | 4 +- 54 files changed, 153 insertions(+), 153 deletions(-) diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index e7996befad3..92c6acbab0b 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -69,7 +69,7 @@ class StarlineSensor(StarlineEntity, SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._key == "battery": return self._device.battery_level @@ -90,7 +90,7 @@ class StarlineSensor(StarlineEntity, SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Get the unit of measurement.""" if self._key == "balance": return self._device.balance.get("currency") or "₽" diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 77f5ab307cb..ae1ac2d4987 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -79,12 +79,12 @@ class StarlingBalanceSensor(SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._starling_account.currency diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 661e00ed494..d4124ec3d7c 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -93,12 +93,12 @@ class StartcaSensor(SensorEntity): return f"{self.client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index de32603c207..ea90346fe7c 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -203,12 +203,12 @@ class StatisticsSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.mean if not self.is_binary else self.count @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement if not self.is_binary else None diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 45ae1a6c70a..18f7c6cc447 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -99,7 +99,7 @@ class SteamSensor(SensorEntity): return f"sensor.steam_{self._account}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index ba722d0a4f2..3af87b8a3f8 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -87,12 +87,12 @@ class StreamLabsDailyUsage(SensorEntity): return WATER_ICON @property - def state(self): + def native_value(self): """Return the current daily usage.""" return self._streamlabs_usage_data.get_daily_usage() @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return gallons as the unit measurement for water.""" return VOLUME_GALLONS @@ -110,7 +110,7 @@ class StreamLabsMonthlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_MONTHLY_USAGE}" @property - def state(self): + def native_value(self): """Return the current monthly usage.""" return self._streamlabs_usage_data.get_monthly_usage() @@ -124,6 +124,6 @@ class StreamLabsYearlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_YEARLY_USAGE}" @property - def state(self): + def native_value(self): """Return the current yearly usage.""" return self._streamlabs_usage_data.get_yearly_usage() diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index ff1d8b715d7..7aeab66b929 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -203,7 +203,7 @@ class SubaruSensor(SubaruEntity, SensorEntity): return None @property - def state(self): + def native_value(self): """Return the state of the sensor.""" self.current_value = self.get_current_value() @@ -238,7 +238,7 @@ class SubaruSensor(SubaruEntity, SensorEntity): return self.current_value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit_of_measurement of the device.""" if self.api_unit in TEMPERATURE_UNITS: return self.hass.config.units.temperature_unit diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 7170e0b8a67..c9c125e8e7e 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -62,12 +62,12 @@ class SuezSensor(SensorEntity): return COMPONENT_NAME @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return VOLUME_LITERS diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py index f701df2d6c3..5db8680f1c9 100644 --- a/homeassistant/components/supervisord/sensor.py +++ b/homeassistant/components/supervisord/sensor.py @@ -50,7 +50,7 @@ class SupervisorProcessSensor(SensorEntity): return self._info.get("name") @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._info.get("statename") diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index fbc8222f292..922bfa84515 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -60,7 +60,7 @@ class SureBattery(SensorEntity): self._attr_device_class = DEVICE_CLASS_BATTERY self._attr_name = f"{surepy_entity.type.name.capitalize()} {surepy_entity.name.capitalize()} Battery Level" - self._attr_unit_of_measurement = PERCENTAGE + self._attr_native_unit_of_measurement = PERCENTAGE self._attr_unique_id = ( f"{surepy_entity.household_id}-{surepy_entity.id}-battery" ) @@ -75,11 +75,11 @@ class SureBattery(SensorEntity): try: per_battery_voltage = state["battery"] / 4 voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW - self._attr_state = min( + self._attr_native_value = min( int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100 ) except (KeyError, TypeError): - self._attr_state = None + self._attr_native_value = None if state: voltage_per_battery = float(state["battery"]) / 4 diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index 1d77410f031..3daa7161869 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -94,14 +94,14 @@ class SwissHydrologicalDataSensor(SensorEntity): return f"{self._station}_{self._condition}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" if self._state is not None: return self.hydro_data.data["parameters"][self._condition]["unit"] return None @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if isinstance(self._state, (int, float)): return round(self._state, 2) diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index a971524c22b..0f0ac28d530 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -84,7 +84,7 @@ class SwissPublicTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return ( self._opendata.connections[0]["departure"] diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 705c6f0a2b6..e070bd52d0d 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -110,7 +110,7 @@ class SwitcherSensorEntity(CoordinatorEntity, SensorEntity): # Entity class attributes self._attr_name = f"{wrapper.name} {description.name}" self._attr_icon = description.icon - self._attr_unit_of_measurement = description.unit + self._attr_native_unit_of_measurement = description.unit self._attr_device_class = description.device_class self._attr_entity_registry_enabled_default = description.default_enabled @@ -122,6 +122,6 @@ class SwitcherSensorEntity(CoordinatorEntity, SensorEntity): } @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return value of sensor.""" return getattr(self.wrapper.data, self.attribute) # type: ignore[no-any-return] diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index 5e8ea2f88c2..924f8aaf669 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -105,7 +105,7 @@ class FolderSensor(SensorEntity): return f"{self._short_server_id}-{self._folder_id}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state["state"] diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 2b559e0a15f..cc832f77f0a 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -124,7 +124,7 @@ class SyncThruSensor(CoordinatorEntity, SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measuremnt.""" return self._unit_of_measurement @@ -148,7 +148,7 @@ class SyncThruMainSensor(SyncThruSensor): self._id_suffix = "_main" @property - def state(self): + def native_value(self): """Set state to human readable version of syncthru status.""" return SYNCTHRU_STATE_HUMAN[self.syncthru.device_status()] @@ -182,7 +182,7 @@ class SyncThruTonerSensor(SyncThruSensor): return self.syncthru.toner_status().get(self._color, {}) @property - def state(self): + def native_value(self): """Show amount of remaining toner.""" return self.syncthru.toner_status().get(self._color, {}).get("remaining") @@ -204,7 +204,7 @@ class SyncThruDrumSensor(SyncThruSensor): return self.syncthru.drum_status().get(self._color, {}) @property - def state(self): + def native_value(self): """Show amount of remaining drum.""" return self.syncthru.drum_status().get(self._color, {}).get("remaining") @@ -225,7 +225,7 @@ class SyncThruInputTraySensor(SyncThruSensor): return self.syncthru.input_tray_status().get(self._number, {}) @property - def state(self): + def native_value(self): """Display ready unless there is some error, then display error.""" tray_state = ( self.syncthru.input_tray_status().get(self._number, {}).get("newError") @@ -251,7 +251,7 @@ class SyncThruOutputTraySensor(SyncThruSensor): return self.syncthru.output_tray_status().get(self._number, {}) @property - def state(self): + def native_value(self): """Display ready unless there is some error, then display error.""" tray_state = ( self.syncthru.output_tray_status().get(self._number, {}).get("status") diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 5942ce4a5b1..1ddc79c0afc 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -90,7 +90,7 @@ class SynoDSMSensor(SynologyDSMBaseEntity): """Mixin for sensor specific attributes.""" @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" if self.entity_type in TEMP_SENSORS_KEYS: return self.hass.config.units.temperature_unit @@ -101,7 +101,7 @@ class SynoDSMUtilSensor(SynoDSMSensor, SensorEntity): """Representation a Synology Utilisation sensor.""" @property - def state(self) -> Any | None: + def native_value(self) -> Any | None: """Return the state.""" attr = getattr(self._api.utilisation, self.entity_type) if callable(attr): @@ -133,7 +133,7 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor, SensorEntity) """Representation a Synology Storage sensor.""" @property - def state(self) -> Any | None: + def native_value(self) -> Any | None: """Return the state.""" attr = getattr(self._api.storage, self.entity_type)(self._device_id) if attr is None: @@ -166,7 +166,7 @@ class SynoDSMInfoSensor(SynoDSMSensor, SensorEntity): self._last_boot: str | None = None @property - def state(self) -> Any | None: + def native_value(self) -> Any | None: """Return the state.""" attr = getattr(self._api.information, self.entity_type) if attr is None: diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index ed3c569f10f..acfcc54f05c 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -92,7 +92,7 @@ class SystemBridgeSensor(SystemBridgeDeviceEntity, SensorEntity): return self._device_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return self._unit_of_measurement @@ -113,7 +113,7 @@ class SystemBridgeBatterySensor(SystemBridgeSensor): ) @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.battery.percent @@ -135,7 +135,7 @@ class SystemBridgeBatteryTimeRemainingSensor(SystemBridgeSensor): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data if bridge.battery.timeRemaining is None: @@ -159,7 +159,7 @@ class SystemBridgeCpuSpeedSensor(SystemBridgeSensor): ) @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.cpu.currentSpeed.avg @@ -181,7 +181,7 @@ class SystemBridgeCpuTemperatureSensor(SystemBridgeSensor): ) @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.cpu.temperature.main @@ -203,7 +203,7 @@ class SystemBridgeCpuVoltageSensor(SystemBridgeSensor): ) @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.cpu.cpu.voltage @@ -229,7 +229,7 @@ class SystemBridgeFilesystemSensor(SystemBridgeSensor): self._fs_key = key @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( @@ -268,7 +268,7 @@ class SystemBridgeMemoryFreeSensor(SystemBridgeSensor): ) @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( @@ -294,7 +294,7 @@ class SystemBridgeMemoryUsedSensor(SystemBridgeSensor): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( @@ -320,7 +320,7 @@ class SystemBridgeMemoryUsedPercentageSensor(SystemBridgeSensor): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( @@ -346,7 +346,7 @@ class SystemBridgeKernelSensor(SystemBridgeSensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.os.kernel @@ -368,7 +368,7 @@ class SystemBridgeOsSensor(SystemBridgeSensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return f"{bridge.os.distro} {bridge.os.release}" @@ -390,7 +390,7 @@ class SystemBridgeProcessesLoadSensor(SystemBridgeSensor): ) @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( @@ -431,7 +431,7 @@ class SystemBridgeBiosVersionSensor(SystemBridgeSensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.system.bios.version diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index bc3e922a923..687e9e8e521 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -331,12 +331,12 @@ class SystemMonitorSensor(SensorEntity): return self.sensor_type[SENSOR_TYPE_ICON] # type: ignore[no-any-return] @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the device.""" return self.data.state @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" return self.sensor_type[SENSOR_TYPE_UOM] # type: ignore[no-any-return] diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index e1219b5620b..537e094bfd2 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -127,7 +127,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): return f"{self._tado.home_name} {self.home_variable}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -137,7 +137,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): return self._state_attributes @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.home_variable in ["temperature", "outdoor temperature"]: return TEMP_CELSIUS @@ -232,7 +232,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): return f"{self.zone_name} {self.zone_variable}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -242,7 +242,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): return self._state_attributes @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.zone_variable == "temperature": return self.hass.config.units.temperature_unit diff --git a/homeassistant/components/tahoma/sensor.py b/homeassistant/components/tahoma/sensor.py index 47e6d300414..35a51b03805 100644 --- a/homeassistant/components/tahoma/sensor.py +++ b/homeassistant/components/tahoma/sensor.py @@ -35,12 +35,12 @@ class TahomaSensor(TahomaDevice, SensorEntity): super().__init__(tahoma_device, controller) @property - def state(self): + def native_value(self): """Return the name of the sensor.""" return self.current_value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" if self.tahoma_device.type == "io:TemperatureIOSystemSensor": return TEMP_CELSIUS diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 379819cf65e..93794ce0c50 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -81,7 +81,7 @@ class TankUtilitySensor(SensorEntity): return self._device @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -91,7 +91,7 @@ class TankUtilitySensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of the device.""" return self._unit_of_measurement diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 5c1898e02a9..166e1da7060 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -110,12 +110,12 @@ class FuelPriceSensor(CoordinatorEntity, SensorEntity): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return unit of measurement.""" return CURRENCY_EURO @property - def state(self): + def native_value(self): """Return the state of the device.""" # key Fuel_type is not available when the fuel station is closed, use "get" instead of "[]" to avoid exceptions return self.coordinator.data[self._station_id].get(self._fuel_type) diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index b756d656921..29144370ae7 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -258,7 +258,7 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): return class_or_icon.get(ICON) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" if self._state_timestamp and self.device_class == DEVICE_CLASS_TIMESTAMP: return self._state_timestamp.isoformat() @@ -270,6 +270,6 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): return True @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return SENSOR_UNIT_MAP.get(self._tasmota_entity.unit, self._tasmota_entity.unit) diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index c50efb00ed7..67df02cb15d 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -114,7 +114,7 @@ class TautulliSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.sessions.get("stream_count") @@ -124,7 +124,7 @@ class TautulliSensor(SensorEntity): return "mdi:plex" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return "Watching" diff --git a/homeassistant/components/tcp/sensor.py b/homeassistant/components/tcp/sensor.py index d282974fd4c..4db511e1f57 100644 --- a/homeassistant/components/tcp/sensor.py +++ b/homeassistant/components/tcp/sensor.py @@ -31,11 +31,11 @@ class TcpSensor(TcpEntity, SensorEntity): """Implementation of a TCP socket based sensor.""" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the device.""" return self._state @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity.""" return self._config[CONF_UNIT_OF_MEASUREMENT] diff --git a/homeassistant/components/ted5000/sensor.py b/homeassistant/components/ted5000/sensor.py index 6732014c747..a7162ee9c63 100644 --- a/homeassistant/components/ted5000/sensor.py +++ b/homeassistant/components/ted5000/sensor.py @@ -79,12 +79,12 @@ class Ted5000Sensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit @property - def state(self): + def native_value(self): """Return the state of the resources.""" with suppress(KeyError): return self._gateway.data[self._mtu][self._unit] diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index a86b487afd2..35fc6809523 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -111,7 +111,7 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): return "{} {}".format(super().name, self.quantity_name or "").strip() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if not self.available: return None @@ -129,7 +129,7 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): return SENSOR_TYPES[self._type][0] if self._type in SENSOR_TYPES else None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return SENSOR_TYPES[self._type][1] if self._type in SENSOR_TYPES else None diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index 599c19388d6..74548f94d1b 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -154,12 +154,12 @@ class TellstickSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/temper/sensor.py b/homeassistant/components/temper/sensor.py index 7d447d3f9ea..ffb5660109c 100644 --- a/homeassistant/components/temper/sensor.py +++ b/homeassistant/components/temper/sensor.py @@ -77,12 +77,12 @@ class TemperSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the entity.""" return self.current_value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self.temp_unit diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index a887890510a..d51b18e294b 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -255,7 +255,7 @@ class SensorTemplate(TemplateEntity, SensorEntity): except template.TemplateError: pass - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._template = state_template self._attr_device_class = device_class self._attr_state_class = state_class @@ -264,7 +264,7 @@ class SensorTemplate(TemplateEntity, SensorEntity): async def async_added_to_hass(self): """Register callbacks.""" self.add_template_attribute( - "_attr_state", self._template, None, self._update_state + "_attr_native_value", self._template, None, self._update_state ) if self._friendly_name_template and not self._friendly_name_template.is_static: self.add_template_attribute("_attr_name", self._friendly_name_template) @@ -274,7 +274,7 @@ class SensorTemplate(TemplateEntity, SensorEntity): @callback def _update_state(self, result): super()._update_state(result) - self._attr_state = None if isinstance(result, TemplateError) else result + self._attr_native_value = None if isinstance(result, TemplateError) else result class TriggerSensorEntity(TriggerEntity, SensorEntity): @@ -284,7 +284,7 @@ class TriggerSensorEntity(TriggerEntity, SensorEntity): extra_template_keys = (CONF_STATE,) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return state of the sensor.""" return self._rendered.get(CONF_STATE) diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py index ad585082b48..60e3e19047d 100644 --- a/homeassistant/components/tesla/sensor.py +++ b/homeassistant/components/tesla/sensor.py @@ -38,7 +38,7 @@ class TeslaSensor(TeslaDevice, SensorEntity): self._unique_id = f"{super().unique_id}_{self.type}" @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" if self.tesla_device.type == "temperature sensor": if self.type == "outside": @@ -57,7 +57,7 @@ class TeslaSensor(TeslaDevice, SensorEntity): return self.tesla_device.get_value() @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit_of_measurement of the device.""" units = self.tesla_device.measurement if units == "F": diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index 4b14c9a9305..2e4ef6e56ec 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -120,7 +120,7 @@ class ThermoworksSmokeSensor(SensorEntity): return self._unique_id @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -130,7 +130,7 @@ class ThermoworksSmokeSensor(SensorEntity): return self._attributes @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this sensor.""" return self._unit_of_measurement diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index 2e139eae63d..089d1eda2ee 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -76,7 +76,7 @@ class TtnDataSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the entity.""" if self._ttn_data_storage.data is not None: try: @@ -86,7 +86,7 @@ class TtnDataSensor(SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py index fa1dfd5988c..e7530636169 100644 --- a/homeassistant/components/thinkingcleaner/sensor.py +++ b/homeassistant/components/thinkingcleaner/sensor.py @@ -95,12 +95,12 @@ class ThinkingCleanerSensor(SensorEntity): return SENSOR_TYPES[self.type][2] @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index b5012cdc41d..6674f6829f9 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -71,39 +71,39 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { key="averagePower", name="average power", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), "power": TibberSensorEntityDescription( key="power", name="power", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), "powerProduction": TibberSensorEntityDescription( key="powerProduction", name="power production", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), "minPower": TibberSensorEntityDescription( key="minPower", name="min power", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), "maxPower": TibberSensorEntityDescription( key="maxPower", name="max power", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), "accumulatedConsumption": TibberSensorEntityDescription( key="accumulatedConsumption", name="accumulated consumption", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.DAILY, ), @@ -111,7 +111,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { key="accumulatedConsumptionLastHour", name="accumulated consumption current hour", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.HOURLY, ), @@ -119,7 +119,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { key="accumulatedProduction", name="accumulated production", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.DAILY, ), @@ -127,7 +127,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { key="accumulatedProductionLastHour", name="accumulated production current hour", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.HOURLY, ), @@ -135,63 +135,63 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { key="lastMeterConsumption", name="last meter consumption", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, ), "lastMeterProduction": TibberSensorEntityDescription( key="lastMeterProduction", name="last meter production", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, ), "voltagePhase1": TibberSensorEntityDescription( key="voltagePhase1", name="voltage phase1", device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), "voltagePhase2": TibberSensorEntityDescription( key="voltagePhase2", name="voltage phase2", device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), "voltagePhase3": TibberSensorEntityDescription( key="voltagePhase3", name="voltage phase3", device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), "currentL1": TibberSensorEntityDescription( key="currentL1", name="current L1", device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), "currentL2": TibberSensorEntityDescription( key="currentL2", name="current L2", device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), "currentL3": TibberSensorEntityDescription( key="currentL3", name="current L3", device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), "signalStrength": TibberSensorEntityDescription( key="signalStrength", name="signal strength", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=STATE_CLASS_MEASUREMENT, ), "accumulatedReward": TibberSensorEntityDescription( @@ -212,7 +212,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { key="powerFactor", name="power factor", device_class=DEVICE_CLASS_POWER_FACTOR, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), } @@ -350,13 +350,13 @@ class TibberSensorElPrice(TibberSensor): return res = self._tibber_home.current_price_data() - self._attr_state, price_level, self._last_updated = res + self._attr_native_value, price_level, self._last_updated = res self._attr_extra_state_attributes["price_level"] = price_level attrs = self._tibber_home.current_attributes() self._attr_extra_state_attributes.update(attrs) - self._attr_available = self._attr_state is not None - self._attr_unit_of_measurement = self._tibber_home.price_unit + self._attr_available = self._attr_native_value is not None + self._attr_native_unit_of_measurement = self._tibber_home.price_unit @Throttle(MIN_TIME_BETWEEN_UPDATES) async def _fetch_data(self): @@ -394,11 +394,11 @@ class TibberSensorRT(TibberSensor): self._device_name = f"{self._model} {self._home_name}" self._attr_name = f"{description.name} {self._home_name}" - self._attr_state = initial_state + self._attr_native_value = initial_state self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.name}" if description.name in ("accumulated cost", "accumulated reward"): - self._attr_unit_of_measurement = tibber_home.currency + self._attr_native_unit_of_measurement = tibber_home.currency if description.reset_type == ResetType.NEVER: self._attr_last_reset = dt_util.utc_from_timestamp(0) elif description.reset_type == ResetType.DAILY: @@ -431,20 +431,20 @@ class TibberSensorRT(TibberSensor): def _set_state(self, state, timestamp): """Set sensor state.""" if ( - state < self._attr_state + state < self._attr_native_value and self.entity_description.reset_type == ResetType.DAILY ): self._attr_last_reset = dt_util.as_utc( timestamp.replace(hour=0, minute=0, second=0, microsecond=0) ) if ( - state < self._attr_state + state < self._attr_native_value and self.entity_description.reset_type == ResetType.HOURLY ): self._attr_last_reset = dt_util.as_utc( timestamp.replace(minute=0, second=0, microsecond=0) ) - self._attr_state = state + self._attr_native_value = state self.async_write_ha_state() diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 08195e6dd3d..58582b3b139 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -65,7 +65,7 @@ class TimeDateSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index 88471a86c27..d777fec38b6 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -85,7 +85,7 @@ class TMBSensor(SensorEntity): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit @@ -95,7 +95,7 @@ class TMBSensor(SensorEntity): return f"{self._stop}_{self._line}" @property - def state(self): + def native_value(self): """Return the next departure time.""" return self._state diff --git a/homeassistant/components/tof/sensor.py b/homeassistant/components/tof/sensor.py index 45713dd8f77..631018f55cd 100644 --- a/homeassistant/components/tof/sensor.py +++ b/homeassistant/components/tof/sensor.py @@ -82,12 +82,12 @@ class VL53L1XSensor(SensorEntity): return self._name @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index b16672674af..8d298c4a865 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -130,7 +130,7 @@ class ToonSensor(ToonEntity, SensorEntity): self._attr_last_reset = sensor.get(ATTR_LAST_RESET) self._attr_name = sensor[ATTR_NAME] self._attr_state_class = sensor.get(ATTR_STATE_CLASS) - self._attr_unit_of_measurement = sensor[ATTR_UNIT_OF_MEASUREMENT] + self._attr_native_unit_of_measurement = sensor[ATTR_UNIT_OF_MEASUREMENT] self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) self._attr_unique_id = ( # This unique ID is a bit ugly and contains unneeded information. @@ -139,7 +139,7 @@ class ToonSensor(ToonEntity, SensorEntity): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" section = getattr( self.coordinator.data, SENSOR_ENTITIES[self.key][ATTR_SECTION] diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py index 8e3053d9bd8..162dd5f437c 100644 --- a/homeassistant/components/torque/sensor.py +++ b/homeassistant/components/torque/sensor.py @@ -120,12 +120,12 @@ class TorqueSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 1f028849d32..f7f68b666ba 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -30,7 +30,7 @@ class TradfriSensor(TradfriBaseDevice, SensorEntity): """The platform class required by Home Assistant.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device, api, gateway_id): """Initialize the device.""" @@ -38,6 +38,6 @@ class TradfriSensor(TradfriBaseDevice, SensorEntity): self._unique_id = f"{gateway_id}-{device.id}" @property - def state(self): + def native_value(self): """Return the current state of the device.""" return self._device.device_info.battery_level diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 5e541045266..cd5cdf29521 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -189,7 +189,7 @@ class TrainSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the departure state.""" state = self._state if state is not None: diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 1ae090ea231..1435da6a988 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -185,12 +185,12 @@ class TrafikverketWeatherStation(SensorEntity): return self._device_class @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index b00ccfc68c0..e5f827d1e52 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -62,7 +62,7 @@ class TransmissionSensor(SensorEntity): return f"{self._tm_client.api.host}-{self.name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -95,7 +95,7 @@ class TransmissionSpeedSensor(TransmissionSensor): """Representation of a Transmission speed sensor.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return DATA_RATE_MEGABYTES_PER_SECOND @@ -145,7 +145,7 @@ class TransmissionTorrentsSensor(TransmissionSensor): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return "Torrents" diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index be76999ec3f..0ebb2b39cb8 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -81,7 +81,7 @@ class TransportNSWSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -101,7 +101,7 @@ class TransportNSWSensor(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index 82b158aa0ec..c4c68197677 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -113,12 +113,12 @@ class TravisCISensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return SENSOR_TYPES[self._sensor_type][1] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index cc17bf6f1a2..0069c3db93c 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -132,7 +132,7 @@ class TwenteMilieuSensor(SensorEntity): self.async_schedule_update_ha_state(True) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index cfabcf1045f..15581e11c28 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -77,7 +77,7 @@ class TwitchSensor(SensorEntity): return self._channel.display_name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index c66db9bb24b..69e4f0df99b 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -93,7 +93,7 @@ class UkTransportSensor(SensorEntity): TRANSPORT_API_URL_BASE = "https://transportapi.com/v3/uk/" _attr_icon = "mdi:train" - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES def __init__(self, name, api_app_id, api_app_key, url): """Initialize the sensor.""" @@ -110,7 +110,7 @@ class UkTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 338f695a2b4..6a009415163 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -86,7 +86,7 @@ class UniFiBandwidthSensor(UniFiClient, SensorEntity): DOMAIN = DOMAIN - _attr_unit_of_measurement = DATA_MEGABYTES + _attr_native_unit_of_measurement = DATA_MEGABYTES @property def name(self) -> str: @@ -105,7 +105,7 @@ class UniFiRxBandwidthSensor(UniFiBandwidthSensor): TYPE = RX_SENSOR @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" if self._is_wired: return self.client.wired_rx_bytes / 1000000 @@ -118,7 +118,7 @@ class UniFiTxBandwidthSensor(UniFiBandwidthSensor): TYPE = TX_SENSOR @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" if self._is_wired: return self.client.wired_tx_bytes / 1000000 @@ -167,7 +167,7 @@ class UniFiUpTimeSensor(UniFiClient, SensorEntity): return f"{super().name} {self.TYPE.capitalize()}" @property - def state(self) -> datetime: + def native_value(self) -> datetime: """Return the uptime of the client.""" if self.client.uptime < 1000000000: return (dt_util.now() - timedelta(seconds=self.client.uptime)).isoformat() diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 54744490a86..82df1f59469 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -161,7 +161,7 @@ class UpnpSensor(CoordinatorEntity, SensorEntity): return f"{self._device.udn}_{self._sensor_type['unique_id']}" @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return self._sensor_type["unit"] @@ -180,7 +180,7 @@ class RawUpnpSensor(UpnpSensor): """Representation of a UPnP/IGD sensor.""" @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the device.""" device_value_key = self._sensor_type["device_value_key"] value = self.coordinator.data[device_value_key] @@ -209,7 +209,7 @@ class DerivedUpnpSensor(UpnpSensor): return f"{self._device.udn}_{self._sensor_type['derived_unique_id']}" @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return self._sensor_type["derived_unit"] @@ -218,7 +218,7 @@ class DerivedUpnpSensor(UpnpSensor): return current_value < self._last_value @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the device.""" # Can't calculate any derivative if we have only one value. device_value_key = self._sensor_type["device_value_key"] diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 5b31b2e81d0..db06b09ea18 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -50,4 +50,4 @@ class UptimeSensor(SensorEntity): self._attr_name: str = name self._attr_device_class: str = DEVICE_CLASS_TIMESTAMP self._attr_should_poll: bool = False - self._attr_state: str = dt_util.now().isoformat() + self._attr_native_value: str = dt_util.now().isoformat() diff --git a/homeassistant/components/uscis/sensor.py b/homeassistant/components/uscis/sensor.py index bd261aba4fb..c0c2d1ae165 100644 --- a/homeassistant/components/uscis/sensor.py +++ b/homeassistant/components/uscis/sensor.py @@ -54,7 +54,7 @@ class UscisSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state.""" return self._state diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 509c0562f97..1ff201aaceb 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -321,7 +321,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -336,7 +336,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): return STATE_CLASS_MEASUREMENT @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement From e23750b2a4eb474677d4bd555713f0c426c56e95 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 11 Aug 2021 18:58:19 +0200 Subject: [PATCH 339/903] Add device class `gas` and enable statistics for it (#54110) Co-authored-by: Martin Hjelmare Co-authored-by: Erik Montnemery --- homeassistant/components/dsmr/const.py | 4 ++ homeassistant/components/dsmr/sensor.py | 14 ++++++- .../components/recorder/statistics.py | 17 ++++++++- homeassistant/components/sensor/__init__.py | 2 + .../components/sensor/device_condition.py | 4 ++ .../components/sensor/device_trigger.py | 4 ++ homeassistant/components/sensor/recorder.py | 11 ++++++ homeassistant/components/sensor/strings.json | 2 + homeassistant/components/toon/const.py | 18 +++++---- homeassistant/const.py | 1 + homeassistant/util/volume.py | 26 +++++++++++-- tests/components/dsmr/test_sensor.py | 21 ++++++----- .../components/sensor/test_device_trigger.py | 2 +- tests/components/sensor/test_recorder.py | 37 ++++++++++++------- .../custom_components/test/sensor.py | 2 + tests/util/test_volume.py | 20 ++++++++++ 16 files changed, 146 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index a5e51816183..b5fb74bbbe6 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT from homeassistant.const import ( DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_POWER, DEVICE_CLASS_VOLTAGE, ) @@ -256,6 +257,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( is_gas=True, force_update=True, icon="mdi:fire", + device_class=DEVICE_CLASS_GAS, last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), @@ -266,6 +268,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( is_gas=True, force_update=True, icon="mdi:fire", + device_class=DEVICE_CLASS_GAS, last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), @@ -276,6 +279,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( is_gas=True, force_update=True, icon="mdi:fire", + device_class=DEVICE_CLASS_GAS, last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index ae3fb6b01a4..dbc29144719 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -16,7 +16,12 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + VOLUME_CUBIC_METERS, +) from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -56,6 +61,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) +UNIT_CONVERSION = {"m3": VOLUME_CUBIC_METERS} + async def async_setup_platform( hass: HomeAssistant, @@ -260,7 +267,10 @@ class DSMREntity(SensorEntity): @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" - return self.get_dsmr_object_attr("unit") + unit_of_measurement = self.get_dsmr_object_attr("unit") + if unit_of_measurement in UNIT_CONVERSION: + return UNIT_CONVERSION[unit_of_measurement] + return unit_of_measurement @staticmethod def translate_tariff(value: str, dsmr_version: str) -> str | None: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index f3b0b27df39..b91e4d160df 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -11,13 +11,19 @@ from sqlalchemy import bindparam from sqlalchemy.ext import baked from sqlalchemy.orm.scoping import scoped_session -from homeassistant.const import PRESSURE_PA, TEMP_CELSIUS +from homeassistant.const import ( + PRESSURE_PA, + TEMP_CELSIUS, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, +) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import entity_registry import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util import homeassistant.util.temperature as temperature_util from homeassistant.util.unit_system import UnitSystem +import homeassistant.util.volume as volume_util from .const import DOMAIN from .models import ( @@ -64,6 +70,11 @@ UNIT_CONVERSIONS = { ) if x is not None else None, + VOLUME_CUBIC_METERS: lambda x, units: volume_util.convert( + x, VOLUME_CUBIC_METERS, _configured_unit(VOLUME_CUBIC_METERS, units) + ) + if x is not None + else None, } _LOGGER = logging.getLogger(__name__) @@ -214,6 +225,10 @@ def _configured_unit(unit: str, units: UnitSystem) -> str: return units.pressure_unit if unit == TEMP_CELSIUS: return units.temperature_unit + if unit == VOLUME_CUBIC_METERS: + if units.is_metric: + return VOLUME_CUBIC_METERS + return VOLUME_CUBIC_FEET return unit diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index c88b7da13f4..483d8b88f2e 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -17,6 +17,7 @@ from homeassistant.const import ( DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_MONETARY, @@ -65,6 +66,7 @@ DEVICE_CLASSES: Final[list[str]] = [ DEVICE_CLASS_POWER, # power (W/kW) DEVICE_CLASS_POWER_FACTOR, # power factor (%) DEVICE_CLASS_VOLTAGE, # voltage (V) + DEVICE_CLASS_GAS, # gas (m³ or ft³) ] DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index a77ed2d2cd7..3b9f3839cfb 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -16,6 +16,7 @@ from homeassistant.const import ( DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, @@ -46,6 +47,7 @@ CONF_IS_CO2 = "is_carbon_dioxide" CONF_IS_CURRENT = "is_current" CONF_IS_ENERGY = "is_energy" CONF_IS_HUMIDITY = "is_humidity" +CONF_IS_GAS = "is_gas" CONF_IS_ILLUMINANCE = "is_illuminance" CONF_IS_POWER = "is_power" CONF_IS_POWER_FACTOR = "is_power_factor" @@ -61,6 +63,7 @@ ENTITY_CONDITIONS = { DEVICE_CLASS_CO2: [{CONF_TYPE: CONF_IS_CO2}], DEVICE_CLASS_CURRENT: [{CONF_TYPE: CONF_IS_CURRENT}], DEVICE_CLASS_ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], + DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_IS_GAS}], DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_IS_HUMIDITY}], DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_IS_ILLUMINANCE}], DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_IS_POWER}], @@ -83,6 +86,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_CO2, CONF_IS_CURRENT, CONF_IS_ENERGY, + CONF_IS_GAS, CONF_IS_HUMIDITY, CONF_IS_ILLUMINANCE, CONF_IS_POWER, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 3b00bae816d..f7d72dd4c1b 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -19,6 +19,7 @@ from homeassistant.const import ( DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, @@ -44,6 +45,7 @@ CONF_CO = "carbon_monoxide" CONF_CO2 = "carbon_dioxide" CONF_CURRENT = "current" CONF_ENERGY = "energy" +CONF_GAS = "gas" CONF_HUMIDITY = "humidity" CONF_ILLUMINANCE = "illuminance" CONF_POWER = "power" @@ -60,6 +62,7 @@ ENTITY_TRIGGERS = { DEVICE_CLASS_CO2: [{CONF_TYPE: CONF_CO2}], DEVICE_CLASS_CURRENT: [{CONF_TYPE: CONF_CURRENT}], DEVICE_CLASS_ENERGY: [{CONF_TYPE: CONF_ENERGY}], + DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_GAS}], DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_HUMIDITY}], DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_ILLUMINANCE}], DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_POWER}], @@ -83,6 +86,7 @@ TRIGGER_SCHEMA = vol.All( CONF_CO2, CONF_CURRENT, CONF_ENERGY, + CONF_GAS, CONF_HUMIDITY, CONF_ILLUMINANCE, CONF_POWER, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index afcfe2f228d..fb7393cfe1d 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_MONETARY, DEVICE_CLASS_PRESSURE, @@ -35,11 +36,14 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, State import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util import homeassistant.util.temperature as temperature_util +import homeassistant.util.volume as volume_util from . import ATTR_LAST_RESET, DOMAIN @@ -53,6 +57,7 @@ DEVICE_CLASS_OR_UNIT_STATISTICS = { DEVICE_CLASS_POWER: {"mean", "min", "max"}, DEVICE_CLASS_PRESSURE: {"mean", "min", "max"}, DEVICE_CLASS_TEMPERATURE: {"mean", "min", "max"}, + DEVICE_CLASS_GAS: {"sum"}, PERCENTAGE: {"mean", "min", "max"}, } @@ -62,6 +67,7 @@ DEVICE_CLASS_UNITS = { DEVICE_CLASS_POWER: POWER_WATT, DEVICE_CLASS_PRESSURE: PRESSURE_PA, DEVICE_CLASS_TEMPERATURE: TEMP_CELSIUS, + DEVICE_CLASS_GAS: VOLUME_CUBIC_METERS, } UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { @@ -92,6 +98,11 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { TEMP_FAHRENHEIT: temperature_util.fahrenheit_to_celsius, TEMP_KELVIN: temperature_util.kelvin_to_celsius, }, + # Convert volume to cubic meter + DEVICE_CLASS_GAS: { + VOLUME_CUBIC_METERS: lambda x: x, + VOLUME_CUBIC_FEET: volume_util.cubic_feet_to_cubic_meter, + }, } # Keep track of entities for which a warning about unsupported unit has been logged diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index efe5366cfec..54d0f9ad76c 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -5,6 +5,7 @@ "is_battery_level": "Current {entity_name} battery level", "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", "is_carbon_dioxide": "Current {entity_name} carbon dioxide concentration level", + "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", "is_illuminance": "Current {entity_name} illuminance", "is_power": "Current {entity_name} power", @@ -21,6 +22,7 @@ "battery_level": "{entity_name} battery level changes", "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "carbon_dioxide": "{entity_name} carbon dioxide concentration changes", + "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", "illuminance": "{entity_name} illuminance changes", "power": "{entity_name} power changes", diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 4af57e03412..1c9192c4544 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -18,10 +18,12 @@ from homeassistant.const import ( ATTR_ICON, ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_GAS, ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, TEMP_CELSIUS, + VOLUME_CUBIC_METERS, ) from homeassistant.util import dt as dt_util @@ -38,7 +40,6 @@ DEFAULT_MIN_TEMP = 6.0 CURRENCY_EUR = "EUR" VOLUME_CM3 = "CM3" -VOLUME_M3 = "M3" VOLUME_LHOUR = "L/H" VOLUME_LMIN = "L/MIN" @@ -125,7 +126,8 @@ SENSOR_ENTITIES = { ATTR_NAME: "Average Daily Gas Usage", ATTR_SECTION: "gas_usage", ATTR_MEASUREMENT: "day_average", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:gas-cylinder", ATTR_DEFAULT_ENABLED: False, }, @@ -133,7 +135,8 @@ SENSOR_ENTITIES = { ATTR_NAME: "Gas Usage Today", ATTR_SECTION: "gas_usage", ATTR_MEASUREMENT: "day_usage", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:gas-cylinder", }, "gas_daily_cost": { @@ -147,9 +150,10 @@ SENSOR_ENTITIES = { ATTR_NAME: "Gas Meter", ATTR_SECTION: "gas_usage", ATTR_MEASUREMENT: "meter", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:gas-cylinder", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), ATTR_DEFAULT_ENABLED: False, }, @@ -321,7 +325,7 @@ SENSOR_ENTITIES = { ATTR_NAME: "Average Daily Water Usage", ATTR_SECTION: "water_usage", ATTR_MEASUREMENT: "day_average", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:water", ATTR_DEFAULT_ENABLED: False, }, @@ -329,7 +333,7 @@ SENSOR_ENTITIES = { ATTR_NAME: "Water Usage Today", ATTR_SECTION: "water_usage", ATTR_MEASUREMENT: "day_usage", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:water", ATTR_DEFAULT_ENABLED: False, }, @@ -337,7 +341,7 @@ SENSOR_ENTITIES = { ATTR_NAME: "Water Meter", ATTR_SECTION: "water_usage", ATTR_MEASUREMENT: "meter", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:water", ATTR_DEFAULT_ENABLED: False, ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, diff --git a/homeassistant/const.py b/homeassistant/const.py index 5f4f8cd084c..9fa5c2cd231 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -247,6 +247,7 @@ DEVICE_CLASS_SIGNAL_STRENGTH: Final = "signal_strength" DEVICE_CLASS_TEMPERATURE: Final = "temperature" DEVICE_CLASS_TIMESTAMP: Final = "timestamp" DEVICE_CLASS_VOLTAGE: Final = "voltage" +DEVICE_CLASS_GAS: Final = "gas" # #### STATES #### STATE_ON: Final = "on" diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py index f4a02dbe82e..84a3faa0951 100644 --- a/homeassistant/util/volume.py +++ b/homeassistant/util/volume.py @@ -6,6 +6,8 @@ from numbers import Number from homeassistant.const import ( UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, VOLUME_FLUID_OUNCE, VOLUME_GALLONS, VOLUME_LITERS, @@ -17,19 +19,31 @@ VALID_UNITS: tuple[str, ...] = ( VOLUME_MILLILITERS, VOLUME_GALLONS, VOLUME_FLUID_OUNCE, + VOLUME_CUBIC_METERS, + VOLUME_CUBIC_FEET, ) -def __liter_to_gallon(liter: float) -> float: +def liter_to_gallon(liter: float) -> float: """Convert a volume measurement in Liter to Gallon.""" return liter * 0.2642 -def __gallon_to_liter(gallon: float) -> float: +def gallon_to_liter(gallon: float) -> float: """Convert a volume measurement in Gallon to Liter.""" return gallon * 3.785 +def cubic_meter_to_cubic_feet(cubic_meter: float) -> float: + """Convert a volume measurement in cubic meter to cubic feet.""" + return cubic_meter * 35.3146667 + + +def cubic_feet_to_cubic_meter(cubic_feet: float) -> float: + """Convert a volume measurement in cubic feet to cubic meter.""" + return cubic_feet * 0.0283168466 + + def convert(volume: float, from_unit: str, to_unit: str) -> float: """Convert a temperature from one unit to another.""" if from_unit not in VALID_UNITS: @@ -45,8 +59,12 @@ def convert(volume: float, from_unit: str, to_unit: str) -> float: result: float = volume if from_unit == VOLUME_LITERS and to_unit == VOLUME_GALLONS: - result = __liter_to_gallon(volume) + result = liter_to_gallon(volume) elif from_unit == VOLUME_GALLONS and to_unit == VOLUME_LITERS: - result = __gallon_to_liter(volume) + result = gallon_to_liter(volume) + elif from_unit == VOLUME_CUBIC_METERS and to_unit == VOLUME_CUBIC_FEET: + result = cubic_meter_to_cubic_feet(volume) + elif from_unit == VOLUME_CUBIC_FEET and to_unit == VOLUME_CUBIC_METERS: + result = cubic_feet_to_cubic_meter(volume) return result diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 90194eaeb6b..c7e0addd800 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -24,6 +24,7 @@ from homeassistant.const import ( ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, STATE_UNKNOWN, @@ -104,7 +105,7 @@ async def test_default_setup(hass, dsmr_connection_fixture): GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + {"value": Decimal(745.695), "unit": "m3"}, ] ), } @@ -164,7 +165,7 @@ async def test_default_setup(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is None + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT @@ -228,7 +229,7 @@ async def test_v4_meter(hass, dsmr_connection_fixture): HOURLY_GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + {"value": Decimal(745.695), "unit": "m3"}, ] ), ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), @@ -263,8 +264,8 @@ async def test_v4_meter(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is None assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT @@ -299,7 +300,7 @@ async def test_v5_meter(hass, dsmr_connection_fixture): HOURLY_GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + {"value": Decimal(745.695), "unit": "m3"}, ] ), ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), @@ -334,7 +335,7 @@ async def test_v5_meter(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is None + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT @@ -370,7 +371,7 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): HOURLY_GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + {"value": Decimal(745.695), "unit": "m3"}, ] ), LUXEMBOURG_ELECTRICITY_USED_TARIFF_GLOBAL: CosemObject( @@ -415,7 +416,7 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is None + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT @@ -450,7 +451,7 @@ async def test_belgian_meter(hass, dsmr_connection_fixture): BELGIUM_HOURLY_GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + {"value": Decimal(745.695), "unit": "m3"}, ] ), ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), @@ -485,7 +486,7 @@ async def test_belgian_meter(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is None + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is DEVICE_CLASS_GAS assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index ce35e2506a9..f955c3c19db 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -86,7 +86,7 @@ async def test_get_triggers(hass, device_reg, entity_reg, enable_custom_integrat if device_class != "none" ] triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert len(triggers) == 13 + assert len(triggers) == 14 assert triggers == expected_triggers diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 58614e86a0e..a612bc75a77 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -39,6 +39,11 @@ TEMPERATURE_SENSOR_ATTRIBUTES = { "state_class": "measurement", "unit_of_measurement": "°C", } +GAS_SENSOR_ATTRIBUTES = { + "device_class": "gas", + "state_class": "measurement", + "unit_of_measurement": "m³", +} @pytest.mark.parametrize( @@ -154,11 +159,13 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes [ ("energy", "kWh", "kWh", 1), ("energy", "Wh", "kWh", 1 / 1000), - ("monetary", "€", "€", 1), + ("monetary", "EUR", "EUR", 1), ("monetary", "SEK", "SEK", 1), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), ], ) -def test_compile_hourly_energy_statistics( +def test_compile_hourly_sum_statistics( hass_recorder, caplog, device_class, unit, native_unit, factor ): """Test compiling hourly statistics.""" @@ -174,7 +181,7 @@ def test_compile_hourly_energy_statistics( } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] - four, eight, states = record_energy_states( + four, eight, states = record_meter_states( hass, zero, "sensor.test1", attributes, seq ) hist = history.get_significant_states( @@ -254,14 +261,14 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - four, eight, states = record_energy_states( + four, eight, states = record_meter_states( hass, zero, "sensor.test1", sns1_attr, seq1 ) - _, _, _states = record_energy_states(hass, zero, "sensor.test2", sns2_attr, seq2) + _, _, _states = record_meter_states(hass, zero, "sensor.test2", sns2_attr, seq2) states = {**states, **_states} - _, _, _states = record_energy_states(hass, zero, "sensor.test3", sns3_attr, seq3) + _, _, _states = record_meter_states(hass, zero, "sensor.test3", sns3_attr, seq3) states = {**states, **_states} - _, _, _states = record_energy_states(hass, zero, "sensor.test4", sns4_attr, seq4) + _, _, _states = record_meter_states(hass, zero, "sensor.test4", sns4_attr, seq4) states = {**states, **_states} hist = history.get_significant_states( @@ -336,14 +343,14 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - four, eight, states = record_energy_states( + four, eight, states = record_meter_states( hass, zero, "sensor.test1", sns1_attr, seq1 ) - _, _, _states = record_energy_states(hass, zero, "sensor.test2", sns2_attr, seq2) + _, _, _states = record_meter_states(hass, zero, "sensor.test2", sns2_attr, seq2) states = {**states, **_states} - _, _, _states = record_energy_states(hass, zero, "sensor.test3", sns3_attr, seq3) + _, _, _states = record_meter_states(hass, zero, "sensor.test3", sns3_attr, seq3) states = {**states, **_states} - _, _, _states = record_energy_states(hass, zero, "sensor.test4", sns4_attr, seq4) + _, _, _states = record_meter_states(hass, zero, "sensor.test4", sns4_attr, seq4) states = {**states, **_states} hist = history.get_significant_states( hass, zero - timedelta.resolution, eight + timedelta.resolution @@ -632,6 +639,8 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog): ("humidity", None, None, "mean"), ("monetary", "USD", "USD", "sum"), ("monetary", "None", "None", "sum"), + ("gas", "m³", "m³", "sum"), + ("gas", "ft³", "m³", "sum"), ("pressure", "Pa", "Pa", "mean"), ("pressure", "hPa", "Pa", "mean"), ("pressure", "mbar", "Pa", "mean"), @@ -697,7 +706,7 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes): def record_states(hass, zero, entity_id, attributes): """Record some test states. - We inject a bunch of state updates for temperature sensors. + We inject a bunch of state updates for measurement sensors. """ attributes = dict(attributes) @@ -725,10 +734,10 @@ def record_states(hass, zero, entity_id, attributes): return four, states -def record_energy_states(hass, zero, entity_id, _attributes, seq): +def record_meter_states(hass, zero, entity_id, _attributes, seq): """Record some test states. - We inject a bunch of state updates for energy sensors. + We inject a bunch of state updates for meter sensors. """ def set_state(entity_id, state, **kwargs): diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 7c121d1c05a..f4b2e96321e 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( PERCENTAGE, PRESSURE_HPA, SIGNAL_STRENGTH_DECIBELS, + VOLUME_CUBIC_METERS, ) from tests.common import MockEntity @@ -30,6 +31,7 @@ UNITS_OF_MEASUREMENT = { sensor.DEVICE_CLASS_ENERGY: "kWh", # energy (Wh/kWh) sensor.DEVICE_CLASS_POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) sensor.DEVICE_CLASS_VOLTAGE: "V", # voltage (V) + sensor.DEVICE_CLASS_GAS: VOLUME_CUBIC_METERS, # gas (m³) } ENTITIES = {} diff --git a/tests/util/test_volume.py b/tests/util/test_volume.py index 2c596d92e5b..3cbf5b72130 100644 --- a/tests/util/test_volume.py +++ b/tests/util/test_volume.py @@ -3,6 +3,8 @@ import pytest from homeassistant.const import ( + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, VOLUME_FLUID_OUNCE, VOLUME_GALLONS, VOLUME_LITERS, @@ -47,3 +49,21 @@ def test_convert_from_gallons(): """Test conversion from gallons to other units.""" gallons = 5 assert volume_util.convert(gallons, VOLUME_GALLONS, VOLUME_LITERS) == 18.925 + + +def test_convert_from_cubic_meters(): + """Test conversion from cubic meter to other units.""" + cubic_meters = 5 + assert ( + volume_util.convert(cubic_meters, VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET) + == 176.5733335 + ) + + +def test_convert_from_cubic_feet(): + """Test conversion from cubic feet to cubic meters to other units.""" + cubic_feets = 500 + assert ( + volume_util.convert(cubic_feets, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS) + == 14.1584233 + ) From ae507aeed1b26fdec6302680b6b6afff7862c770 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Aug 2021 21:17:16 +0200 Subject: [PATCH 340/903] Move temperature conversions to sensor base class (8/8) (#54483) * Move temperature conversions to entity base class (8/8) * Fix wallbox sensor * Fix tests --- homeassistant/components/vallox/sensor.py | 4 ++-- homeassistant/components/vasttrafik/sensor.py | 2 +- homeassistant/components/velbus/sensor.py | 4 ++-- homeassistant/components/vera/sensor.py | 4 ++-- homeassistant/components/verisure/sensor.py | 12 +++++----- homeassistant/components/versasense/sensor.py | 4 ++-- homeassistant/components/version/sensor.py | 2 +- .../components/viaggiatreno/sensor.py | 4 ++-- homeassistant/components/vicare/sensor.py | 4 ++-- homeassistant/components/vilfo/sensor.py | 4 ++-- .../components/volkszaehler/sensor.py | 4 ++-- .../components/volvooncall/sensor.py | 4 ++-- homeassistant/components/vultr/sensor.py | 4 ++-- homeassistant/components/wallbox/sensor.py | 8 +++---- homeassistant/components/waqi/sensor.py | 4 ++-- .../components/waterfurnace/sensor.py | 4 ++-- .../components/waze_travel_time/sensor.py | 4 ++-- .../components/websocket_api/sensor.py | 4 ++-- homeassistant/components/whois/sensor.py | 4 ++-- homeassistant/components/wiffi/sensor.py | 6 ++--- homeassistant/components/wink/sensor.py | 4 ++-- .../components/wirelesstag/sensor.py | 4 ++-- homeassistant/components/withings/sensor.py | 4 ++-- homeassistant/components/wled/sensor.py | 22 ++++++++--------- homeassistant/components/wolflink/sensor.py | 12 +++++----- homeassistant/components/worldclock/sensor.py | 2 +- .../components/worldtidesinfo/sensor.py | 2 +- .../components/worxlandroid/sensor.py | 4 ++-- homeassistant/components/wsdot/sensor.py | 4 ++-- homeassistant/components/xbee/__init__.py | 4 ++-- homeassistant/components/xbee/sensor.py | 4 ++-- homeassistant/components/xbox/sensor.py | 2 +- homeassistant/components/xbox_live/sensor.py | 2 +- .../components/xiaomi_aqara/sensor.py | 8 +++---- .../components/xiaomi_miio/sensor.py | 24 +++++++++---------- homeassistant/components/xs1/sensor.py | 4 ++-- .../components/yandex_transport/sensor.py | 2 +- homeassistant/components/zabbix/sensor.py | 4 ++-- homeassistant/components/zamg/sensor.py | 4 ++-- homeassistant/components/zestimate/sensor.py | 2 +- homeassistant/components/zha/sensor.py | 6 ++--- homeassistant/components/zodiac/sensor.py | 2 +- homeassistant/components/zoneminder/sensor.py | 8 +++---- homeassistant/components/zwave/sensor.py | 8 +++---- homeassistant/components/zwave_js/sensor.py | 16 ++++++------- tests/components/vultr/test_sensor.py | 1 + tests/components/wsdot/test_sensor.py | 3 +++ tests/components/zwave/test_sensor.py | 18 ++++++++++---- 48 files changed, 141 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index ddfb9d1a7d3..836931f089e 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -141,7 +141,7 @@ class ValloxSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement @@ -161,7 +161,7 @@ class ValloxSensor(SensorEntity): return self._available @property - def state(self): + def native_value(self): """Return the state.""" return self._state diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 31c5da097ff..4c1c1de5e52 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -110,7 +110,7 @@ class VasttrafikDepartureSensor(SensorEntity): return self._attributes @property - def state(self): + def native_value(self): """Return the next departure time.""" return self._state diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 9d9b68dd4eb..3a4aa2302f6 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -45,14 +45,14 @@ class VelbusSensor(VelbusEntity, SensorEntity): return self._module.get_class(self._channel) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._is_counter: return self._module.get_counter_state(self._channel) return self._module.get_state(self._channel) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" if self._is_counter: return self._module.get_counter_unit(self._channel) diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index 878f6ff376d..dd6d891c11d 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -53,12 +53,12 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) @property - def state(self) -> str: + def native_value(self) -> str: """Return the name of the sensor.""" return self.current_value @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index d39c235e9d5..cdeddd8d6e4 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -51,7 +51,7 @@ class VerisureThermometer(CoordinatorEntity, SensorEntity): coordinator: VerisureDataUpdateCoordinator _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS def __init__( self, coordinator: VerisureDataUpdateCoordinator, serial_number: str @@ -84,7 +84,7 @@ class VerisureThermometer(CoordinatorEntity, SensorEntity): } @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" return self.coordinator.data["climate"][self.serial_number]["temperature"] @@ -104,7 +104,7 @@ class VerisureHygrometer(CoordinatorEntity, SensorEntity): coordinator: VerisureDataUpdateCoordinator _attr_device_class = DEVICE_CLASS_HUMIDITY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__( self, coordinator: VerisureDataUpdateCoordinator, serial_number: str @@ -137,7 +137,7 @@ class VerisureHygrometer(CoordinatorEntity, SensorEntity): } @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" return self.coordinator.data["climate"][self.serial_number]["humidity"] @@ -156,7 +156,7 @@ class VerisureMouseDetection(CoordinatorEntity, SensorEntity): coordinator: VerisureDataUpdateCoordinator - _attr_unit_of_measurement = "Mice" + _attr_native_unit_of_measurement = "Mice" def __init__( self, coordinator: VerisureDataUpdateCoordinator, serial_number: str @@ -186,7 +186,7 @@ class VerisureMouseDetection(CoordinatorEntity, SensorEntity): } @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" return self.coordinator.data["mice"][self.serial_number]["detections"] diff --git a/homeassistant/components/versasense/sensor.py b/homeassistant/components/versasense/sensor.py index d29032af399..50982e92d12 100644 --- a/homeassistant/components/versasense/sensor.py +++ b/homeassistant/components/versasense/sensor.py @@ -65,12 +65,12 @@ class VSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index f20f2682986..1cd42cce9b3 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -151,5 +151,5 @@ class VersionSensor(SensorEntity): async def async_update(self): """Get the latest version information.""" await self.data.async_update() - self._attr_state = self.data.api.version + self._attr_native_value = self.data.api.version self._attr_extra_state_attributes = self.data.api.version_data diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 10821859f9a..ddfbb9f20dd 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -103,7 +103,7 @@ class ViaggiaTrenoSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -113,7 +113,7 @@ class ViaggiaTrenoSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 4f7ab9df985..e96b3b8120a 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -369,12 +369,12 @@ class ViCareSensor(SensorEntity): return self._sensor[CONF_ICON] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._sensor[CONF_UNIT_OF_MEASUREMENT] diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index 90527c60458..bb2df21f257 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -72,7 +72,7 @@ class VilfoRouterSensor(SensorEntity): return f"{parent_device_name} {sensor_name}" @property - def state(self): + def native_value(self): """Return the state.""" return self._state @@ -82,7 +82,7 @@ class VilfoRouterSensor(SensorEntity): return self._unique_id @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity.""" return SENSOR_TYPES[self.sensor_type].get(ATTR_UNIT) diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py index 4eb2f512f31..21705d494d9 100644 --- a/homeassistant/components/volkszaehler/sensor.py +++ b/homeassistant/components/volkszaehler/sensor.py @@ -97,7 +97,7 @@ class VolkszaehlerSensor(SensorEntity): return SENSOR_TYPES[self.type][2] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return SENSOR_TYPES[self.type][1] @@ -107,7 +107,7 @@ class VolkszaehlerSensor(SensorEntity): return self.vz_api.available @property - def state(self): + def native_value(self): """Return the state of the resources.""" return self._state diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py index ad6571576b4..7a37713301e 100644 --- a/homeassistant/components/volvooncall/sensor.py +++ b/homeassistant/components/volvooncall/sensor.py @@ -15,11 +15,11 @@ class VolvoSensor(VolvoEntity, SensorEntity): """Representation of a Volvo sensor.""" @property - def state(self): + def native_value(self): """Return the state.""" return self.instrument.state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.instrument.unit diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py index 5e6815944d7..01506d4f47e 100644 --- a/homeassistant/components/vultr/sensor.py +++ b/homeassistant/components/vultr/sensor.py @@ -92,12 +92,12 @@ class VultrSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement to present the value in.""" return self._units @property - def state(self): + def native_value(self): """Return the value of this given sensor type.""" try: return round(float(self.data.get(self._condition)), 2) diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 6d3ef952cbe..0691a39ff48 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -1,6 +1,6 @@ """Home Assistant component for accessing the Wallbox Portal API. The sensor component creates multiple sensors regarding wallbox performance.""" -from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -28,7 +28,7 @@ async def async_setup_entry(hass, config, async_add_entities): ) -class WallboxSensor(CoordinatorEntity, Entity): +class WallboxSensor(CoordinatorEntity, SensorEntity): """Representation of the Wallbox portal.""" def __init__(self, coordinator, idx, ent, config): @@ -46,12 +46,12 @@ class WallboxSensor(CoordinatorEntity, Entity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.coordinator.data[self._ent] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of the sensor.""" return self._unit diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 084ec17bb28..ed6013daa74 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -129,7 +129,7 @@ class WaqiSensor(SensorEntity): return "mdi:cloud" @property - def state(self): + def native_value(self): """Return the state of the device.""" if self._data is not None: return self._data.get("aqi") @@ -146,7 +146,7 @@ class WaqiSensor(SensorEntity): return self.uid @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return "AQI" diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 8691cc4ed02..5d7832ca58d 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -101,7 +101,7 @@ class WaterFurnaceSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -111,7 +111,7 @@ class WaterFurnaceSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index b0168bbb44e..43265062998 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -202,7 +202,7 @@ class WazeTravelTime(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._waze_data.duration is not None: return round(self._waze_data.duration) @@ -210,7 +210,7 @@ class WazeTravelTime(SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return TIME_MINUTES diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py index 60d42e97604..d6f27aff6ae 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -53,12 +53,12 @@ class APICount(SensorEntity): return "Connected clients" @property - def state(self) -> int: + def native_value(self) -> int: """Return current API count.""" return self.count @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return "clients" diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 4219c80193d..5d5e595fa50 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -70,12 +70,12 @@ class WhoisSensor(SensorEntity): return "mdi:calendar-clock" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement to present the value in.""" return TIME_DAYS @property - def state(self): + def native_value(self): """Return the expiration days for hostname.""" return self._state diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index 800a420f8f0..b9bcd317b46 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -78,12 +78,12 @@ class NumberEntity(WiffiEntity, SensorEntity): return self._device_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the value of the entity.""" return self._value @@ -111,7 +111,7 @@ class StringEntity(WiffiEntity, SensorEntity): self.reset_expiration_date() @property - def state(self): + def native_value(self): """Return the value of the entity.""" return self._value diff --git a/homeassistant/components/wink/sensor.py b/homeassistant/components/wink/sensor.py index f640a24def2..86199f44e91 100644 --- a/homeassistant/components/wink/sensor.py +++ b/homeassistant/components/wink/sensor.py @@ -62,7 +62,7 @@ class WinkSensorEntity(WinkDevice, SensorEntity): self.hass.data[DOMAIN]["entities"]["sensor"].append(self) @property - def state(self): + def native_value(self): """Return the state.""" state = None if self.capability == "humidity": @@ -82,7 +82,7 @@ class WinkSensorEntity(WinkDevice, SensorEntity): return state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index de70efda424..7ad0a7f52c2 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -83,7 +83,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): return self.name.lower().replace(" ", "_") @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -93,7 +93,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): return self._sensor_type @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._sensor.unit diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index ca7391eb58e..0ca40d28440 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -30,11 +30,11 @@ class WithingsHealthSensor(BaseWithingsSensor, SensorEntity): """Implementation of a Withings sensor.""" @property - def state(self) -> None | str | int | float: + def native_value(self) -> None | str | int | float: """Return the state of the entity.""" return self._state_data @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return self._attribute.unit_of_measurement diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 634f903c020..48d8443a0a9 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -48,7 +48,7 @@ class WLEDEstimatedCurrentSensor(WLEDEntity, SensorEntity): """Defines a WLED estimated current sensor.""" _attr_icon = "mdi:power" - _attr_unit_of_measurement = ELECTRIC_CURRENT_MILLIAMPERE + _attr_native_unit_of_measurement = ELECTRIC_CURRENT_MILLIAMPERE _attr_device_class = DEVICE_CLASS_CURRENT def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -66,7 +66,7 @@ class WLEDEstimatedCurrentSensor(WLEDEntity, SensorEntity): } @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self.coordinator.data.info.leds.power @@ -84,7 +84,7 @@ class WLEDUptimeSensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_uptime" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime) return uptime.replace(microsecond=0).isoformat() @@ -95,7 +95,7 @@ class WLEDFreeHeapSensor(WLEDEntity, SensorEntity): _attr_icon = "mdi:memory" _attr_entity_registry_enabled_default = False - _attr_unit_of_measurement = DATA_BYTES + _attr_native_unit_of_measurement = DATA_BYTES def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED free heap sensor.""" @@ -104,7 +104,7 @@ class WLEDFreeHeapSensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_free_heap" @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self.coordinator.data.info.free_heap @@ -113,7 +113,7 @@ class WLEDWifiSignalSensor(WLEDEntity, SensorEntity): """Defines a WLED Wi-Fi signal sensor.""" _attr_icon = "mdi:wifi" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_entity_registry_enabled_default = False def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -123,7 +123,7 @@ class WLEDWifiSignalSensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_signal" @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" if not self.coordinator.data.info.wifi: return None @@ -134,7 +134,7 @@ class WLEDWifiRSSISensor(WLEDEntity, SensorEntity): """Defines a WLED Wi-Fi RSSI sensor.""" _attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH - _attr_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT _attr_entity_registry_enabled_default = False def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -144,7 +144,7 @@ class WLEDWifiRSSISensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_rssi" @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" if not self.coordinator.data.info.wifi: return None @@ -164,7 +164,7 @@ class WLEDWifiChannelSensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_channel" @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" if not self.coordinator.data.info.wifi: return None @@ -184,7 +184,7 @@ class WLEDWifiBSSIDSensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_bssid" @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" if not self.coordinator.data.info.wifi: return None diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 0d35d4bce5c..92f18e04de4 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -63,7 +63,7 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): return f"{self.wolf_object.name}" @property - def state(self): + def native_value(self): """Return the state. Wolf Client is returning only changed values so we need to store old value here.""" if self.wolf_object.parameter_id in self.coordinator.data: new_state = self.coordinator.data[self.wolf_object.parameter_id] @@ -95,7 +95,7 @@ class WolfLinkHours(WolfLinkSensor): return "mdi:clock" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return TIME_HOURS @@ -109,7 +109,7 @@ class WolfLinkTemperature(WolfLinkSensor): return DEVICE_CLASS_TEMPERATURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return TEMP_CELSIUS @@ -123,7 +123,7 @@ class WolfLinkPressure(WolfLinkSensor): return DEVICE_CLASS_PRESSURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return PRESSURE_BAR @@ -132,7 +132,7 @@ class WolfLinkPercentage(WolfLinkSensor): """Class for percentage based entities.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self.wolf_object.unit @@ -146,7 +146,7 @@ class WolfLinkState(WolfLinkSensor): return "wolflink__state" @property - def state(self): + def native_value(self): """Return the state converting with supported values.""" state = super().state resolved_state = [ diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index de5b3991e3f..74da12f7f61 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -54,7 +54,7 @@ class WorldClockSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py index 0fa65957e40..4d7a32605b0 100644 --- a/homeassistant/components/worldtidesinfo/sensor.py +++ b/homeassistant/components/worldtidesinfo/sensor.py @@ -88,7 +88,7 @@ class WorldTidesInfoSensor(SensorEntity): return attr @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.data: if "High" in str(self.data["extremes"][0]["type"]): diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index e7600670c52..b34481d0990 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -68,12 +68,12 @@ class WorxLandroidSensor(SensorEntity): return f"worxlandroid-{self.sensor}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of the sensor.""" if self.sensor == "battery": return PERCENTAGE diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 153d496a7d6..bc0023ac54f 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -88,7 +88,7 @@ class WashingtonStateTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -96,7 +96,7 @@ class WashingtonStateTransportSensor(SensorEntity): class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): """Travel time sensor from WSDOT.""" - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES def __init__(self, name, access_code, travel_time_id): """Construct a travel time sensor.""" diff --git a/homeassistant/components/xbee/__init__.py b/homeassistant/components/xbee/__init__.py index 13cd4217b4d..5ca9e4ef6f7 100644 --- a/homeassistant/components/xbee/__init__.py +++ b/homeassistant/components/xbee/__init__.py @@ -369,7 +369,7 @@ class XBeeDigitalOut(XBeeDigitalIn): class XBeeAnalogIn(SensorEntity): """Representation of a GPIO pin configured as an analog input.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, config, device): """Initialize the XBee analog in device.""" @@ -416,7 +416,7 @@ class XBeeAnalogIn(SensorEntity): return self._config.should_poll @property - def state(self): + def sensor_state(self): """Return the state of the entity.""" return self._value diff --git a/homeassistant/components/xbee/sensor.py b/homeassistant/components/xbee/sensor.py index b1d5ece7d57..8dae25ad5e1 100644 --- a/homeassistant/components/xbee/sensor.py +++ b/homeassistant/components/xbee/sensor.py @@ -47,7 +47,7 @@ class XBeeTemperatureSensor(SensorEntity): """Representation of XBee Pro temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS def __init__(self, config, device): """Initialize the sensor.""" @@ -61,7 +61,7 @@ class XBeeTemperatureSensor(SensorEntity): return self._config.name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._temp diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 9aa0de4a727..854c0b007f6 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -33,7 +33,7 @@ class XboxSensorEntity(XboxBaseSensorEntity, SensorEntity): """Representation of a Xbox presence state.""" @property - def state(self): + def native_value(self): """Return the state of the requested attribute.""" if not self.coordinator.last_update_success: return None diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index 2717bc1ad62..c09b707cba0 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -98,7 +98,7 @@ class XboxSensor(SensorEntity): return False @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index cc49bb14251..cad3afb11ba 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -125,7 +125,7 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" try: return SENSOR_TYPES.get(self._data_key)[0] @@ -142,7 +142,7 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -176,7 +176,7 @@ class XiaomiBatterySensor(XiaomiDevice, SensorEntity): """Representation of a XiaomiSensor.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return PERCENTAGE @@ -186,7 +186,7 @@ class XiaomiBatterySensor(XiaomiDevice, SensorEntity): return DEVICE_CLASS_BATTERY @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index c180bb75a77..bbf83825dca 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -100,34 +100,34 @@ SENSOR_TYPES = { ATTR_TEMPERATURE: XiaomiMiioSensorDescription( key=ATTR_TEMPERATURE, name="Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), ATTR_HUMIDITY: XiaomiMiioSensorDescription( key=ATTR_HUMIDITY, name="Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), ATTR_PRESSURE: XiaomiMiioSensorDescription( key=ATTR_PRESSURE, name="Pressure", - unit_of_measurement=PRESSURE_HPA, + native_unit_of_measurement=PRESSURE_HPA, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), ATTR_LOAD_POWER: XiaomiMiioSensorDescription( key=ATTR_LOAD_POWER, name="Load Power", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), ATTR_WATER_LEVEL: XiaomiMiioSensorDescription( key=ATTR_WATER_LEVEL, name="Water Level", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:water-check", state_class=STATE_CLASS_MEASUREMENT, valid_min_value=0.0, @@ -136,27 +136,27 @@ SENSOR_TYPES = { ATTR_ACTUAL_SPEED: XiaomiMiioSensorDescription( key=ATTR_ACTUAL_SPEED, name="Actual Speed", - unit_of_measurement="rpm", + native_unit_of_measurement="rpm", icon="mdi:fast-forward", state_class=STATE_CLASS_MEASUREMENT, ), ATTR_MOTOR_SPEED: XiaomiMiioSensorDescription( key=ATTR_MOTOR_SPEED, name="Motor Speed", - unit_of_measurement="rpm", + native_unit_of_measurement="rpm", icon="mdi:fast-forward", state_class=STATE_CLASS_MEASUREMENT, ), ATTR_ILLUMINANCE: XiaomiMiioSensorDescription( key=ATTR_ILLUMINANCE, name="Illuminance", - unit_of_measurement=UNIT_LUMEN, + native_unit_of_measurement=UNIT_LUMEN, device_class=DEVICE_CLASS_ILLUMINANCE, state_class=STATE_CLASS_MEASUREMENT, ), ATTR_AIR_QUALITY: XiaomiMiioSensorDescription( key=ATTR_AIR_QUALITY, - unit_of_measurement="AQI", + native_unit_of_measurement="AQI", icon="mdi:cloud", state_class=STATE_CLASS_MEASUREMENT, ), @@ -331,7 +331,7 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): return self._available @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -378,7 +378,7 @@ class XiaomiGatewaySensor(XiaomiGatewayDevice, SensorEntity): self.entity_description = description @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._sub_device.status[self.entity_description.key] @@ -403,7 +403,7 @@ class XiaomiGatewayIlluminanceSensor(SensorEntity): return self._available @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/xs1/sensor.py b/homeassistant/components/xs1/sensor.py index f158e7d74b8..ed022f5b9e7 100644 --- a/homeassistant/components/xs1/sensor.py +++ b/homeassistant/components/xs1/sensor.py @@ -37,11 +37,11 @@ class XS1Sensor(XS1DeviceEntity, SensorEntity): return self.device.name() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.device.value() @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.device.unit() diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 08e856a721e..b4f7f986626 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -108,7 +108,7 @@ class DiscoverYandexTransport(SensorEntity): self._attrs = attrs @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index a2644287690..ff2e2c4d9ba 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -94,12 +94,12 @@ class ZabbixTriggerCountSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return "issues" diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index a6018de831e..5659e4835db 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -171,12 +171,12 @@ class ZamgSensor(SensorEntity): return f"{self.client_name} {self.variable}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.probe.get_data(self.variable) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return SENSOR_TYPES[self.variable][1] diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 0333bb76a20..bac32563776 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -77,7 +77,7 @@ class ZestimateDataSensor(SensorEntity): return f"{self._name} {self.address}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" try: return round(float(self._state), 1) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 3c3aba919ed..cc401cb1e05 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -135,12 +135,12 @@ class Sensor(ZhaEntity, SensorEntity): return self._state_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity.""" return self._unit @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the entity.""" assert self.SENSOR_ATTR is not None raw_state = self._channel.cluster.get(self.SENSOR_ATTR) @@ -274,7 +274,7 @@ class SmartEnergyMetering(Sensor): return self._channel.formatter_function(value) @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return Unit of measurement.""" return self._channel.unit_of_measurement diff --git a/homeassistant/components/zodiac/sensor.py b/homeassistant/components/zodiac/sensor.py index 80a4f782915..b337dda1db0 100644 --- a/homeassistant/components/zodiac/sensor.py +++ b/homeassistant/components/zodiac/sensor.py @@ -196,7 +196,7 @@ class ZodiacSensor(SensorEntity): return "zodiac__sign" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the device.""" return self._state diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 701f4b490d3..d392901b633 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -71,7 +71,7 @@ class ZMSensorMonitors(SensorEntity): return f"{self._monitor.name} Status" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -107,12 +107,12 @@ class ZMSensorEvents(SensorEntity): return f"{self._monitor.name} {self.time_period.title}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return "Events" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -136,7 +136,7 @@ class ZMSensorRunState(SensorEntity): return "Run State" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/zwave/sensor.py b/homeassistant/components/zwave/sensor.py index d973e52ff92..75046c2f9d8 100644 --- a/homeassistant/components/zwave/sensor.py +++ b/homeassistant/components/zwave/sensor.py @@ -56,12 +56,12 @@ class ZWaveSensor(ZWaveDeviceEntity, SensorEntity): return True @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement the value is expressed in.""" return self._units @@ -70,7 +70,7 @@ class ZWaveMultilevelSensor(ZWaveSensor): """Representation of a multi level sensor Z-Wave sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._units in ("C", "F"): return round(self._state, 1) @@ -87,7 +87,7 @@ class ZWaveMultilevelSensor(ZWaveSensor): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" if self._units == "C": return TEMP_CELSIUS diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 7b491661e68..aa163fa8bd9 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -181,14 +181,14 @@ class ZWaveStringSensor(ZwaveSensorBase): """Representation of a Z-Wave String sensor.""" @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return state of the sensor.""" if self.info.primary_value.value is None: return None return str(self.info.primary_value.value) @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return unit of measurement the value is expressed in.""" if self.info.primary_value.metadata.unit is None: return None @@ -215,14 +215,14 @@ class ZWaveNumericSensor(ZwaveSensorBase): ) @property - def state(self) -> float: + def native_value(self) -> float: """Return state of the sensor.""" if self.info.primary_value.value is None: return 0 return round(float(self.info.primary_value.value), 2) @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return unit of measurement the value is expressed in.""" if self.info.primary_value.metadata.unit is None: return None @@ -345,7 +345,7 @@ class ZWaveListSensor(ZwaveSensorBase): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return state of the sensor.""" if self.info.primary_value.value is None: return None @@ -387,7 +387,7 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return state of the sensor.""" if self.info.primary_value.value is None: return None @@ -439,7 +439,7 @@ class ZWaveNodeStatusSensor(SensorEntity): self._attr_device_info = { "identifiers": {get_device_id(self.client, self.node)}, } - self._attr_state: str = node.status.name.lower() + self._attr_native_value: str = node.status.name.lower() async def async_poll_value(self, _: bool) -> None: """Poll a value.""" @@ -447,7 +447,7 @@ class ZWaveNodeStatusSensor(SensorEntity): def _status_changed(self, _: dict) -> None: """Call when status event is received.""" - self._attr_state = self.node.status.name.lower() + self._attr_native_value = self.node.status.name.lower() self.async_write_ha_state() async def async_added_to_hass(self) -> None: diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index 4449859ddb2..e1dbda1dd04 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -29,6 +29,7 @@ class TestVultrSensorSetup(unittest.TestCase): def add_entities(self, devices, action): """Mock add devices.""" for device in devices: + device.hass = self.hass self.DEVICES.append(device) def setUp(self): diff --git a/tests/components/wsdot/test_sensor.py b/tests/components/wsdot/test_sensor.py index bbb56efdeda..f1c96bc3ed8 100644 --- a/tests/components/wsdot/test_sensor.py +++ b/tests/components/wsdot/test_sensor.py @@ -35,6 +35,9 @@ async def test_setup(hass, requests_mock): def add_entities(new_entities, update_before_add=False): """Mock add entities.""" + for entity in new_entities: + entity.hass = hass + if update_before_add: for entity in new_entities: entity.update() diff --git a/tests/components/zwave/test_sensor.py b/tests/components/zwave/test_sensor.py index ae0fa44ed8c..4f995131d15 100644 --- a/tests/components/zwave/test_sensor.py +++ b/tests/components/zwave/test_sensor.py @@ -70,8 +70,10 @@ def test_get_device_detects_battery_sensor(mock_openzwave): assert device.device_class == homeassistant.const.DEVICE_CLASS_BATTERY -def test_multilevelsensor_value_changed_temp_fahrenheit(mock_openzwave): +def test_multilevelsensor_value_changed_temp_fahrenheit(hass, mock_openzwave): """Test value changed for Z-Wave multilevel sensor for temperature.""" + hass.config.units.temperature_unit = homeassistant.const.TEMP_FAHRENHEIT + node = MockNode( command_classes=[ const.COMMAND_CLASS_SENSOR_MULTILEVEL, @@ -82,6 +84,7 @@ def test_multilevelsensor_value_changed_temp_fahrenheit(mock_openzwave): values = MockEntityValues(primary=value) device = sensor.get_device(node=node, values=values, node_config={}) + device.hass = hass assert device.state == 191.0 assert device.unit_of_measurement == homeassistant.const.TEMP_FAHRENHEIT assert device.device_class == homeassistant.const.DEVICE_CLASS_TEMPERATURE @@ -90,8 +93,9 @@ def test_multilevelsensor_value_changed_temp_fahrenheit(mock_openzwave): assert device.state == 198.0 -def test_multilevelsensor_value_changed_temp_celsius(mock_openzwave): +def test_multilevelsensor_value_changed_temp_celsius(hass, mock_openzwave): """Test value changed for Z-Wave multilevel sensor for temperature.""" + hass.config.units.temperature_unit = homeassistant.const.TEMP_CELSIUS node = MockNode( command_classes=[ const.COMMAND_CLASS_SENSOR_MULTILEVEL, @@ -102,6 +106,7 @@ def test_multilevelsensor_value_changed_temp_celsius(mock_openzwave): values = MockEntityValues(primary=value) device = sensor.get_device(node=node, values=values, node_config={}) + device.hass = hass assert device.state == 38.9 assert device.unit_of_measurement == homeassistant.const.TEMP_CELSIUS assert device.device_class == homeassistant.const.DEVICE_CLASS_TEMPERATURE @@ -110,7 +115,7 @@ def test_multilevelsensor_value_changed_temp_celsius(mock_openzwave): assert device.state == 38.0 -def test_multilevelsensor_value_changed_other_units(mock_openzwave): +def test_multilevelsensor_value_changed_other_units(hass, mock_openzwave): """Test value changed for Z-Wave multilevel sensor for other units.""" node = MockNode( command_classes=[ @@ -124,6 +129,7 @@ def test_multilevelsensor_value_changed_other_units(mock_openzwave): values = MockEntityValues(primary=value) device = sensor.get_device(node=node, values=values, node_config={}) + device.hass = hass assert device.state == 190.96 assert device.unit_of_measurement == homeassistant.const.ENERGY_KILO_WATT_HOUR assert device.device_class is None @@ -132,7 +138,7 @@ def test_multilevelsensor_value_changed_other_units(mock_openzwave): assert device.state == 197.96 -def test_multilevelsensor_value_changed_integer(mock_openzwave): +def test_multilevelsensor_value_changed_integer(hass, mock_openzwave): """Test value changed for Z-Wave multilevel sensor for other units.""" node = MockNode( command_classes=[ @@ -144,6 +150,7 @@ def test_multilevelsensor_value_changed_integer(mock_openzwave): values = MockEntityValues(primary=value) device = sensor.get_device(node=node, values=values, node_config={}) + device.hass = hass assert device.state == 5 assert device.unit_of_measurement == "counts" assert device.device_class is None @@ -152,7 +159,7 @@ def test_multilevelsensor_value_changed_integer(mock_openzwave): assert device.state == 6 -def test_alarm_sensor_value_changed(mock_openzwave): +def test_alarm_sensor_value_changed(hass, mock_openzwave): """Test value changed for Z-Wave sensor.""" node = MockNode( command_classes=[const.COMMAND_CLASS_ALARM, const.COMMAND_CLASS_SENSOR_ALARM] @@ -161,6 +168,7 @@ def test_alarm_sensor_value_changed(mock_openzwave): values = MockEntityValues(primary=value) device = sensor.get_device(node=node, values=values, node_config={}) + device.hass = hass assert device.state == 12.34 assert device.unit_of_measurement == homeassistant.const.PERCENTAGE assert device.device_class is None From 2720ba275377f7949538d7a8c1254faea34af63e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Aug 2021 21:17:47 +0200 Subject: [PATCH 341/903] Move temperature conversions to sensor base class (4/8) (#54472) * Move temperature conversions to entity base class (4/8) * Fix litterrobot sensor * Fix tests --- homeassistant/components/iammeter/sensor.py | 4 +-- homeassistant/components/iaqualink/sensor.py | 4 +-- homeassistant/components/icloud/sensor.py | 4 +-- homeassistant/components/ihc/sensor.py | 4 +-- homeassistant/components/imap/sensor.py | 2 +- .../components/imap_email_content/sensor.py | 2 +- homeassistant/components/incomfort/sensor.py | 4 +-- homeassistant/components/influxdb/sensor.py | 4 +-- .../components/integration/sensor.py | 4 +-- homeassistant/components/ios/sensor.py | 10 +++++-- homeassistant/components/iota/sensor.py | 6 ++-- homeassistant/components/iperf3/sensor.py | 4 +-- homeassistant/components/ipp/sensor.py | 8 +++--- homeassistant/components/iqvia/__init__.py | 2 +- homeassistant/components/iqvia/sensor.py | 4 +-- .../components/irish_rail_transport/sensor.py | 4 +-- .../components/islamic_prayer_times/sensor.py | 2 +- homeassistant/components/isy994/sensor.py | 6 ++-- .../components/jewish_calendar/sensor.py | 4 +-- homeassistant/components/juicenet/sensor.py | 14 +++++----- homeassistant/components/kaiterra/sensor.py | 4 +-- homeassistant/components/keba/sensor.py | 4 +-- homeassistant/components/kira/sensor.py | 2 +- homeassistant/components/knx/sensor.py | 4 +-- homeassistant/components/konnected/sensor.py | 4 +-- .../components/kostal_plenticore/sensor.py | 4 +-- homeassistant/components/kraken/sensor.py | 4 +-- homeassistant/components/kwb/sensor.py | 4 +-- homeassistant/components/lacrosse/sensor.py | 10 +++---- homeassistant/components/lastfm/sensor.py | 2 +- .../components/launch_library/sensor.py | 2 +- homeassistant/components/lcn/sensor.py | 6 ++-- homeassistant/components/lightwave/sensor.py | 4 +-- .../components/linux_battery/sensor.py | 4 +-- .../components/litterrobot/sensor.py | 8 +++--- homeassistant/components/local_ip/sensor.py | 2 +- .../components/logi_circle/sensor.py | 4 +-- homeassistant/components/london_air/sensor.py | 2 +- .../components/london_underground/sensor.py | 2 +- homeassistant/components/loopenergy/sensor.py | 4 +-- homeassistant/components/luftdaten/sensor.py | 4 +-- homeassistant/components/lyft/sensor.py | 4 +-- homeassistant/components/lyric/sensor.py | 12 ++++---- .../components/magicseaweed/sensor.py | 4 +-- homeassistant/components/mazda/sensor.py | 28 +++++++++---------- homeassistant/components/melcloud/sensor.py | 18 ++++++------ .../components/meteo_france/sensor.py | 8 +++--- .../components/meteoclimatic/sensor.py | 6 ++-- homeassistant/components/metoffice/sensor.py | 26 ++++++++--------- homeassistant/components/mfi/sensor.py | 4 +-- homeassistant/components/mhz19/sensor.py | 4 +-- homeassistant/components/miflora/sensor.py | 4 +-- homeassistant/components/min_max/sensor.py | 4 +-- .../components/minecraft_server/sensor.py | 4 +-- homeassistant/components/mitemp_bt/sensor.py | 4 +-- homeassistant/components/mobile_app/sensor.py | 4 +-- homeassistant/components/modbus/sensor.py | 6 ++-- .../components/modem_callerid/sensor.py | 2 +- .../components/modern_forms/sensor.py | 4 +-- .../components/mold_indicator/sensor.py | 4 +-- homeassistant/components/moon/sensor.py | 2 +- .../components/motion_blinds/sensor.py | 10 +++---- homeassistant/components/mqtt/sensor.py | 4 +-- homeassistant/components/mqtt_room/sensor.py | 2 +- homeassistant/components/mvglive/sensor.py | 4 +-- homeassistant/components/mychevy/sensor.py | 6 ++-- homeassistant/components/mysensors/sensor.py | 6 ++-- tests/components/litterrobot/test_sensor.py | 1 + tests/components/mfi/test_sensor.py | 4 ++- tests/components/mhz19/test_sensor.py | 9 ++++-- 70 files changed, 195 insertions(+), 183 deletions(-) diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index b1882619fda..de0e76fc3aa 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -86,7 +86,7 @@ class IamMeter(CoordinatorEntity, SensorEntity): self.dev_name = dev_name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.coordinator.data.data[self.sensor_name] @@ -106,6 +106,6 @@ class IamMeter(CoordinatorEntity, SensorEntity): return "mdi:flash" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.unit diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index ae32db9eb9e..61e4560c3be 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -31,7 +31,7 @@ class HassAqualinkSensor(AqualinkEntity, SensorEntity): return self.dev.label @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the measurement unit for the sensor.""" if self.dev.name.endswith("_temp"): if self.dev.system.temp_unit == "F": @@ -40,7 +40,7 @@ class HassAqualinkSensor(AqualinkEntity, SensorEntity): return None @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" if self.dev.state == "": return None diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index ec55a1fcedd..5469eadc998 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -54,7 +54,7 @@ class IcloudDeviceBatterySensor(SensorEntity): """Representation of a iCloud device battery sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, account: IcloudAccount, device: IcloudDevice) -> None: """Initialize the battery sensor.""" @@ -73,7 +73,7 @@ class IcloudDeviceBatterySensor(SensorEntity): return f"{self._device.name} battery state" @property - def state(self) -> int: + def native_value(self) -> int: """Battery state percentage.""" return self._device.battery_level diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index d1aec781df7..17c17980c95 100644 --- a/homeassistant/components/ihc/sensor.py +++ b/homeassistant/components/ihc/sensor.py @@ -48,12 +48,12 @@ class IHCSensor(IHCDevice, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 4158d1be801..c3d6b2198ce 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -95,7 +95,7 @@ class ImapSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the number of emails found.""" return self._email_count diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index cdd47d68d76..87c18a56bbe 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -165,7 +165,7 @@ class EmailContentSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the current email state.""" return self._message diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index a9e1faaba10..9fb99321ff2 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -59,7 +59,7 @@ class IncomfortSensor(IncomfortChild, SensorEntity): self._unit_of_measurement = None @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._heater.status[self._state_attr] @@ -69,7 +69,7 @@ class IncomfortSensor(IncomfortChild, SensorEntity): return self._device_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor.""" return self._unit_of_measurement diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index c2cb5070a4c..bdbfafaf790 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -222,12 +222,12 @@ class InfluxSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 9308adc622d..cabcb2fd394 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -213,12 +213,12 @@ class IntegrationSensor(RestoreEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return round(self._state, self._round_digits) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index d3b006f9078..c3c1ad2b8ce 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -14,7 +14,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="level", name="Battery Level", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="state", @@ -114,12 +114,16 @@ class IOSSensor(SensorEntity): def _update(self, device): """Get the latest state of the sensor.""" self._device = device - self._attr_state = self._device[ios.ATTR_BATTERY][self.entity_description.key] + self._attr_native_value = self._device[ios.ATTR_BATTERY][ + self.entity_description.key + ] self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Added to hass so need to register to dispatch.""" - self._attr_state = self._device[ios.ATTR_BATTERY][self.entity_description.key] + self._attr_native_value = self._device[ios.ATTR_BATTERY][ + self.entity_description.key + ] device_id = self._device[ios.ATTR_DEVICE_ID] self.async_on_remove( async_dispatcher_connect(self.hass, f"{DOMAIN}.{device_id}", self._update) diff --git a/homeassistant/components/iota/sensor.py b/homeassistant/components/iota/sensor.py index 62260be2410..687a4ca35d6 100644 --- a/homeassistant/components/iota/sensor.py +++ b/homeassistant/components/iota/sensor.py @@ -47,12 +47,12 @@ class IotaBalanceSensor(IotaDevice, SensorEntity): return f"{self._name} Balance" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return "IOTA" @@ -81,7 +81,7 @@ class IotaNodeSensor(IotaDevice, SensorEntity): return "IOTA Node" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index 610ff91250f..07b9cc069e4 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -41,12 +41,12 @@ class Iperf3Sensor(RestoreEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 5d736c864e1..e7c0d5c38f5 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -72,7 +72,7 @@ class IPPSensor(IPPEntity, SensorEntity): """Initialize IPP sensor.""" self._key = key self._attr_unique_id = f"{unique_id}_{key}" - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement super().__init__( entry_id=entry_id, @@ -123,7 +123,7 @@ class IPPMarkerSensor(IPPSensor): } @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" level = self.coordinator.data.markers[self.marker_index].level @@ -164,7 +164,7 @@ class IPPPrinterSensor(IPPSensor): } @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return self.coordinator.data.state.printer_state @@ -189,7 +189,7 @@ class IPPUptimeSensor(IPPSensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime) return uptime.replace(microsecond=0).isoformat() diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index fa783cc9031..37cc7bedb71 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -109,7 +109,7 @@ class IQVIAEntity(CoordinatorEntity, SensorEntity): self._attr_icon = icon self._attr_name = name self._attr_unique_id = f"{entry.data[CONF_ZIP_CODE]}_{sensor_type}" - self._attr_unit_of_measurement = "index" + self._attr_native_unit_of_measurement = "index" self._entry = entry self._type = sensor_type diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 5762e4a3888..10d33bfb4bf 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -120,7 +120,7 @@ class ForecastSensor(IQVIAEntity): if i["minimum"] <= average <= i["maximum"] ] - self._attr_state = average + self._attr_native_value = average self._attr_extra_state_attributes.update( { ATTR_CITY: data["City"].title(), @@ -213,4 +213,4 @@ class IndexSensor(IQVIAEntity): f"{attrs['Name'].lower()}_index" ] = attrs["Index"] - self._attr_state = period["Index"] + self._attr_native_value = period["Index"] diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index b5ba16f8541..9ec28d73836 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -83,7 +83,7 @@ class IrishRailTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -114,7 +114,7 @@ class IrishRailTransportSensor(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index 2fa563785d2..99cc65bb548 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -43,7 +43,7 @@ class IslamicPrayerTimeSensor(SensorEntity): return self.sensor_type @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return ( self.client.prayer_times_info.get(self.sensor_type) diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index ebf32384d85..f12f3cb6bdd 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -67,7 +67,7 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): return UOM_FRIENDLY_NAME.get(uom) @property - def state(self) -> str: + def native_value(self) -> str: """Get the state of the ISY994 sensor device.""" value = self._node.status if value == ISY_VALUE_UNKNOWN: @@ -97,7 +97,7 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): return value @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Get the Home Assistant unit of measurement for the device.""" raw_units = self.raw_unit_of_measurement # Check if this is a known index pair UOM @@ -117,7 +117,7 @@ class ISYSensorVariableEntity(ISYEntity, SensorEntity): self._name = vname @property - def state(self): + def native_value(self): """Return the state of the variable.""" return convert_isy_value_to_hass(self._node.status, "", self._node.prec) diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 17a61c932a3..e3f51ea5e2c 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -50,7 +50,7 @@ class JewishCalendarSensor(SensorEntity): self._holiday_attrs = {} @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if isinstance(self._state, datetime): return self._state.isoformat() @@ -134,7 +134,7 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): _attr_device_class = DEVICE_CLASS_TIMESTAMP @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._state is None: return None diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 435508f823d..4eaaba41b55 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -31,40 +31,40 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", name="Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="voltage", name="Voltage", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, ), SensorEntityDescription( key="amps", name="Amps", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="watts", name="Watts", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="charge_time", name="Charge time", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", ), SensorEntityDescription( key="energy_added", name="Energy added", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), ) @@ -110,6 +110,6 @@ class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): return icon @property - def state(self): + def native_value(self): """Return the state.""" return getattr(self.device, self.entity_description.key, None) diff --git a/homeassistant/components/kaiterra/sensor.py b/homeassistant/components/kaiterra/sensor.py index 6c82013361a..fbaa730ab9f 100644 --- a/homeassistant/components/kaiterra/sensor.py +++ b/homeassistant/components/kaiterra/sensor.py @@ -70,7 +70,7 @@ class KaiterraSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state.""" return self._sensor.get("value") @@ -80,7 +80,7 @@ class KaiterraSensor(SensorEntity): return f"{self._device_id}_{self._kind}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" if not self._sensor.get("units"): return None diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py index 2792246d71c..37b42cb3cbe 100644 --- a/homeassistant/components/keba/sensor.py +++ b/homeassistant/components/keba/sensor.py @@ -104,12 +104,12 @@ class KebaSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Get the unit of measurement.""" return self._unit diff --git a/homeassistant/components/kira/sensor.py b/homeassistant/components/kira/sensor.py index a6b1b9ada22..b28aac740f1 100644 --- a/homeassistant/components/kira/sensor.py +++ b/homeassistant/components/kira/sensor.py @@ -50,7 +50,7 @@ class KiraReceiver(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the state of the receiver.""" return self._state diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 5fee8446e91..933ba7bf30d 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -62,11 +62,11 @@ class KNXSensor(KnxEntity, SensorEntity): ) self._attr_force_update = self._device.always_callback self._attr_unique_id = str(self._device.sensor_value.group_address_state) - self._attr_unit_of_measurement = self._device.unit_of_measurement() + self._attr_native_unit_of_measurement = self._device.unit_of_measurement() self._attr_state_class = config.get(SensorSchema.CONF_STATE_CLASS) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" return self._device.resolve_state() diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index 18975bdb467..a22b30f6862 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -104,12 +104,12 @@ class KonnectedSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 099d359e619..57b37e51d11 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -169,7 +169,7 @@ class PlenticoreDataSensor(CoordinatorEntity, SensorEntity): return f"{self.platform_name} {self._sensor_name}" @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of this Sensor Entity or None.""" return self._sensor_data.get(ATTR_UNIT_OF_MEASUREMENT) @@ -199,7 +199,7 @@ class PlenticoreDataSensor(CoordinatorEntity, SensorEntity): return self._sensor_data.get(ATTR_LAST_RESET) @property - def state(self) -> Any | None: + def native_value(self) -> Any | None: """Return the state of the sensor.""" if self.coordinator.data is None: # None is translated to STATE_UNKNOWN diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index bc0a0a21845..1b9f8ca13cc 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -124,7 +124,7 @@ class KrakenSensor(CoordinatorEntity, SensorEntity): return self._name.lower() @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" return self._state @@ -229,7 +229,7 @@ class KrakenSensor(CoordinatorEntity, SensorEntity): return "mdi:cash" @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" if "number_of" not in self._sensor_type: return self._unit_of_measurement diff --git a/homeassistant/components/kwb/sensor.py b/homeassistant/components/kwb/sensor.py index 1b56803fae6..c6d2794a06d 100644 --- a/homeassistant/components/kwb/sensor.py +++ b/homeassistant/components/kwb/sensor.py @@ -94,13 +94,13 @@ class KWBSensor(SensorEntity): return self._sensor.available @property - def state(self): + def native_value(self): """Return the state of value.""" if self._sensor.value is not None and self._sensor.available: return self._sensor.value return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._sensor.unit_of_measurement diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index 2f93196a4bb..99aa39ce7cd 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -176,10 +176,10 @@ class LaCrosseTemperature(LaCrosseSensor): """Implementation of a Lacrosse temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._temperature @@ -187,11 +187,11 @@ class LaCrosseTemperature(LaCrosseSensor): class LaCrosseHumidity(LaCrosseSensor): """Implementation of a Lacrosse humidity sensor.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_icon = "mdi:water-percent" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._humidity @@ -200,7 +200,7 @@ class LaCrosseBattery(LaCrosseSensor): """Implementation of a Lacrosse battery sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._low_battery is None: return None diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 128450826d6..66f05c5d34d 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -77,7 +77,7 @@ class LastfmSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 68d2a024bca..18a947f7757 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -59,7 +59,7 @@ class LaunchLibrarySensor(SensorEntity): else: if next_launch := next((launch for launch in launches), None): self._attr_available = True - self._attr_state = next_launch.name + self._attr_native_value = next_launch.name self._attr_extra_state_attributes = { ATTR_LAUNCH_TIME: next_launch.net, ATTR_AGENCY: next_launch.launch_service_provider.name, diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index fdd6ee51872..965e9626f66 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -93,12 +93,12 @@ class LcnVariableSensor(LcnEntity, SensorEntity): await self.device_connection.cancel_status_request_handler(self.variable) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" return self._value @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return cast(str, self.unit.value) @@ -145,7 +145,7 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity): await self.device_connection.cancel_status_request_handler(self.source) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" return self._value diff --git a/homeassistant/components/lightwave/sensor.py b/homeassistant/components/lightwave/sensor.py index b298b78c7f6..369256ce403 100644 --- a/homeassistant/components/lightwave/sensor.py +++ b/homeassistant/components/lightwave/sensor.py @@ -26,7 +26,7 @@ class LightwaveBattery(SensorEntity): """Lightwave TRV Battery.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = STATE_CLASS_MEASUREMENT def __init__(self, name, lwlink, serial): @@ -43,7 +43,7 @@ class LightwaveBattery(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py index b7746392cee..18f1c81e368 100644 --- a/homeassistant/components/linux_battery/sensor.py +++ b/homeassistant/components/linux_battery/sensor.py @@ -90,12 +90,12 @@ class LinuxBatterySensor(SensorEntity): return DEVICE_CLASS_BATTERY @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._battery_stat.capacity @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return PERCENTAGE diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 15ea68f8342..cbcb75c0b23 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -36,7 +36,7 @@ class LitterRobotPropertySensor(LitterRobotEntity, SensorEntity): self.sensor_attribute = sensor_attribute @property - def state(self) -> str: + def native_value(self) -> str: """Return the state.""" return getattr(self.robot, self.sensor_attribute) @@ -45,7 +45,7 @@ class LitterRobotWasteSensor(LitterRobotPropertySensor): """Litter-Robot waste sensor.""" @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return unit of measurement.""" return PERCENTAGE @@ -59,10 +59,10 @@ class LitterRobotSleepTimeSensor(LitterRobotPropertySensor): """Litter-Robot sleep time sensor.""" @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state.""" if self.robot.sleep_mode_enabled: - return super().state.isoformat() + return super().native_value.isoformat() return None @property diff --git a/homeassistant/components/local_ip/sensor.py b/homeassistant/components/local_ip/sensor.py index 661ef88e641..bd1b3d54fac 100644 --- a/homeassistant/components/local_ip/sensor.py +++ b/homeassistant/components/local_ip/sensor.py @@ -33,6 +33,6 @@ class IPSensor(SensorEntity): async def async_update(self) -> None: """Fetch new state data for the sensor.""" - self._attr_state = await async_get_source_ip( + self._attr_native_value = await async_get_source_ip( self.hass, target_ip=PUBLIC_TARGET_IP ) diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index 29cd6e28e1c..a4158762b37 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -67,7 +67,7 @@ class LogiSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -112,7 +112,7 @@ class LogiSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return SENSOR_TYPES.get(self._sensor_type)[1] diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index 23eea5c00e0..23bc67f46bc 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -108,7 +108,7 @@ class AirSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index b7f2cb50cbf..eb962772fe5 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -67,7 +67,7 @@ class LondonTubeSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/loopenergy/sensor.py b/homeassistant/components/loopenergy/sensor.py index 78e55f22eb8..05d7f79ebfd 100644 --- a/homeassistant/components/loopenergy/sensor.py +++ b/homeassistant/components/loopenergy/sensor.py @@ -97,7 +97,7 @@ class LoopEnergySensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -107,7 +107,7 @@ class LoopEnergySensor(SensorEntity): return False @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index b27cc35ab26..b4bdd7f30b3 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -73,7 +73,7 @@ class LuftdatenSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the device.""" if self._data is not None: try: @@ -82,7 +82,7 @@ class LuftdatenSensor(SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/lyft/sensor.py b/homeassistant/components/lyft/sensor.py index 39cfff38a1b..84e3744a0e2 100644 --- a/homeassistant/components/lyft/sensor.py +++ b/homeassistant/components/lyft/sensor.py @@ -103,12 +103,12 @@ class LyftSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index f4d4d4b999a..868b6262ddc 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -94,7 +94,7 @@ class LyricSensor(LyricDeviceEntity, SensorEntity): return self._device_class @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return self._unit_of_measurement @@ -123,7 +123,7 @@ class LyricIndoorTemperatureSensor(LyricSensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return self.device.indoorTemperature @@ -152,7 +152,7 @@ class LyricOutdoorTemperatureSensor(LyricSensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return self.device.outdoorTemperature @@ -181,7 +181,7 @@ class LyricOutdoorHumiditySensor(LyricSensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return self.device.displayedOutdoorHumidity @@ -209,7 +209,7 @@ class LyricNextPeriodSensor(LyricSensor): ) @property - def state(self) -> datetime: + def native_value(self) -> datetime: """Return the state of the sensor.""" device = self.device time = dt_util.parse_time(device.changeableValues.nextPeriodTime) @@ -242,7 +242,7 @@ class LyricSetpointStatusSensor(LyricSensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" device = self.device if device.changeableValues.thermostatSetpointStatus == PRESET_HOLD_UNTIL: diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py index 0dd27a60ae0..12288c5ab78 100644 --- a/homeassistant/components/magicseaweed/sensor.py +++ b/homeassistant/components/magicseaweed/sensor.py @@ -115,7 +115,7 @@ class MagicSeaweedSensor(SensorEntity): return f"{self.hour} {self.client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -125,7 +125,7 @@ class MagicSeaweedSensor(SensorEntity): return self._unit_system @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/mazda/sensor.py b/homeassistant/components/mazda/sensor.py index 673c965544b..03bfbd23b31 100644 --- a/homeassistant/components/mazda/sensor.py +++ b/homeassistant/components/mazda/sensor.py @@ -46,7 +46,7 @@ class MazdaFuelRemainingSensor(MazdaEntity, SensorEntity): return f"{self.vin}_fuel_remaining_percentage" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE @@ -56,7 +56,7 @@ class MazdaFuelRemainingSensor(MazdaEntity, SensorEntity): return "mdi:gas-station" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.data["status"]["fuelRemainingPercent"] @@ -76,7 +76,7 @@ class MazdaFuelDistanceSensor(MazdaEntity, SensorEntity): return f"{self.vin}_fuel_distance_remaining" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: return LENGTH_MILES @@ -88,7 +88,7 @@ class MazdaFuelDistanceSensor(MazdaEntity, SensorEntity): return "mdi:gas-station" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" fuel_distance_km = self.data["status"]["fuelDistanceRemainingKm"] return ( @@ -115,7 +115,7 @@ class MazdaOdometerSensor(MazdaEntity, SensorEntity): return f"{self.vin}_odometer" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: return LENGTH_MILES @@ -127,7 +127,7 @@ class MazdaOdometerSensor(MazdaEntity, SensorEntity): return "mdi:speedometer" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" odometer_km = self.data["status"]["odometerKm"] return ( @@ -152,7 +152,7 @@ class MazdaFrontLeftTirePressureSensor(MazdaEntity, SensorEntity): return f"{self.vin}_front_left_tire_pressure" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PRESSURE_PSI @@ -162,7 +162,7 @@ class MazdaFrontLeftTirePressureSensor(MazdaEntity, SensorEntity): return "mdi:car-tire-alert" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" tire_pressure = self.data["status"]["tirePressure"]["frontLeftTirePressurePsi"] return None if tire_pressure is None else round(tire_pressure) @@ -183,7 +183,7 @@ class MazdaFrontRightTirePressureSensor(MazdaEntity, SensorEntity): return f"{self.vin}_front_right_tire_pressure" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PRESSURE_PSI @@ -193,7 +193,7 @@ class MazdaFrontRightTirePressureSensor(MazdaEntity, SensorEntity): return "mdi:car-tire-alert" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" tire_pressure = self.data["status"]["tirePressure"]["frontRightTirePressurePsi"] return None if tire_pressure is None else round(tire_pressure) @@ -214,7 +214,7 @@ class MazdaRearLeftTirePressureSensor(MazdaEntity, SensorEntity): return f"{self.vin}_rear_left_tire_pressure" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PRESSURE_PSI @@ -224,7 +224,7 @@ class MazdaRearLeftTirePressureSensor(MazdaEntity, SensorEntity): return "mdi:car-tire-alert" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" tire_pressure = self.data["status"]["tirePressure"]["rearLeftTirePressurePsi"] return None if tire_pressure is None else round(tire_pressure) @@ -245,7 +245,7 @@ class MazdaRearRightTirePressureSensor(MazdaEntity, SensorEntity): return f"{self.vin}_rear_right_tire_pressure" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PRESSURE_PSI @@ -255,7 +255,7 @@ class MazdaRearRightTirePressureSensor(MazdaEntity, SensorEntity): return "mdi:car-tire-alert" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" tire_pressure = self.data["status"]["tirePressure"]["rearRightTirePressurePsi"] return None if tire_pressure is None else round(tire_pressure) diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 6c303e8e3c3..12029127b84 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -41,7 +41,7 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="room_temperature", name="Room Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda x: x.device.room_temperature, enabled=lambda x: True, @@ -50,7 +50,7 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="energy", name="Energy", icon="mdi:factory", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, value_fn=lambda x: x.device.total_energy_consumed, enabled=lambda x: x.device.has_energy_consumed_meter, @@ -61,7 +61,7 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="outside_temperature", name="Outside Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda x: x.device.outside_temperature, enabled=lambda x: True, @@ -70,7 +70,7 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="tank_temperature", name="Tank Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda x: x.device.tank_temperature, enabled=lambda x: True, @@ -81,7 +81,7 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="room_temperature", name="Room Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda zone: zone.room_temperature, enabled=lambda x: True, @@ -90,7 +90,7 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="flow_temperature", name="Flow Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda zone: zone.flow_temperature, enabled=lambda x: True, @@ -99,7 +99,7 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="return_temperature", name="Flow Return Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda zone: zone.return_temperature, enabled=lambda x: True, @@ -156,7 +156,7 @@ class MelDeviceSensor(SensorEntity): self._attr_last_reset = dt_util.utc_from_timestamp(0) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.entity_description.value_fn(self._api) @@ -187,6 +187,6 @@ class AtwZoneSensor(MelDeviceSensor): self._attr_name = f"{api.name} {zone.name} {description.name}" @property - def state(self): + def native_value(self): """Return zone based state.""" return self.entity_description.value_fn(self._zone) diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index ed1978d160d..df006c78194 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -109,7 +109,7 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): } @property - def state(self): + def native_value(self): """Return the state.""" path = SENSOR_TYPES[self._type][ENTITY_API_DATA_PATH].split(":") data = getattr(self.coordinator.data, path[0]) @@ -135,7 +135,7 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): return value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return SENSOR_TYPES[self._type][ENTITY_UNIT] @@ -164,7 +164,7 @@ class MeteoFranceRainSensor(MeteoFranceSensor): """Representation of a Meteo-France rain sensor.""" @property - def state(self): + def native_value(self): """Return the state.""" # search first cadran with rain next_rain = next( @@ -202,7 +202,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor): self._unique_id = self._name @property - def state(self): + def native_value(self): """Return the state.""" return get_warning_text_status_from_indice_color( self.coordinator.data.get_domain_max_color() diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index 101b889498d..b5a07ad06e6 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -51,7 +51,9 @@ class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): f"{station.name} {SENSOR_TYPES[sensor_type][SENSOR_TYPE_NAME]}" ) self._attr_unique_id = f"{station.code}_{sensor_type}" - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type].get(SENSOR_TYPE_UNIT) + self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type].get( + SENSOR_TYPE_UNIT + ) @property def device_info(self): @@ -65,7 +67,7 @@ class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): } @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return ( getattr(self.coordinator.data["weather"], self._type) diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 749282b1a21..4919e36bd58 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -42,7 +42,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="name", name="Station Name", device_class=None, - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:label-outline", entity_registry_enabled_default=False, ), @@ -50,7 +50,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="weather", name="Weather", device_class=None, - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:weather-sunny", # but will adapt to current conditions entity_registry_enabled_default=True, ), @@ -58,7 +58,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="temperature", name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, icon=None, entity_registry_enabled_default=True, ), @@ -66,7 +66,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="feels_like_temperature", name="Feels Like Temperature", device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, icon=None, entity_registry_enabled_default=False, ), @@ -74,7 +74,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="wind_speed", name="Wind Speed", device_class=None, - unit_of_measurement=SPEED_MILES_PER_HOUR, + native_unit_of_measurement=SPEED_MILES_PER_HOUR, icon="mdi:weather-windy", entity_registry_enabled_default=True, ), @@ -82,7 +82,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="wind_direction", name="Wind Direction", device_class=None, - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:compass-outline", entity_registry_enabled_default=False, ), @@ -90,7 +90,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="wind_gust", name="Wind Gust", device_class=None, - unit_of_measurement=SPEED_MILES_PER_HOUR, + native_unit_of_measurement=SPEED_MILES_PER_HOUR, icon="mdi:weather-windy", entity_registry_enabled_default=False, ), @@ -98,7 +98,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="visibility", name="Visibility", device_class=None, - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:eye", entity_registry_enabled_default=False, ), @@ -106,7 +106,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="visibility_distance", name="Visibility Distance", device_class=None, - unit_of_measurement=LENGTH_KILOMETERS, + native_unit_of_measurement=LENGTH_KILOMETERS, icon="mdi:eye", entity_registry_enabled_default=False, ), @@ -114,7 +114,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="uv", name="UV Index", device_class=None, - unit_of_measurement=UV_INDEX, + native_unit_of_measurement=UV_INDEX, icon="mdi:weather-sunny-alert", entity_registry_enabled_default=True, ), @@ -122,7 +122,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="precipitation", name="Probability of Precipitation", device_class=None, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-rainy", entity_registry_enabled_default=True, ), @@ -130,7 +130,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="humidity", name="Humidity", device_class=DEVICE_CLASS_HUMIDITY, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon=None, entity_registry_enabled_default=False, ), @@ -189,7 +189,7 @@ class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): self.use_3hourly = use_3hourly @property - def state(self): + def native_value(self): """Return the state of the sensor.""" value = None diff --git a/homeassistant/components/mfi/sensor.py b/homeassistant/components/mfi/sensor.py index fafaf53ff99..b27f719d974 100644 --- a/homeassistant/components/mfi/sensor.py +++ b/homeassistant/components/mfi/sensor.py @@ -88,7 +88,7 @@ class MfiSensor(SensorEntity): return self._port.label @property - def state(self): + def native_value(self): """Return the state of the sensor.""" try: tag = self._port.tag @@ -115,7 +115,7 @@ class MfiSensor(SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" try: tag = self._port.tag diff --git a/homeassistant/components/mhz19/sensor.py b/homeassistant/components/mhz19/sensor.py index 63a1181f720..7d5d5eba183 100644 --- a/homeassistant/components/mhz19/sensor.py +++ b/homeassistant/components/mhz19/sensor.py @@ -90,12 +90,12 @@ class MHZ19Sensor(SensorEntity): return f"{self._name}: {SENSOR_TYPES[self._sensor_type][0]}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._ppm if self._sensor_type == SENSOR_CO2 else self._temperature @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py index a7aab41bea9..f712ffe6fe5 100644 --- a/homeassistant/components/miflora/sensor.py +++ b/homeassistant/components/miflora/sensor.py @@ -180,7 +180,7 @@ class MiFloraSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -207,7 +207,7 @@ class MiFloraSensor(SensorEntity): return STATE_CLASS_MEASUREMENT @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return self._unit diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index d103ff8eaa6..e4b4cdf9922 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -172,7 +172,7 @@ class MinMaxSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._unit_of_measurement_mismatch: return None @@ -181,7 +181,7 @@ class MinMaxSensor(SensorEntity): ) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" if self._unit_of_measurement_mismatch: return "ERR" diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 651c2762c55..9f1c89f09c6 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -70,12 +70,12 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): return self._server.online @property - def state(self) -> Any: + def native_value(self) -> Any: """Return sensor state.""" return self._state @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return sensor measurement unit.""" return self._unit diff --git a/homeassistant/components/mitemp_bt/sensor.py b/homeassistant/components/mitemp_bt/sensor.py index 670a6daf3d3..732beb11b3a 100644 --- a/homeassistant/components/mitemp_bt/sensor.py +++ b/homeassistant/components/mitemp_bt/sensor.py @@ -127,12 +127,12 @@ class MiTempBtSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return self._unit diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 7e3c1c13148..f6652f7f889 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -74,11 +74,11 @@ class MobileAppSensor(MobileAppEntity, SensorEntity): """Representation of an mobile app sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._config[ATTR_SENSOR_STATE] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._config.get(ATTR_SENSOR_UOM) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index fee3f53667d..3165f416a6e 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -47,14 +47,14 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): ) -> None: """Initialize the modbus register sensor.""" super().__init__(hub, entry) - self._attr_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) async def async_added_to_hass(self): """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: - self._attr_state = state.state + self._attr_native_value = state.state async def async_update(self, now=None): """Update the state of the sensor.""" @@ -68,6 +68,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): self.async_write_ha_state() return - self._attr_state = self.unpack_structure_result(result.registers) + self._attr_native_value = self.unpack_structure_result(result.registers) self._attr_available = True self.async_write_ha_state() diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index 080e077a457..afbc09eb45c 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -75,7 +75,7 @@ class ModemCalleridSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index 01efe3f1d28..1e51ec9a1ae 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -73,7 +73,7 @@ class ModernFormsLightTimerRemainingTimeSensor(ModernFormsSensor): self._attr_device_class = DEVICE_CLASS_TIMESTAMP @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" sleep_time: datetime = dt_util.utc_from_timestamp( self.coordinator.data.state.light_sleep_timer @@ -103,7 +103,7 @@ class ModernFormsFanTimerRemainingTimeSensor(ModernFormsSensor): self._attr_device_class = DEVICE_CLASS_TIMESTAMP @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" sleep_time: datetime = dt_util.utc_from_timestamp( self.coordinator.data.state.fan_sleep_timer diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 7bfa161f9ec..c57903ce5b7 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -359,12 +359,12 @@ class MoldIndicator(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE @property - def state(self): + def native_value(self): """Return the state of the entity.""" return self._state diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 6213e218d24..223ee831779 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -60,7 +60,7 @@ class MoonSensor(SensorEntity): return "moon__phase" @property - def state(self): + def native_value(self): """Return the state of the device.""" if self._state == 0: return STATE_NEW_MOON diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index be88a099f25..9c6db5d88ec 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -47,7 +47,7 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): """ _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, coordinator, blind): """Initialize the Motion Battery Sensor.""" @@ -70,7 +70,7 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): return self.coordinator.data[self._blind.mac][ATTR_AVAILABLE] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._blind.battery_level @@ -106,7 +106,7 @@ class MotionTDBUBatterySensor(MotionBatterySensor): self._attr_name = f"{blind.blind_type}-{motor}-battery-{blind.mac[12:]}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._blind.battery_level is None: return None @@ -128,7 +128,7 @@ class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): _attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH _attr_entity_registry_enabled_default = False - _attr_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT def __init__(self, coordinator, device, device_type): """Initialize the Motion Signal Strength Sensor.""" @@ -162,7 +162,7 @@ class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.RSSI diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 239af7b450a..eac136d3f84 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -214,7 +214,7 @@ class MqttSensor(MqttEntity, SensorEntity): self.async_write_ha_state() @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._config.get(CONF_UNIT_OF_MEASUREMENT) @@ -224,7 +224,7 @@ class MqttSensor(MqttEntity, SensorEntity): return self._config[CONF_FORCE_UPDATE] @property - def state(self): + def native_value(self): """Return the state of the entity.""" return self._state diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index b40d550abf6..479b02ebcbd 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -139,7 +139,7 @@ class MQTTRoomSensor(SensorEntity): return {ATTR_DISTANCE: self._distance} @property - def state(self): + def native_value(self): """Return the current room of the entity.""" return self._state diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index 953fe4c69a8..416ce21cbaf 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -108,7 +108,7 @@ class MVGLiveSensor(SensorEntity): return self._station @property - def state(self): + def native_value(self): """Return the next departure time.""" return self._state @@ -128,7 +128,7 @@ class MVGLiveSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/mychevy/sensor.py b/homeassistant/components/mychevy/sensor.py index 18b5e95d838..1a5613d8864 100644 --- a/homeassistant/components/mychevy/sensor.py +++ b/homeassistant/components/mychevy/sensor.py @@ -98,7 +98,7 @@ class MyChevyStatus(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state.""" return self._state @@ -166,7 +166,7 @@ class EVSensor(SensorEntity): self.async_write_ha_state() @property - def state(self): + def native_value(self): """Return the state.""" return self._state @@ -176,7 +176,7 @@ class EVSensor(SensorEntity): return self._state_attributes @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement the state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index c7755b13512..bb56770fd0c 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -147,8 +147,8 @@ class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): return True @property - def state(self) -> str | None: - """Return the state of this entity.""" + def native_value(self) -> str | None: + """Return the state of the device.""" return self._values.get(self.value_type) @property @@ -176,7 +176,7 @@ class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): return self._get_sensor_type()[3] @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity.""" set_req = self.gateway.const.SetReq if ( diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index a5f5b955882..dbc8c39790c 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -26,6 +26,7 @@ async def test_sleep_time_sensor_with_none_state(hass): sensor = LitterRobotSleepTimeSensor( robot, "Sleep Mode Start Time", Mock(), "sleep_mode_start_time" ) + sensor.hass = hass assert sensor assert sensor.state is None diff --git a/tests/components/mfi/test_sensor.py b/tests/components/mfi/test_sensor.py index 1f5cc5fd04f..4032e29b743 100644 --- a/tests/components/mfi/test_sensor.py +++ b/tests/components/mfi/test_sensor.py @@ -121,7 +121,9 @@ def port_fixture(): @pytest.fixture(name="sensor") def sensor_fixture(hass, port): """Sensor fixture.""" - return mfi.MfiSensor(port, hass) + sensor = mfi.MfiSensor(port, hass) + sensor.hass = hass + return sensor async def test_name(port, sensor): diff --git a/tests/components/mhz19/test_sensor.py b/tests/components/mhz19/test_sensor.py index e827b5dfbd2..26e9441f9fc 100644 --- a/tests/components/mhz19/test_sensor.py +++ b/tests/components/mhz19/test_sensor.py @@ -83,10 +83,11 @@ async def aiohttp_client_update_good_read(mock_function): @patch("pmsensor.co2sensor.read_mh_z19_with_temperature", return_value=(1000, 24)) -async def test_co2_sensor(mock_function): +async def test_co2_sensor(mock_function, hass): """Test CO2 sensor.""" client = mhz19.MHZClient(co2sensor, "test.serial") sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_CO2, None, "name") + sensor.hass = hass sensor.update() assert sensor.name == "name: CO2" @@ -97,10 +98,11 @@ async def test_co2_sensor(mock_function): @patch("pmsensor.co2sensor.read_mh_z19_with_temperature", return_value=(1000, 24)) -async def test_temperature_sensor(mock_function): +async def test_temperature_sensor(mock_function, hass): """Test temperature sensor.""" client = mhz19.MHZClient(co2sensor, "test.serial") sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_TEMPERATURE, None, "name") + sensor.hass = hass sensor.update() assert sensor.name == "name: Temperature" @@ -111,12 +113,13 @@ async def test_temperature_sensor(mock_function): @patch("pmsensor.co2sensor.read_mh_z19_with_temperature", return_value=(1000, 24)) -async def test_temperature_sensor_f(mock_function): +async def test_temperature_sensor_f(mock_function, hass): """Test temperature sensor.""" client = mhz19.MHZClient(co2sensor, "test.serial") sensor = mhz19.MHZ19Sensor( client, mhz19.SENSOR_TEMPERATURE, TEMP_FAHRENHEIT, "name" ) + sensor.hass = hass sensor.update() assert sensor.state == 75.2 From 539ed56000d27e1e6fc2256000cc68457236677e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20M=C3=BCndler?= Date: Wed, 11 Aug 2021 22:40:04 +0200 Subject: [PATCH 342/903] Refactor Fronius sensor device class and long term statistics (#54185) --- homeassistant/components/fronius/sensor.py | 101 +++++++-------------- 1 file changed, 35 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 6f949334d02..211fdaabafd 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, SensorEntity, - SensorEntityDescription, ) from homeassistant.const import ( CONF_DEVICE, @@ -20,8 +19,14 @@ from homeassistant.const import ( CONF_RESOURCE, CONF_SCAN_INTERVAL, CONF_SENSOR_TYPE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLTAGE, ) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -48,6 +53,17 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) SENSOR_TYPES = [TYPE_INVERTER, TYPE_STORAGE, TYPE_METER, TYPE_POWER_FLOW] SCOPE_TYPES = [SCOPE_DEVICE, SCOPE_SYSTEM] +PREFIX_DEVICE_CLASS_MAPPING = [ + ("state_of_charge", DEVICE_CLASS_BATTERY), + ("temperature", DEVICE_CLASS_TEMPERATURE), + ("power_factor", DEVICE_CLASS_POWER_FACTOR), + ("power", DEVICE_CLASS_POWER), + ("energy", DEVICE_CLASS_ENERGY), + ("current", DEVICE_CLASS_CURRENT), + ("timestamp", DEVICE_CLASS_TIMESTAMP), + ("voltage", DEVICE_CLASS_VOLTAGE), +] + def _device_id_validator(config): """Ensure that inverters have default id 1 and other devices 0.""" @@ -161,12 +177,6 @@ class FroniusAdapter: """Whether the fronius device is active.""" return self._available - def entity_description( # pylint: disable=no-self-use - self, key - ) -> SensorEntityDescription | None: - """Create entity description for a key.""" - return None - async def async_update(self): """Retrieve and update latest state.""" try: @@ -223,18 +233,6 @@ class FroniusAdapter: class FroniusInverterSystem(FroniusAdapter): """Adapter for the fronius inverter with system scope.""" - def entity_description(self, key): - """Return the entity descriptor.""" - if key != "energy_total": - return None - - return SensorEntityDescription( - key=key, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), - ) - async def _update(self): """Get the values for the current state.""" return await self.bridge.current_system_inverter_data() @@ -243,18 +241,6 @@ class FroniusInverterSystem(FroniusAdapter): class FroniusInverterDevice(FroniusAdapter): """Adapter for the fronius inverter with device scope.""" - def entity_description(self, key): - """Return the entity descriptor.""" - if key != "energy_total": - return None - - return SensorEntityDescription( - key=key, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), - ) - async def _update(self): """Get the values for the current state.""" return await self.bridge.current_inverter_data(self._device) @@ -271,18 +257,6 @@ class FroniusStorage(FroniusAdapter): class FroniusMeterSystem(FroniusAdapter): """Adapter for the fronius meter with system scope.""" - def entity_description(self, key): - """Return the entity descriptor.""" - if not key.startswith("energy_real_"): - return None - - return SensorEntityDescription( - key=key, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), - ) - async def _update(self): """Get the values for the current state.""" return await self.bridge.current_system_meter_data() @@ -291,18 +265,6 @@ class FroniusMeterSystem(FroniusAdapter): class FroniusMeterDevice(FroniusAdapter): """Adapter for the fronius meter with device scope.""" - def entity_description(self, key): - """Return the entity descriptor.""" - if not key.startswith("energy_real_"): - return None - - return SensorEntityDescription( - key=key, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), - ) - async def _update(self): """Get the values for the current state.""" return await self.bridge.current_meter_data(self._device) @@ -311,14 +273,6 @@ class FroniusMeterDevice(FroniusAdapter): class FroniusPowerFlow(FroniusAdapter): """Adapter for the fronius power flow.""" - def entity_description(self, key): - """Return the entity descriptor.""" - return SensorEntityDescription( - key=key, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ) - async def _update(self): """Get the values for the current state.""" return await self.bridge.current_power_flow() @@ -327,13 +281,17 @@ class FroniusPowerFlow(FroniusAdapter): class FroniusTemplateSensor(SensorEntity): """Sensor for the single values (e.g. pv power, ac power).""" - def __init__(self, parent: FroniusAdapter, key): + _attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__(self, parent: FroniusAdapter, key: str) -> None: """Initialize a singular value sensor.""" self._key = key self._attr_name = f"{key.replace('_', ' ').capitalize()} {parent.name}" self._parent = parent - if entity_description := parent.entity_description(key): - self.entity_description = entity_description + for prefix, device_class in PREFIX_DEVICE_CLASS_MAPPING: + if self._key.startswith(prefix): + self._attr_device_class = device_class + break @property def should_poll(self): @@ -353,6 +311,17 @@ class FroniusTemplateSensor(SensorEntity): self._attr_state = round(self._attr_state, 2) self._attr_unit_of_measurement = state.get("unit") + @property + def last_reset(self) -> dt.dt.datetime | None: + """Return the time when the sensor was last reset, if it is a meter.""" + if self._key.endswith("day"): + return dt.start_of_local_day() + if self._key.endswith("year"): + return dt.start_of_local_day(dt.dt.date(dt.now().year, 1, 1)) + if self._key.endswith("total") or self._key.startswith("energy_real"): + return dt.utc_from_timestamp(0) + return None + async def async_added_to_hass(self): """Register at parent component for updates.""" self.async_on_remove(self._parent.register(self)) From b4113728723847174535d1acb24d4a14767c274e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 11 Aug 2021 22:41:51 +0200 Subject: [PATCH 343/903] Use EntityDescription - blink (#54360) --- .../components/blink/binary_sensor.py | 47 +++++++++++------- homeassistant/components/blink/sensor.py | 49 +++++++++++-------- 2 files changed, 59 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index f9b8ec31605..6be284e2197 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -1,47 +1,60 @@ """Support for Blink system camera control.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_MOTION, BinarySensorEntity, + BinarySensorEntityDescription, ) from .const import DOMAIN, TYPE_BATTERY, TYPE_CAMERA_ARMED, TYPE_MOTION_DETECTED -BINARY_SENSORS = { - TYPE_BATTERY: ["Battery", DEVICE_CLASS_BATTERY], - TYPE_CAMERA_ARMED: ["Camera Armed", None], - TYPE_MOTION_DETECTED: ["Motion Detected", DEVICE_CLASS_MOTION], -} +BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key=TYPE_BATTERY, + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + ), + BinarySensorEntityDescription( + key=TYPE_CAMERA_ARMED, + name="Camera Armed", + ), + BinarySensorEntityDescription( + key=TYPE_MOTION_DETECTED, + name="Motion Detected", + device_class=DEVICE_CLASS_MOTION, + ), +) async def async_setup_entry(hass, config, async_add_entities): """Set up the blink binary sensors.""" data = hass.data[DOMAIN][config.entry_id] - entities = [] - for camera in data.cameras: - for sensor_type in BINARY_SENSORS: - entities.append(BlinkBinarySensor(data, camera, sensor_type)) + entities = [ + BlinkBinarySensor(data, camera, description) + for camera in data.cameras + for description in BINARY_SENSORS_TYPES + ] async_add_entities(entities) class BlinkBinarySensor(BinarySensorEntity): """Representation of a Blink binary sensor.""" - def __init__(self, data, camera, sensor_type): + def __init__(self, data, camera, description: BinarySensorEntityDescription): """Initialize the sensor.""" self.data = data - self._type = sensor_type - name, device_class = BINARY_SENSORS[sensor_type] - self._attr_name = f"{DOMAIN} {camera} {name}" - self._attr_device_class = device_class + self.entity_description = description + self._attr_name = f"{DOMAIN} {camera} {description.name}" self._camera = data.cameras[camera] - self._attr_unique_id = f"{self._camera.serial}-{sensor_type}" + self._attr_unique_id = f"{self._camera.serial}-{description.key}" def update(self): """Update sensor state.""" self.data.refresh() - state = self._camera.attributes[self._type] - if self._type == TYPE_BATTERY: + state = self._camera.attributes[self.entity_description.key] + if self.entity_description.key == TYPE_BATTERY: state = state != "ok" self._attr_is_on = state diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 88f10183b32..d2122b59cd8 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -1,7 +1,9 @@ """Support for Blink system camera sensors.""" +from __future__ import annotations + import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, @@ -13,23 +15,30 @@ from .const import DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH _LOGGER = logging.getLogger(__name__) -SENSORS = { - TYPE_TEMPERATURE: ["Temperature", TEMP_FAHRENHEIT, DEVICE_CLASS_TEMPERATURE], - TYPE_WIFI_STRENGTH: [ - "Wifi Signal", - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - DEVICE_CLASS_SIGNAL_STRENGTH, - ], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=TYPE_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_WIFI_STRENGTH, + name="Wifi Signal", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + ), +) async def async_setup_entry(hass, config, async_add_entities): """Initialize a Blink sensor.""" data = hass.data[DOMAIN][config.entry_id] - entities = [] - for camera in data.cameras: - for sensor_type in SENSORS: - entities.append(BlinkSensor(data, camera, sensor_type)) + entities = [ + BlinkSensor(data, camera, description) + for camera in data.cameras + for description in SENSOR_TYPES + ] async_add_entities(entities) @@ -37,17 +46,17 @@ async def async_setup_entry(hass, config, async_add_entities): class BlinkSensor(SensorEntity): """A Blink camera sensor.""" - def __init__(self, data, camera, sensor_type): + def __init__(self, data, camera, description: SensorEntityDescription): """Initialize sensors from Blink camera.""" - name, units, device_class = SENSORS[sensor_type] - self._attr_name = f"{DOMAIN} {camera} {name}" - self._attr_device_class = device_class + self.entity_description = description + self._attr_name = f"{DOMAIN} {camera} {description.name}" self.data = data self._camera = data.cameras[camera] - self._attr_native_unit_of_measurement = units - self._attr_unique_id = f"{self._camera.serial}-{sensor_type}" + self._attr_unique_id = f"{self._camera.serial}-{description.key}" self._sensor_key = ( - "temperature_calibrated" if sensor_type == "temperature" else sensor_type + "temperature_calibrated" + if description.key == "temperature" + else description.key ) def update(self): From f77187d28ab50448b8e8e0bce181fa9f86fb0b80 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 12 Aug 2021 00:16:28 +0200 Subject: [PATCH 344/903] Deprecate Wink integration (#54496) --- homeassistant/components/wink/__init__.py | 45 +++++++++++++---------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index f11e15670e9..f346d9145f8 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -111,25 +111,28 @@ CHIME_TONES = TONES + ["inactive"] AUTO_SHUTOFF_TIMES = [None, -1, 30, 60, 120] CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Inclusive( - CONF_EMAIL, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG - ): cv.string, - vol.Inclusive( - CONF_PASSWORD, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG - ): cv.string, - vol.Inclusive( - CONF_CLIENT_ID, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG - ): cv.string, - vol.Inclusive( - CONF_CLIENT_SECRET, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG - ): cv.string, - vol.Optional(CONF_LOCAL_CONTROL, default=False): cv.boolean, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Inclusive( + CONF_EMAIL, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG + ): cv.string, + vol.Inclusive( + CONF_PASSWORD, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG + ): cv.string, + vol.Inclusive( + CONF_CLIENT_ID, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG + ): cv.string, + vol.Inclusive( + CONF_CLIENT_SECRET, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG + ): cv.string, + vol.Optional(CONF_LOCAL_CONTROL, default=False): cv.boolean, + } + ), + }, + ), extra=vol.ALLOW_EXTRA, ) @@ -282,6 +285,10 @@ def _request_oauth_completion(hass, config): def setup(hass, config): # noqa: C901 """Set up the Wink component.""" + _LOGGER.warning( + "The Wink integration has been deprecated and is pending removal in " + "Home Assistant Core 2021.11" + ) if hass.data.get(DOMAIN) is None: hass.data[DOMAIN] = { From 0626542a143931de9435b95a8b5dfa9884da0cc5 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 12 Aug 2021 00:14:10 +0000 Subject: [PATCH 345/903] [ci skip] Translation update --- .../components/adax/translations/zh-Hans.json | 14 +++++++ .../august/translations/zh-Hans.json | 16 +++++++ .../azure_devops/translations/zh-Hans.json | 26 +++++++++++- .../bosch_shc/translations/zh-Hans.json | 14 +++++++ .../braviatv/translations/zh-Hans.json | 14 ++++++- .../cloudflare/translations/zh-Hans.json | 5 +++ .../elgato/translations/zh-Hans.json | 15 +++++++ .../energy/translations/zh-Hans.json | 3 ++ .../enphase_envoy/translations/zh-Hans.json | 11 +++++ .../epson/translations/zh-Hans.json | 16 +++++++ .../components/flipr/translations/es.json | 8 +++- .../flipr/translations/zh-Hans.json | 11 +++++ .../flume/translations/zh-Hans.json | 6 +++ .../forked_daapd/translations/zh-Hans.json | 25 +++++++++++ .../fritz/translations/zh-Hans.json | 27 ++++++++++++ .../growatt_server/translations/zh-Hans.json | 11 +++++ .../components/hive/translations/zh-Hans.json | 19 +++++++++ .../honeywell/translations/zh-Hans.json | 11 +++++ .../ifttt/translations/zh-Hans.json | 4 ++ .../kmtronic/translations/zh-Hans.json | 11 +++++ .../translations/zh-Hans.json | 11 +++++ .../kraken/translations/es-419.json | 12 ++++++ .../components/kraken/translations/es.json | 10 +++++ .../components/litejet/translations/es.json | 10 +++++ .../litejet/translations/zh-Hans.json | 9 ++++ .../litterrobot/translations/zh-Hans.json | 11 +++++ .../components/myq/translations/zh-Hans.json | 6 +++ .../components/nut/translations/zh-Hans.json | 32 +++++++++++++- .../onvif/translations/zh-Hans.json | 2 +- .../ovo_energy/translations/zh-Hans.json | 7 ++++ .../prosegur/translations/zh-Hans.json | 29 +++++++++++++ .../renault/translations/es-419.json | 9 ++++ .../components/renault/translations/es.json | 12 +++++- .../renault/translations/zh-Hans.json | 1 + .../translations/zh-Hans.json | 11 +++++ .../roomba/translations/zh-Hans.json | 12 ++++++ .../components/sensor/translations/en.json | 2 + .../components/sensor/translations/et.json | 2 + .../sensor/translations/zh-Hant.json | 2 + .../shopping_list/translations/zh-Hans.json | 14 +++++++ .../smartthings/translations/zh-Hans.json | 5 +++ .../smarttub/translations/zh-Hans.json | 11 +++++ .../components/soma/translations/zh-Hans.json | 7 ++++ .../sonarr/translations/zh-Hans.json | 38 +++++++++++++++++ .../srp_energy/translations/zh-Hans.json | 13 ++++++ .../subaru/translations/zh-Hans.json | 11 +++++ .../components/tesla/translations/es.json | 1 + .../tesla/translations/zh-Hans.json | 11 +++++ .../uptimerobot/translations/es-419.json | 7 ++++ .../uptimerobot/translations/es.json | 13 +++++- .../uptimerobot/translations/zh-Hans.json | 12 +++++- .../verisure/translations/zh-Hans.json | 16 +++++++ .../wallbox/translations/zh-Hans.json | 11 +++++ .../xiaomi_miio/translations/zh-Hans.json | 3 +- .../yale_smart_alarm/translations/es-419.json | 11 +++++ .../translations/zh-Hans.json | 28 +++++++++++++ .../translations/zh-Hans.json | 9 ++++ .../yeelight/translations/zh-Hans.json | 42 +++++++++++++++++++ .../youless/translations/zh-Hans.json | 15 +++++++ .../zoneminder/translations/zh-Hans.json | 6 +++ 60 files changed, 729 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/adax/translations/zh-Hans.json create mode 100644 homeassistant/components/august/translations/zh-Hans.json create mode 100644 homeassistant/components/bosch_shc/translations/zh-Hans.json create mode 100644 homeassistant/components/energy/translations/zh-Hans.json create mode 100644 homeassistant/components/enphase_envoy/translations/zh-Hans.json create mode 100644 homeassistant/components/epson/translations/zh-Hans.json create mode 100644 homeassistant/components/flipr/translations/zh-Hans.json create mode 100644 homeassistant/components/forked_daapd/translations/zh-Hans.json create mode 100644 homeassistant/components/fritz/translations/zh-Hans.json create mode 100644 homeassistant/components/growatt_server/translations/zh-Hans.json create mode 100644 homeassistant/components/hive/translations/zh-Hans.json create mode 100644 homeassistant/components/honeywell/translations/zh-Hans.json create mode 100644 homeassistant/components/kmtronic/translations/zh-Hans.json create mode 100644 homeassistant/components/kostal_plenticore/translations/zh-Hans.json create mode 100644 homeassistant/components/kraken/translations/es-419.json create mode 100644 homeassistant/components/litejet/translations/zh-Hans.json create mode 100644 homeassistant/components/litterrobot/translations/zh-Hans.json create mode 100644 homeassistant/components/prosegur/translations/zh-Hans.json create mode 100644 homeassistant/components/renault/translations/es-419.json create mode 100644 homeassistant/components/rituals_perfume_genie/translations/zh-Hans.json create mode 100644 homeassistant/components/roomba/translations/zh-Hans.json create mode 100644 homeassistant/components/shopping_list/translations/zh-Hans.json create mode 100644 homeassistant/components/smarttub/translations/zh-Hans.json create mode 100644 homeassistant/components/soma/translations/zh-Hans.json create mode 100644 homeassistant/components/sonarr/translations/zh-Hans.json create mode 100644 homeassistant/components/srp_energy/translations/zh-Hans.json create mode 100644 homeassistant/components/subaru/translations/zh-Hans.json create mode 100644 homeassistant/components/tesla/translations/zh-Hans.json create mode 100644 homeassistant/components/uptimerobot/translations/es-419.json create mode 100644 homeassistant/components/verisure/translations/zh-Hans.json create mode 100644 homeassistant/components/wallbox/translations/zh-Hans.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/es-419.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/zh-Hans.json create mode 100644 homeassistant/components/yamaha_musiccast/translations/zh-Hans.json create mode 100644 homeassistant/components/yeelight/translations/zh-Hans.json create mode 100644 homeassistant/components/youless/translations/zh-Hans.json diff --git a/homeassistant/components/adax/translations/zh-Hans.json b/homeassistant/components/adax/translations/zh-Hans.json new file mode 100644 index 00000000000..7356ec08b15 --- /dev/null +++ b/homeassistant/components/adax/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/zh-Hans.json b/homeassistant/components/august/translations/zh-Hans.json new file mode 100644 index 00000000000..b932dae2511 --- /dev/null +++ b/homeassistant/components/august/translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_validate": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "user_validate": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/zh-Hans.json b/homeassistant/components/azure_devops/translations/zh-Hans.json index b0c629646e2..d6a6e62e27c 100644 --- a/homeassistant/components/azure_devops/translations/zh-Hans.json +++ b/homeassistant/components/azure_devops/translations/zh-Hans.json @@ -1,8 +1,32 @@ { "config": { + "abort": { + "already_configured": "\u8d26\u6237\u5df2\u88ab\u914d\u7f6e", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f" + }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", - "invalid_auth": "\u9a8c\u8bc1\u7801\u65e0\u6548" + "invalid_auth": "\u9a8c\u8bc1\u7801\u65e0\u6548", + "project_error": "\u65e0\u6cd5\u83b7\u53d6\u9879\u76ee\u4fe1\u606f\u3002" + }, + "flow_title": "{project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c (PAT)" + }, + "description": "{project_url} \u8eab\u4efd\u9a8c\u8bc1\u5931\u8d25\u3002\u8bf7\u8f93\u5165\u60a8\u5f53\u524d\u7684\u51ed\u636e\u3002", + "title": "\u91cd\u9a8c\u8bc1" + }, + "user": { + "data": { + "organization": "\u7ec4\u7ec7", + "personal_access_token": "\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c (PAT)", + "project": "\u9879\u76ee" + }, + "description": "\u8bbe\u7f6e Azure DevOps \u5b9e\u4f8b\u4ee5\u8bbf\u95ee\u60a8\u7684\u9879\u76ee\u3002\u79c1\u4eba\u9879\u76ee\u624d\u9700\u8981\u63d0\u4f9b\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c\u3002", + "title": "\u6dfb\u52a0 Azure DevOps \u9879\u76ee" + } } } } \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/zh-Hans.json b/homeassistant/components/bosch_shc/translations/zh-Hans.json new file mode 100644 index 00000000000..46682f56114 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "pairing_failed": "\u914d\u5bf9\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u535a\u4e16 Smart Home Controller \u662f\u5426\u6b63\u5728\u5904\u4e8e\u914d\u5bf9\u6a21\u5f0f(LED \u706f\u95ea\u70c1)\uff0c\u4ee5\u53ca\u952e\u5165\u7684\u5bc6\u7801\u662f\u5426\u6b63\u786e" + }, + "step": { + "credentials": { + "data": { + "password": "Smart Home Controller \u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/zh-Hans.json b/homeassistant/components/braviatv/translations/zh-Hans.json index c839a271614..d02d562d55d 100644 --- a/homeassistant/components/braviatv/translations/zh-Hans.json +++ b/homeassistant/components/braviatv/translations/zh-Hans.json @@ -4,10 +4,20 @@ "authorize": { "data": { "pin": "PIN \u7801" - } + }, + "description": "\u8f93\u5165\u5728 Sony Bravia \u7535\u89c6\u4e0a\u663e\u793a\u7684 PIN \u7801\u3002 \n\n\u5982\u679c\u672a\u663e\u793a PIN \u7801\uff0c\u60a8\u9700\u8981\u5728\u7535\u89c6\u4e0a\u53d6\u6d88\u6ce8\u518c Home Assistant\uff0c\u8bf7\u8f6c\u5230\uff1a\u8bbe\u7f6e - >\u7f51\u7edc - >\u8fdc\u7a0b\u8bbe\u5907\u8bbe\u7f6e - >\u53d6\u6d88\u6ce8\u518c\u8fdc\u7a0b\u8bbe\u5907\u3002", + "title": "\u6388\u6743 Sony Bravia \u7535\u89c6" }, "user": { - "description": "\u8bbe\u7f6eSony Bravia\u7535\u89c6\u96c6\u6210\u3002\u5982\u679c\u60a8\u5728\u914d\u7f6e\u65b9\u9762\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/braviatv\n\u786e\u4fdd\u7535\u89c6\u5df2\u6253\u5f00\u3002" + "description": "\u8bbe\u7f6e Sony Bravia \u7535\u89c6\u96c6\u6210\u3002\u5982\u679c\u60a8\u5728\u914d\u7f6e\u65b9\u9762\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/braviatv\n\u786e\u4fdd\u7535\u89c6\u5df2\u6253\u5f00\u3002", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "title": "Sony Bravia \u7535\u89c6\u9009\u9879" } } } diff --git a/homeassistant/components/cloudflare/translations/zh-Hans.json b/homeassistant/components/cloudflare/translations/zh-Hans.json index 4b0a696e5fc..78429184bad 100644 --- a/homeassistant/components/cloudflare/translations/zh-Hans.json +++ b/homeassistant/components/cloudflare/translations/zh-Hans.json @@ -5,6 +5,11 @@ "invalid_auth": "\u9a8c\u8bc1\u7801\u65e0\u6548" }, "step": { + "reauth_confirm": { + "data": { + "description": "\u4f7f\u7528\u60a8\u7684 Cloudflare \u5e10\u6237\u91cd\u65b0\u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1\u3002" + } + }, "user": { "data": { "api_token": "API \u5bc6\u7801" diff --git a/homeassistant/components/elgato/translations/zh-Hans.json b/homeassistant/components/elgato/translations/zh-Hans.json index 254f6df9327..94813c444eb 100644 --- a/homeassistant/components/elgato/translations/zh-Hans.json +++ b/homeassistant/components/elgato/translations/zh-Hans.json @@ -1,10 +1,25 @@ { "config": { "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", "cannot_connect": "\u8fde\u63a5\u5931\u8d25" }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "flow_title": "{serial_number}", + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3" + }, + "description": "\u8bbe\u7f6e\u60a8\u7684 Elgato Light \u4ee5\u4e0e Home Assistant \u96c6\u6210\u3002" + }, + "zeroconf_confirm": { + "description": "\u60a8\u60f3\u5c06\u5e8f\u5217\u53f7\u4e3a `{serial_number}` \u7684 Elgato Light \u6dfb\u52a0\u5230 Home Assistant \u5417\uff1f", + "title": "\u53d1\u73b0 Elgato Light \u88c5\u7f6e" + } } } } \ No newline at end of file diff --git a/homeassistant/components/energy/translations/zh-Hans.json b/homeassistant/components/energy/translations/zh-Hans.json new file mode 100644 index 00000000000..bae50fae66e --- /dev/null +++ b/homeassistant/components/energy/translations/zh-Hans.json @@ -0,0 +1,3 @@ +{ + "title": "\u80fd\u6e90" +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/zh-Hans.json b/homeassistant/components/enphase_envoy/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/epson/translations/zh-Hans.json b/homeassistant/components/epson/translations/zh-Hans.json new file mode 100644 index 00000000000..3cb7f97ceb9 --- /dev/null +++ b/homeassistant/components/epson/translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "powered_off": "\u6295\u5f71\u4eea\u662f\u5426\u5df2\u7ecf\u6253\u5f00\uff1f\u60a8\u9700\u8981\u6253\u5f00\u6295\u5f71\u4eea\u4ee5\u8fdb\u884c\u521d\u59cb\u914d\u7f6e\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/es.json b/homeassistant/components/flipr/translations/es.json index 478510ba5f1..56898d19a42 100644 --- a/homeassistant/components/flipr/translations/es.json +++ b/homeassistant/components/flipr/translations/es.json @@ -4,11 +4,17 @@ "unknown": "Error desconocido" }, "step": { + "flipr_id": { + "description": "Elija su ID de Flipr en la lista", + "title": "Elige tu Flipr" + }, "user": { "data": { "email": "Correo-e", "password": "Clave" - } + }, + "description": "Con\u00e9ctese usando su cuenta Flipr.", + "title": "Conectarse a Flipr" } } } diff --git a/homeassistant/components/flipr/translations/zh-Hans.json b/homeassistant/components/flipr/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/flipr/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/zh-Hans.json b/homeassistant/components/flume/translations/zh-Hans.json index a5f4ff11f09..db06c3cf23a 100644 --- a/homeassistant/components/flume/translations/zh-Hans.json +++ b/homeassistant/components/flume/translations/zh-Hans.json @@ -1,6 +1,12 @@ { "config": { "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "{username} \u7684\u5bc6\u7801\u5df2\u5931\u6548\u3002" + }, "user": { "data": { "username": "\u7528\u6237\u540d" diff --git a/homeassistant/components/forked_daapd/translations/zh-Hans.json b/homeassistant/components/forked_daapd/translations/zh-Hans.json new file mode 100644 index 00000000000..9b2bd981397 --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/zh-Hans.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "not_forked_daapd": "\u6b64\u8bbe\u5907\u4e0d\u662f\u4e00\u4e2a forked-daapd \u670d\u52a1\u5668\u3002" + }, + "error": { + "forbidden": "\u65e0\u6cd5\u8fde\u63a5\u3002\u8bf7\u68c0\u67e5\u60a8\u7684 forked-daapd \u7f51\u7edc\u6743\u9650\u3002", + "websocket_not_enabled": "\u672a\u542f\u7528 forked-daapd \u670d\u52a1\u5668\u7684 Websocket \u529f\u80fd\u3002", + "wrong_server_type": "forked-daapd \u96c6\u6210\u9700\u8981 forked-daapd \u670d\u52a1\u5668\u7248\u672c\u53f7\u81f3\u5c11\u5927\u4e8e\u6216\u7b49\u4e8e 27.0 \u3002" + }, + "step": { + "user": { + "title": "\u8bbe\u7f6e forked-daapd \u8bbe\u5907" + } + } + }, + "options": { + "step": { + "init": { + "description": "\u4e3a forked-daapd \u96c6\u6210\u8bbe\u7f6e\u5404\u79cd\u9009\u9879\u3002", + "title": "\u914d\u7f6e forked-daapd" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/zh-Hans.json b/homeassistant/components/fritz/translations/zh-Hans.json new file mode 100644 index 00000000000..91d68989675 --- /dev/null +++ b/homeassistant/components/fritz/translations/zh-Hans.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "confirm": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "start_config": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "user": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "\u914d\u7f6e FRITZ!Box Tool \u4ee5\u63a7\u5236\u60a8\u7684 FRITZ!Box\u3002\n\u6700\u4f4e\u4fe1\u606f\u63d0\u4f9b\u8981\u6c42\uff1a\u7528\u6237\u540d\u3001\u5bc6\u7801\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/zh-Hans.json b/homeassistant/components/growatt_server/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hive/translations/zh-Hans.json b/homeassistant/components/hive/translations/zh-Hans.json new file mode 100644 index 00000000000..780a47cb958 --- /dev/null +++ b/homeassistant/components/hive/translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "invalid_password": "\u65e0\u6cd5\u767b\u5f55 Hive\uff0c\u5bc6\u7801\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5\u3002" + }, + "step": { + "reauth": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/zh-Hans.json b/homeassistant/components/honeywell/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/honeywell/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/translations/zh-Hans.json b/homeassistant/components/ifttt/translations/zh-Hans.json index c9e8bfd6044..78cbc37a7d9 100644 --- a/homeassistant/components/ifttt/translations/zh-Hans.json +++ b/homeassistant/components/ifttt/translations/zh-Hans.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "single_instance_allowed": "\u5b9e\u4f8b\u5df2\u914d\u7f6e\uff0c\u4e14\u53ea\u80fd\u5b58\u5728\u5355\u4e2a\u914d\u7f6e\u3002", + "webhook_not_internet_accessible": "Home Assistant \u9700\u8981\u7f51\u7edc\u8fde\u63a5\u4ee5\u83b7\u53d6\u76f8\u5173\u63a8\u9001\u4fe1\u606f\u3002" + }, "create_entry": { "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u4f7f\u7528 [IFTTT Webhook applet]({applet_url}) \u4e2d\u7684 \"Make a web request\" \u52a8\u4f5c\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002" }, diff --git a/homeassistant/components/kmtronic/translations/zh-Hans.json b/homeassistant/components/kmtronic/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/kmtronic/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kostal_plenticore/translations/zh-Hans.json b/homeassistant/components/kostal_plenticore/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/kostal_plenticore/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/es-419.json b/homeassistant/components/kraken/translations/es-419.json new file mode 100644 index 00000000000..106ff98de0d --- /dev/null +++ b/homeassistant/components/kraken/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "one": "", + "other": "Otros" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/es.json b/homeassistant/components/kraken/translations/es.json index afcf3f92d45..1befa14a52b 100644 --- a/homeassistant/components/kraken/translations/es.json +++ b/homeassistant/components/kraken/translations/es.json @@ -1,4 +1,14 @@ { + "config": { + "step": { + "user": { + "data": { + "one": "", + "other": "Otros" + } + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/litejet/translations/es.json b/homeassistant/components/litejet/translations/es.json index 32d39e995e1..41875da9e69 100644 --- a/homeassistant/components/litejet/translations/es.json +++ b/homeassistant/components/litejet/translations/es.json @@ -15,5 +15,15 @@ "title": "Conectarse a LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Transici\u00f3n predeterminada (segundos)" + }, + "title": "Configurar LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/zh-Hans.json b/homeassistant/components/litejet/translations/zh-Hans.json new file mode 100644 index 00000000000..133385be2d3 --- /dev/null +++ b/homeassistant/components/litejet/translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "options": { + "step": { + "init": { + "title": "\u914d\u7f6e LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/zh-Hans.json b/homeassistant/components/litterrobot/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/myq/translations/zh-Hans.json b/homeassistant/components/myq/translations/zh-Hans.json index a5f4ff11f09..db06c3cf23a 100644 --- a/homeassistant/components/myq/translations/zh-Hans.json +++ b/homeassistant/components/myq/translations/zh-Hans.json @@ -1,6 +1,12 @@ { "config": { "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "{username} \u7684\u5bc6\u7801\u5df2\u5931\u6548\u3002" + }, "user": { "data": { "username": "\u7528\u6237\u540d" diff --git a/homeassistant/components/nut/translations/zh-Hans.json b/homeassistant/components/nut/translations/zh-Hans.json index 91522c7f609..4afd1ff0031 100644 --- a/homeassistant/components/nut/translations/zh-Hans.json +++ b/homeassistant/components/nut/translations/zh-Hans.json @@ -1,15 +1,34 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, "step": { "resources": { "data": { "resources": "\u8d44\u6e90" - } + }, + "title": "\u9009\u62e9\u8981\u76d1\u89c6\u7684\u8d44\u6e90" + }, + "ups": { + "data": { + "alias": "\u522b\u540d", + "resources": "\u8d44\u6e90" + }, + "title": "\u9009\u62e9\u8981\u76d1\u63a7\u7684 UPS" }, "user": { "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", "username": "\u7528\u6237\u540d" - } + }, + "title": "\u8fde\u63a5\u5230 NUT \u670d\u52a1\u5668" } } }, @@ -17,6 +36,15 @@ "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", "unknown": "\u4e0d\u5728\u9884\u671f\u5185\u7684\u9519\u8bef" + }, + "step": { + "init": { + "data": { + "resources": "\u8d44\u6e90", + "scan_interval": "\u626b\u63cf\u95f4\u9694\uff08\u79d2\uff09" + }, + "description": "\u9009\u62e9\u8981\u76d1\u89c6\u7684\u8d44\u6e90" + } } } } \ No newline at end of file diff --git a/homeassistant/components/onvif/translations/zh-Hans.json b/homeassistant/components/onvif/translations/zh-Hans.json index 13dd993228e..8ebde5a1bda 100644 --- a/homeassistant/components/onvif/translations/zh-Hans.json +++ b/homeassistant/components/onvif/translations/zh-Hans.json @@ -62,7 +62,7 @@ "onvif_devices": { "data": { "extra_arguments": "\u9644\u52a0 FFmpeg \u53c2\u6570", - "rtsp_transport": "RTSP \u4f20\u8f93" + "rtsp_transport": "RTSP \u4f20\u8f93\u901a\u8baf\u534f\u8bae" }, "title": "ONVIF \u8bbe\u5907\u9009\u9879" } diff --git a/homeassistant/components/ovo_energy/translations/zh-Hans.json b/homeassistant/components/ovo_energy/translations/zh-Hans.json index a7477e8c370..cf7a799531d 100644 --- a/homeassistant/components/ovo_energy/translations/zh-Hans.json +++ b/homeassistant/components/ovo_energy/translations/zh-Hans.json @@ -4,9 +4,16 @@ "cannot_connect": "\u8fde\u63a5\u5931\u8d25", "invalid_auth": "\u9a8c\u8bc1\u7801\u9519\u8bef" }, + "flow_title": "{username}", "step": { + "reauth": { + "data": { + "password": "\u5bc6\u7801" + } + }, "user": { "data": { + "password": "\u5bc6\u7801", "username": "\u7528\u6237\u540d" } } diff --git a/homeassistant/components/prosegur/translations/zh-Hans.json b/homeassistant/components/prosegur/translations/zh-Hans.json new file mode 100644 index 00000000000..426e1f2919b --- /dev/null +++ b/homeassistant/components/prosegur/translations/zh-Hans.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u6210\u529f", + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "\u4f7f\u7528 Prosegur \u5e10\u6237\u91cd\u65b0\u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1\u3002", + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + } + }, + "user": { + "data": { + "country": "\u56fd\u5bb6/\u5730\u533a", + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/es-419.json b/homeassistant/components/renault/translations/es-419.json new file mode 100644 index 00000000000..6c895416ef8 --- /dev/null +++ b/homeassistant/components/renault/translations/es-419.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "Establecer las credenciales de Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/es.json b/homeassistant/components/renault/translations/es.json index 894226d361e..0eabcacccd3 100644 --- a/homeassistant/components/renault/translations/es.json +++ b/homeassistant/components/renault/translations/es.json @@ -1,18 +1,26 @@ { "config": { "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada" + "already_configured": "La cuenta ya est\u00e1 configurada", + "kamereon_no_account": "No se pudo encontrar la cuenta de Kamereon." }, "error": { "invalid_credentials": "Autenticaci\u00f3n err\u00f3nea" }, "step": { + "kamereon": { + "data": { + "kamereon_account_id": "ID de cuenta de Kamereon" + }, + "title": "Seleccione el id de la cuenta de Kamereon" + }, "user": { "data": { "locale": "Configuraci\u00f3n regional", "password": "Clave", "username": "Correo-e" - } + }, + "title": "Establecer las credenciales de Renault" } } } diff --git a/homeassistant/components/renault/translations/zh-Hans.json b/homeassistant/components/renault/translations/zh-Hans.json index b081f64a961..ab8c60ed030 100644 --- a/homeassistant/components/renault/translations/zh-Hans.json +++ b/homeassistant/components/renault/translations/zh-Hans.json @@ -16,6 +16,7 @@ }, "user": { "data": { + "locale": "\u5730\u533a", "password": "\u5bc6\u7801", "username": "\u7535\u5b50\u90ae\u7bb1" }, diff --git a/homeassistant/components/rituals_perfume_genie/translations/zh-Hans.json b/homeassistant/components/rituals_perfume_genie/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roomba/translations/zh-Hans.json b/homeassistant/components/roomba/translations/zh-Hans.json new file mode 100644 index 00000000000..7674a49c492 --- /dev/null +++ b/homeassistant/components/roomba/translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "not_irobot_device": "\u5df2\u53d1\u73b0\u7684\u8bbe\u5907\u5e76\u4e0d\u662f iRobot \u8bbe\u5907" + }, + "step": { + "manual": { + "description": "\u672a\u5728\u60a8\u7684\u7f51\u7edc\u4e0a\u53d1\u73b0 Roomba \u6216 Braava\u3002 BLID \u662f\u8bbe\u5907\u4e3b\u673a\u540d\u4e2d \u201ciRobot-\u201d \u6216 \u201cRoomba-\u201d \u4e4b\u540e\u7684\u90e8\u5206\u3002\u8bf7\u6309\u7167\u6587\u6863\u4e2d\u6982\u8ff0\u7684\u6b65\u9aa4\u64cd\u4f5c\uff1a {auth_help_url}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/translations/en.json b/homeassistant/components/sensor/translations/en.json index f8f45f93309..69737c7c93a 100644 --- a/homeassistant/components/sensor/translations/en.json +++ b/homeassistant/components/sensor/translations/en.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", "is_current": "Current {entity_name} current", "is_energy": "Current {entity_name} energy", + "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", "is_illuminance": "Current {entity_name} illuminance", "is_power": "Current {entity_name} power", @@ -22,6 +23,7 @@ "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "current": "{entity_name} current changes", "energy": "{entity_name} energy changes", + "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", "illuminance": "{entity_name} illuminance changes", "power": "{entity_name} power changes", diff --git a/homeassistant/components/sensor/translations/et.json b/homeassistant/components/sensor/translations/et.json index 4169e7b82db..839f505f6aa 100644 --- a/homeassistant/components/sensor/translations/et.json +++ b/homeassistant/components/sensor/translations/et.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "{entity_name} praegune vingugaasi tase", "is_current": "Praegune {entity_name} voolutugevus", "is_energy": "Praegune {entity_name} v\u00f5imsus", + "is_gas": "Praegune {entity_name} gaas", "is_humidity": "Praegune {entity_name} niiskus", "is_illuminance": "Praegune {entity_name} valgustatus", "is_power": "Praegune {entity_name} toide (v\u00f5imsus)", @@ -22,6 +23,7 @@ "carbon_monoxide": "{entity_name} vingugaasi tase muutus", "current": "{entity_name} voolutugevus muutub", "energy": "{entity_name} v\u00f5imsus muutub", + "gas": "{entity_name} gaasivahetus", "humidity": "{entity_name} niiskus muutub", "illuminance": "{entity_name} valgustustugevus muutub", "power": "{entity_name} energiare\u017eiimi muutub", diff --git a/homeassistant/components/sensor/translations/zh-Hant.json b/homeassistant/components/sensor/translations/zh-Hant.json index a15af383da6..b22ba82f3a4 100644 --- a/homeassistant/components/sensor/translations/zh-Hant.json +++ b/homeassistant/components/sensor/translations/zh-Hant.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "\u76ee\u524d {entity_name} \u4e00\u6c27\u5316\u78b3\u6fc3\u5ea6\u72c0\u614b", "is_current": "\u76ee\u524d{entity_name}\u96fb\u6d41", "is_energy": "\u76ee\u524d{entity_name}\u96fb\u529b", + "is_gas": "\u76ee\u524d{entity_name}\u6c23\u9ad4", "is_humidity": "\u76ee\u524d{entity_name}\u6fd5\u5ea6", "is_illuminance": "\u76ee\u524d{entity_name}\u7167\u5ea6", "is_power": "\u76ee\u524d{entity_name}\u96fb\u529b", @@ -22,6 +23,7 @@ "carbon_monoxide": "{entity_name} \u4e00\u6c27\u5316\u78b3\u6fc3\u5ea6\u8b8a\u5316", "current": "\u76ee\u524d{entity_name}\u96fb\u6d41\u8b8a\u66f4", "energy": "\u76ee\u524d{entity_name}\u96fb\u529b\u8b8a\u66f4", + "gas": "{entity_name}\u6c23\u9ad4\u8b8a\u66f4", "humidity": "{entity_name}\u6fd5\u5ea6\u8b8a\u66f4", "illuminance": "{entity_name}\u7167\u5ea6\u8b8a\u66f4", "power": "{entity_name}\u96fb\u529b\u8b8a\u66f4", diff --git a/homeassistant/components/shopping_list/translations/zh-Hans.json b/homeassistant/components/shopping_list/translations/zh-Hans.json new file mode 100644 index 00000000000..fa498b4ff60 --- /dev/null +++ b/homeassistant/components/shopping_list/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u914d\u7f6e" + }, + "step": { + "user": { + "description": "\u60a8\u8981\u914d\u7f6e\u8d2d\u7269\u6e05\u5355\u5417\uff1f", + "title": "\u8d2d\u7269\u6e05\u5355" + } + } + }, + "title": "\u8d2d\u7269\u6e05\u5355" +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/translations/zh-Hans.json b/homeassistant/components/smartthings/translations/zh-Hans.json index 849d69d55e5..3db5d7f0354 100644 --- a/homeassistant/components/smartthings/translations/zh-Hans.json +++ b/homeassistant/components/smartthings/translations/zh-Hans.json @@ -8,6 +8,11 @@ "webhook_error": "SmartThings \u65e0\u6cd5\u9a8c\u8bc1 `base_url` \u4e2d\u914d\u7f6e\u7684\u7aef\u70b9\u3002\u8bf7\u67e5\u770b\u7ec4\u4ef6\u9700\u6c42\u3002" }, "step": { + "pat": { + "data": { + "access_token": "\u8bbf\u95ee\u4ee4\u724c" + } + }, "user": { "description": "\u8bf7\u8f93\u5165\u6309\u7167[\u8bf4\u660e]({component_url})\u521b\u5efa\u7684 SmartThings [\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c]({token_url})\u3002", "title": "\u8f93\u5165\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c" diff --git a/homeassistant/components/smarttub/translations/zh-Hans.json b/homeassistant/components/smarttub/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/smarttub/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/soma/translations/zh-Hans.json b/homeassistant/components/soma/translations/zh-Hans.json new file mode 100644 index 00000000000..51fbc254b7f --- /dev/null +++ b/homeassistant/components/soma/translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "connection_error": "\u65e0\u6cd5\u8fde\u63a5 SOMA Connect\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sonarr/translations/zh-Hans.json b/homeassistant/components/sonarr/translations/zh-Hans.json new file mode 100644 index 00000000000..265928213f5 --- /dev/null +++ b/homeassistant/components/sonarr/translations/zh-Hans.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548" + }, + "flow_title": "{name}", + "step": { + "reauth_confirm": { + "description": "Sonarr \u96c6\u6210\u9700\u8981\u624b\u52a8\u91cd\u65b0\u9a8c\u8bc1\uff1a{host}" + }, + "user": { + "data": { + "api_key": "API \u5bc6\u94a5", + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u8bc1\u4e66", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "\u663e\u793a\u5373\u5c06\u5185\u5bb9\u7684\u5929\u6570", + "wanted_max_items": "\u5185\u5bb9\u663e\u793a\u6700\u5927\u6570\u91cf" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/srp_energy/translations/zh-Hans.json b/homeassistant/components/srp_energy/translations/zh-Hans.json new file mode 100644 index 00000000000..36016f3e217 --- /dev/null +++ b/homeassistant/components/srp_energy/translations/zh-Hans.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + } + } + } + }, + "title": "SRP Energy" +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/zh-Hans.json b/homeassistant/components/subaru/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/subaru/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/es.json b/homeassistant/components/tesla/translations/es.json index 54fbfd1a21d..8211e806741 100644 --- a/homeassistant/components/tesla/translations/es.json +++ b/homeassistant/components/tesla/translations/es.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "C\u00f3digo MFA (opcional)", "password": "Contrase\u00f1a", "username": "Correo electr\u00f3nico" }, diff --git a/homeassistant/components/tesla/translations/zh-Hans.json b/homeassistant/components/tesla/translations/zh-Hans.json new file mode 100644 index 00000000000..35635ce3be3 --- /dev/null +++ b/homeassistant/components/tesla/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mfa": "MFA \u4ee3\u7801\uff08\u53ef\u9009\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/es-419.json b/homeassistant/components/uptimerobot/translations/es-419.json new file mode 100644 index 00000000000..445247107c0 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/es-419.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/es.json b/homeassistant/components/uptimerobot/translations/es.json index 1f88050745d..d3c7f2b036d 100644 --- a/homeassistant/components/uptimerobot/translations/es.json +++ b/homeassistant/components/uptimerobot/translations/es.json @@ -2,18 +2,29 @@ "config": { "abort": { "already_configured": "La cuenta ya est\u00e1 configurada", + "reauth_failed_existing": "No se pudo actualizar la entrada de configuraci\u00f3n, elimine la integraci\u00f3n y config\u00farela nuevamente.", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa", "unknown": "Error desconocido" }, "error": { "cannot_connect": "No se pudo conectar", "invalid_api_key": "Clave de la API err\u00f3nea", + "reauth_failed_matching_account": "La clave de API que proporcion\u00f3 no coincide con el ID de cuenta para la configuraci\u00f3n existente.", "unknown": "Error desconocido" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key" + }, + "description": "Debe proporcionar una nueva clave API de solo lectura de Uptime Robot", + "title": "Volver a autenticar la integraci\u00f3n" + }, "user": { "data": { "api_key": "Clave de la API" - } + }, + "description": "Debe proporcionar una clave API de solo lectura de robot de tiempo de actividad/funcionamiento" } } } diff --git a/homeassistant/components/uptimerobot/translations/zh-Hans.json b/homeassistant/components/uptimerobot/translations/zh-Hans.json index 92106b06ce2..d680c09e967 100644 --- a/homeassistant/components/uptimerobot/translations/zh-Hans.json +++ b/homeassistant/components/uptimerobot/translations/zh-Hans.json @@ -2,18 +2,28 @@ "config": { "abort": { "already_configured": "\u6b64\u8d26\u53f7\u5df2\u88ab\u914d\u7f6e", + "reauth_failed_existing": "\u65e0\u6cd5\u66f4\u65b0\u914d\u7f6e\u6761\u76ee\uff0c\u8bf7\u5220\u9664\u96c6\u6210\u5e76\u91cd\u65b0\u8bbe\u7f6e\u3002", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f", "unknown": "\u672a\u77e5\u9519\u8bef" }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", "invalid_api_key": "\u65e0\u6548\u7684 API \u5bc6\u94a5", + "reauth_failed_matching_account": "\u60a8\u63d0\u4f9b\u7684 API \u5bc6\u94a5\u4e0e\u73b0\u6709\u914d\u7f6e\u7684\u8d26\u53f7 ID \u4e0d\u5339\u914d", "unknown": "\u672a\u77e5\u9519\u8bef" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u5bc6\u94a5" + }, + "description": "\u60a8\u9700\u8981\u4ece Uptime Robot \u4e2d\u63d0\u4f9b\u4e00\u4e2a\"\u53ea\u8bfb API \u5bc6\u94a5\"" + }, "user": { "data": { "api_key": "API \u5bc6\u94a5" - } + }, + "description": "\u60a8\u9700\u8981\u4ece Uptime Robot \u4e2d\u63d0\u4f9b\u4e00\u4e2a\"\u53ea\u8bfb API \u5bc6\u94a5\"" } } } diff --git a/homeassistant/components/verisure/translations/zh-Hans.json b/homeassistant/components/verisure/translations/zh-Hans.json new file mode 100644 index 00000000000..e786edb1405 --- /dev/null +++ b/homeassistant/components/verisure/translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wallbox/translations/zh-Hans.json b/homeassistant/components/wallbox/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/wallbox/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/zh-Hans.json b/homeassistant/components/xiaomi_miio/translations/zh-Hans.json index 0034f73fbf3..c3eb4affc4c 100644 --- a/homeassistant/components/xiaomi_miio/translations/zh-Hans.json +++ b/homeassistant/components/xiaomi_miio/translations/zh-Hans.json @@ -56,7 +56,8 @@ "data": { "host": "IP \u5730\u5740", "token": "API Token" - } + }, + "description": "\u60a8\u9700\u8981\u83b7\u53d6\u4e00\u4e2a 32 \u4f4d\u7684 API Token\uff0c\u8bf7\u53c2\u8003 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u4e2d\u63d0\u5230\u7684\u65b9\u6cd5\u83b7\u53d6\u8be5\u4fe1\u606f\u3002\u8bf7\u6ce8\u610f\uff0c\u8be5 API Token \u4e0d\u540c\u4e8e \"Xiaomi Aqara\" \u96c6\u6210\u6240\u4f7f\u7528\u7684\u5bc6\u94a5\u3002" }, "reauth_confirm": { "description": "\u5c0f\u7c73 Miio \u96c6\u6210\u9700\u8981\u91cd\u65b0\u9a8c\u8bc1\u60a8\u7684\u5e10\u6237\uff0c\u4ee5\u4fbf\u66f4\u65b0 token \u6216\u6dfb\u52a0\u4e22\u5931\u7684\u4e91\u7aef\u51ed\u636e\u3002" diff --git a/homeassistant/components/yale_smart_alarm/translations/es-419.json b/homeassistant/components/yale_smart_alarm/translations/es-419.json new file mode 100644 index 00000000000..f3cbae5ed03 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/es-419.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID de \u00c1rea" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/zh-Hans.json b/homeassistant/components/yale_smart_alarm/translations/zh-Hans.json new file mode 100644 index 00000000000..2afd7efdb15 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/zh-Hans.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u8d26\u53f7\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "\u533a\u57df ID", + "name": "\u540d\u79f0", + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + } + }, + "user": { + "data": { + "area_id": "\u533a\u57df ID", + "name": "\u540d\u79f0", + "password": "\u5bc6\u7801", + "username": "\u8d26\u53f7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/zh-Hans.json b/homeassistant/components/yamaha_musiccast/translations/zh-Hans.json new file mode 100644 index 00000000000..f69ab4546d5 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "description": "\u8bbe\u7f6e MusicCast \u4ee5\u4e0e Home Assistant \u96c6\u6210\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yeelight/translations/zh-Hans.json b/homeassistant/components/yeelight/translations/zh-Hans.json new file mode 100644 index 00000000000..43fb1d9fe25 --- /dev/null +++ b/homeassistant/components/yeelight/translations/zh-Hans.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "no_devices_found": "\u60a8\u7684\u7f51\u7edc\u672a\u53d1\u73b0 Yeelight \u8bbe\u5907" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "flow_title": "{model} {host}", + "step": { + "discovery_confirm": { + "description": "\u60a8\u8981\u8bbe\u7f6e {model} ( {host} )\u5417\uff1f" + }, + "pick_device": { + "data": { + "device": "\u8bbe\u5907" + } + }, + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740" + }, + "description": "\u5982\u679c\u60a8\u5c06\u4e3b\u673a\u5730\u5740\u680f\u7559\u7a7a\uff0c\u5219\u5c06\u81ea\u52a8\u5bfb\u627e\u8bbe\u5907\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "model": "\u578b\u53f7\uff08\u53ef\u9009\uff09", + "nightlight_switch": "\u4f7f\u7528\u591c\u5149\u5f00\u5173", + "save_on_change": "\u4fdd\u5b58\u66f4\u6539\u72b6\u6001", + "transition": "\u8fc7\u6e21\u65f6\u95f4\uff08\u6beb\u79d2\uff09", + "use_music_mode": "\u542f\u7528\u97f3\u4e50\u6a21\u5f0f" + }, + "description": "\u5982\u679c\u5c06\u4fe1\u53f7\u680f\u7559\u7a7a\uff0c\u96c6\u6210\u5c06\u4f1a\u81ea\u52a8\u68c0\u6d4b\u76f8\u5173\u4fe1\u606f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/zh-Hans.json b/homeassistant/components/youless/translations/zh-Hans.json new file mode 100644 index 00000000000..cfe90f18df3 --- /dev/null +++ b/homeassistant/components/youless/translations/zh-Hans.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zoneminder/translations/zh-Hans.json b/homeassistant/components/zoneminder/translations/zh-Hans.json index a5f4ff11f09..8f3265d4344 100644 --- a/homeassistant/components/zoneminder/translations/zh-Hans.json +++ b/homeassistant/components/zoneminder/translations/zh-Hans.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u81f3 ZoneMinder \u670d\u52a1\u5668\u3002" + }, + "error": { + "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u81f3 ZoneMinder \u670d\u52a1\u5668\u3002" + }, "step": { "user": { "data": { From c040be423a20d4e21ba068c0bb59695509caaf48 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Wed, 11 Aug 2021 21:17:25 -0600 Subject: [PATCH 346/903] Updates to bump MyQ to 3.1.2 (#54488) --- .coveragerc | 1 + homeassistant/components/myq/config_flow.py | 2 +- homeassistant/components/myq/cover.py | 8 ++++++-- homeassistant/components/myq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.coveragerc b/.coveragerc index bf51d5ad594..3795f7e49b8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -671,6 +671,7 @@ omit = homeassistant/components/mystrom/light.py homeassistant/components/mystrom/switch.py homeassistant/components/myq/__init__.py + homeassistant/components/myq/cover.py homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/light.py homeassistant/components/neato/__init__.py diff --git a/homeassistant/components/myq/config_flow.py b/homeassistant/components/myq/config_flow.py index 78a751a18b1..8c088de6715 100644 --- a/homeassistant/components/myq/config_flow.py +++ b/homeassistant/components/myq/config_flow.py @@ -31,7 +31,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Validate the user input allows us to connect.""" websession = aiohttp_client.async_get_clientsession(self.hass) try: - await pymyq.login(username, password, websession) + await pymyq.login(username, password, websession, True) except InvalidCredentialsError: return {CONF_PASSWORD: "invalid_auth"} except MyQError: diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index 3d587635f2d..8d36db8e0ab 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -115,7 +115,9 @@ class MyQDevice(CoordinatorEntity, CoverEntity): # Write closing state to HASS self.async_write_ha_state() - if not await wait_task: + result = wait_task if isinstance(wait_task, bool) else await wait_task + + if not result: _LOGGER.error("Closing of cover %s failed", self._device.name) # Write final state to HASS @@ -137,7 +139,9 @@ class MyQDevice(CoordinatorEntity, CoverEntity): # Write opening state to HASS self.async_write_ha_state() - if not await wait_task: + result = wait_task if isinstance(wait_task, bool) else await wait_task + + if not result: _LOGGER.error("Opening of cover %s failed", self._device.name) # Write final state to HASS diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index a93501c941f..a4de12290f1 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -2,7 +2,7 @@ "domain": "myq", "name": "MyQ", "documentation": "https://www.home-assistant.io/integrations/myq", - "requirements": ["pymyq==3.0.4"], + "requirements": ["pymyq==3.1.2"], "codeowners": ["@bdraco"], "config_flow": true, "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index bbf9e8344f2..76a9b4f7543 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1610,7 +1610,7 @@ pymonoprice==0.3 pymsteams==0.1.12 # homeassistant.components.myq -pymyq==3.0.4 +pymyq==3.1.2 # homeassistant.components.mysensors pymysensors==0.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf1c915d751..988c2bfb2c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -920,7 +920,7 @@ pymodbus==2.5.3rc1 pymonoprice==0.3 # homeassistant.components.myq -pymyq==3.0.4 +pymyq==3.1.2 # homeassistant.components.mysensors pymysensors==0.21.0 From 49a69d5ba093c3ad6d95667632e866b44f9f8adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 12 Aug 2021 07:30:35 +0200 Subject: [PATCH 347/903] Add missing PRESSURE_BAR conversion (#54497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add missing PRESSURE_BAR * style Signed-off-by: Daniel Hjelseth Høyer * valid units Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/util/pressure.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py index 188cf66491e..95b32a69643 100644 --- a/homeassistant/util/pressure.py +++ b/homeassistant/util/pressure.py @@ -5,6 +5,7 @@ from numbers import Number from homeassistant.const import ( PRESSURE, + PRESSURE_BAR, PRESSURE_HPA, PRESSURE_INHG, PRESSURE_MBAR, @@ -16,6 +17,7 @@ from homeassistant.const import ( VALID_UNITS: tuple[str, ...] = ( PRESSURE_PA, PRESSURE_HPA, + PRESSURE_BAR, PRESSURE_MBAR, PRESSURE_INHG, PRESSURE_PSI, @@ -24,6 +26,7 @@ VALID_UNITS: tuple[str, ...] = ( UNIT_CONVERSION: dict[str, float] = { PRESSURE_PA: 1, PRESSURE_HPA: 1 / 100, + PRESSURE_BAR: 1 / 100000, PRESSURE_MBAR: 1 / 100, PRESSURE_INHG: 1 / 3386.389, PRESSURE_PSI: 1 / 6894.757, From e55868b17f22c089ed38fec7748b279dddefc41e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 12 Aug 2021 12:38:33 +0200 Subject: [PATCH 348/903] Use entity class attributes for Adax (#54501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adax attributes Signed-off-by: Daniel Hjelseth Høyer * Adax attributes Signed-off-by: Daniel Hjelseth Høyer * Update homeassistant/components/adax/climate.py Co-authored-by: Joakim Sørensen * style Signed-off-by: Daniel Hjelseth Høyer Co-authored-by: Joakim Sørensen --- homeassistant/components/adax/climate.py | 42 +++++------------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 74e973ba6d5..1abd83fdbfc 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -49,20 +49,19 @@ async def async_setup_entry( class AdaxDevice(ClimateEntity): """Representation of a heater.""" + _attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + _attr_max_temp = 35 + _attr_min_temp = 5 + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = TEMP_CELSIUS + def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: """Initialize the heater.""" self._heater_data = heater_data self._adax_data_handler = adax_data_handler - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_TARGET_TEMPERATURE - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._heater_data['homeId']}_{self._heater_data['id']}" + self._attr_unique_id = f"{heater_data['homeId']}_{heater_data['id']}" @property def name(self) -> str: @@ -83,11 +82,6 @@ class AdaxDevice(ClimateEntity): return "mdi:radiator" return "mdi:radiator-off" - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return [HVAC_MODE_HEAT, HVAC_MODE_OFF] - async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set hvac mode.""" if hvac_mode == HVAC_MODE_HEAT: @@ -105,21 +99,6 @@ class AdaxDevice(ClimateEntity): return await self._adax_data_handler.update() - @property - def temperature_unit(self) -> str: - """Return the unit of measurement which this device uses.""" - return TEMP_CELSIUS - - @property - def min_temp(self) -> int: - """Return the minimum temperature.""" - return 5 - - @property - def max_temp(self) -> int: - """Return the maximum temperature.""" - return 35 - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -130,11 +109,6 @@ class AdaxDevice(ClimateEntity): """Return the temperature we try to reach.""" return self._heater_data.get("targetTemperature") - @property - def target_temperature_step(self) -> int: - """Return the supported step of target temperature.""" - return PRECISION_WHOLE - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) From 103e21c278300d328b1b5bbc78d9d6fd77908b6c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 12 Aug 2021 13:26:17 +0200 Subject: [PATCH 349/903] Move temperature conversions to sensor base class (5/8) (#54475) --- homeassistant/components/nam/const.py | 38 ++--- homeassistant/components/nam/sensor.py | 4 +- homeassistant/components/neato/sensor.py | 4 +- .../nederlandse_spoorwegen/sensor.py | 2 +- .../components/nest/legacy/sensor.py | 8 +- homeassistant/components/nest/sensor_sdm.py | 8 +- homeassistant/components/netatmo/sensor.py | 54 +++---- homeassistant/components/netdata/sensor.py | 6 +- .../components/netgear_lte/sensor.py | 10 +- .../components/neurio_energy/sensor.py | 4 +- homeassistant/components/nexia/sensor.py | 8 +- homeassistant/components/nextbus/sensor.py | 2 +- homeassistant/components/nextcloud/sensor.py | 2 +- homeassistant/components/nightscout/sensor.py | 4 +- .../components/nissan_leaf/sensor.py | 8 +- homeassistant/components/nmbs/sensor.py | 6 +- homeassistant/components/noaa_tides/sensor.py | 2 +- homeassistant/components/notion/sensor.py | 4 +- .../components/nsw_fuel_station/sensor.py | 4 +- homeassistant/components/numato/sensor.py | 4 +- homeassistant/components/nut/const.py | 138 +++++++++--------- homeassistant/components/nut/sensor.py | 2 +- homeassistant/components/nws/const.py | 22 +-- homeassistant/components/nws/sensor.py | 6 +- homeassistant/components/nzbget/sensor.py | 4 +- .../components/oasa_telematics/sensor.py | 2 +- homeassistant/components/obihai/sensor.py | 2 +- homeassistant/components/octoprint/sensor.py | 4 +- homeassistant/components/ohmconnect/sensor.py | 2 +- homeassistant/components/ombi/sensor.py | 2 +- homeassistant/components/omnilogic/sensor.py | 14 +- homeassistant/components/ondilo_ico/sensor.py | 16 +- homeassistant/components/onewire/sensor.py | 6 +- homeassistant/components/onvif/sensor.py | 4 +- homeassistant/components/openerz/sensor.py | 2 +- homeassistant/components/openevse/sensor.py | 4 +- .../components/openexchangerates/sensor.py | 2 +- .../components/openhardwaremonitor/sensor.py | 4 +- homeassistant/components/opensky/sensor.py | 4 +- .../components/opentherm_gw/sensor.py | 4 +- homeassistant/components/openuv/sensor.py | 20 +-- .../openweathermap/abstract_owm_sensor.py | 2 +- .../components/openweathermap/sensor.py | 4 +- homeassistant/components/oru/sensor.py | 4 +- homeassistant/components/otp/sensor.py | 2 +- homeassistant/components/ovo_energy/sensor.py | 10 +- homeassistant/components/ozw/sensor.py | 10 +- homeassistant/components/pi_hole/const.py | 18 +-- homeassistant/components/pi_hole/sensor.py | 2 +- homeassistant/components/picnic/sensor.py | 7 +- homeassistant/components/pilight/sensor.py | 4 +- homeassistant/components/plaato/sensor.py | 4 +- homeassistant/components/plex/sensor.py | 8 +- homeassistant/components/plugwise/sensor.py | 4 +- .../components/pocketcasts/sensor.py | 2 +- homeassistant/components/point/sensor.py | 4 +- homeassistant/components/poolsense/sensor.py | 4 +- homeassistant/components/powerwall/sensor.py | 8 +- homeassistant/components/pushbullet/sensor.py | 2 +- homeassistant/components/pvoutput/sensor.py | 4 +- .../components/pvpc_hourly_pricing/sensor.py | 2 +- homeassistant/components/pyload/sensor.py | 4 +- .../components/qbittorrent/sensor.py | 4 +- homeassistant/components/qnap/sensor.py | 14 +- homeassistant/components/qwikswitch/sensor.py | 4 +- 65 files changed, 289 insertions(+), 288 deletions(-) diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py index 85472deba06..a9d044f2c1d 100644 --- a/homeassistant/components/nam/const.py +++ b/homeassistant/components/nam/const.py @@ -65,133 +65,133 @@ SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( key=ATTR_BME280_HUMIDITY, name=f"{DEFAULT_NAME} BME280 Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BME280_PRESSURE, name=f"{DEFAULT_NAME} BME280 Pressure", - unit_of_measurement=PRESSURE_HPA, + native_unit_of_measurement=PRESSURE_HPA, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BME280_TEMPERATURE, name=f"{DEFAULT_NAME} BME280 Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BMP280_PRESSURE, name=f"{DEFAULT_NAME} BMP280 Pressure", - unit_of_measurement=PRESSURE_HPA, + native_unit_of_measurement=PRESSURE_HPA, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BMP280_TEMPERATURE, name=f"{DEFAULT_NAME} BMP280 Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_HECA_HUMIDITY, name=f"{DEFAULT_NAME} HECA Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_HECA_TEMPERATURE, name=f"{DEFAULT_NAME} HECA Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_MHZ14A_CARBON_DIOXIDE, name=f"{DEFAULT_NAME} MH-Z14A Carbon Dioxide", - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=DEVICE_CLASS_CO2, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SDS011_P1, name=f"{DEFAULT_NAME} SDS011 Particulate Matter 10", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, icon="mdi:blur", state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SDS011_P2, name=f"{DEFAULT_NAME} SDS011 Particulate Matter 2.5", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, icon="mdi:blur", state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SHT3X_HUMIDITY, name=f"{DEFAULT_NAME} SHT3X Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SHT3X_TEMPERATURE, name=f"{DEFAULT_NAME} SHT3X Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P0, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 1.0", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, icon="mdi:blur", state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P1, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 10", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, icon="mdi:blur", state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P2, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 2.5", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, icon="mdi:blur", state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P4, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 4.0", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, icon="mdi:blur", state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_DHT22_HUMIDITY, name=f"{DEFAULT_NAME} DHT22 Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_DHT22_TEMPERATURE, name=f"{DEFAULT_NAME} DHT22 Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SIGNAL_STRENGTH, name=f"{DEFAULT_NAME} Signal Strength", - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 298f88d5c29..c5c9c9f2e77 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -75,7 +75,7 @@ class NAMSensor(CoordinatorEntity, SensorEntity): self.entity_description = description @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" return cast( StateType, getattr(self.coordinator.data, self.entity_description.key) @@ -99,7 +99,7 @@ class NAMSensorUptime(NAMSensor): """Define an Nettigo Air Monitor uptime sensor.""" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state.""" uptime_sec = getattr(self.coordinator.data, self.entity_description.key) return ( diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 1cf10112b92..2d54e89bb04 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -89,14 +89,14 @@ class NeatoSensor(SensorEntity): return self._available @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state.""" if self._state is not None: return str(self._state["details"]["charge"]) return None @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return unit of measurement.""" return PERCENTAGE diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index de8a85f44fd..8cbe7b1f803 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -118,7 +118,7 @@ class NSDepartureSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the next departure time.""" return self._state diff --git a/homeassistant/components/nest/legacy/sensor.py b/homeassistant/components/nest/legacy/sensor.py index 0939e925b43..f2c6670bf8b 100644 --- a/homeassistant/components/nest/legacy/sensor.py +++ b/homeassistant/components/nest/legacy/sensor.py @@ -154,12 +154,12 @@ class NestBasicSensor(NestSensorDevice, SensorEntity): """Representation a basic Nest sensor.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -189,12 +189,12 @@ class NestTempSensor(NestSensorDevice, SensorEntity): """Representation of a Nest Temperature sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 42614af8c40..0034acff3af 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -95,13 +95,13 @@ class TemperatureSensor(SensorBase): return f"{self._device_info.device_name} Temperature" @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" trait: TemperatureTrait = self._device.traits[TemperatureTrait.NAME] return trait.ambient_temperature_celsius @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @@ -126,13 +126,13 @@ class HumiditySensor(SensorBase): return f"{self._device_info.device_name} Humidity" @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" trait: HumidityTrait = self._device.traits[HumidityTrait.NAME] return trait.ambient_humidity_percent @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return PERCENTAGE diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 0c55b459847..a1f7b2ac079 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -83,7 +83,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Temperature", netatmo_name="Temperature", entity_registry_enabled_default=True, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), @@ -98,7 +98,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="co2", name="CO2", netatmo_name="CO2", - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, entity_registry_enabled_default=True, device_class=DEVICE_CLASS_CO2, state_class=STATE_CLASS_MEASUREMENT, @@ -108,7 +108,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Pressure", netatmo_name="Pressure", entity_registry_enabled_default=True, - unit_of_measurement=PRESSURE_MBAR, + native_unit_of_measurement=PRESSURE_MBAR, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), @@ -124,7 +124,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Noise", netatmo_name="Noise", entity_registry_enabled_default=True, - unit_of_measurement=SOUND_PRESSURE_DB, + native_unit_of_measurement=SOUND_PRESSURE_DB, icon="mdi:volume-high", state_class=STATE_CLASS_MEASUREMENT, ), @@ -133,7 +133,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Humidity", netatmo_name="Humidity", entity_registry_enabled_default=True, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), @@ -142,7 +142,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Rain", netatmo_name="Rain", entity_registry_enabled_default=True, - unit_of_measurement=LENGTH_MILLIMETERS, + native_unit_of_measurement=LENGTH_MILLIMETERS, icon="mdi:weather-rainy", ), NetatmoSensorEntityDescription( @@ -150,7 +150,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Rain last hour", netatmo_name="sum_rain_1", entity_registry_enabled_default=False, - unit_of_measurement=LENGTH_MILLIMETERS, + native_unit_of_measurement=LENGTH_MILLIMETERS, icon="mdi:weather-rainy", ), NetatmoSensorEntityDescription( @@ -158,7 +158,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Rain today", netatmo_name="sum_rain_24", entity_registry_enabled_default=True, - unit_of_measurement=LENGTH_MILLIMETERS, + native_unit_of_measurement=LENGTH_MILLIMETERS, icon="mdi:weather-rainy", ), NetatmoSensorEntityDescription( @@ -166,7 +166,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Battery Percent", netatmo_name="battery_percent", entity_registry_enabled_default=True, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_BATTERY, state_class=STATE_CLASS_MEASUREMENT, ), @@ -182,7 +182,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Angle", netatmo_name="WindAngle", entity_registry_enabled_default=False, - unit_of_measurement=DEGREE, + native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", state_class=STATE_CLASS_MEASUREMENT, ), @@ -191,7 +191,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Wind Strength", netatmo_name="WindStrength", entity_registry_enabled_default=True, - unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", state_class=STATE_CLASS_MEASUREMENT, ), @@ -207,7 +207,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Gust Angle", netatmo_name="GustAngle", entity_registry_enabled_default=False, - unit_of_measurement=DEGREE, + native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", state_class=STATE_CLASS_MEASUREMENT, ), @@ -216,7 +216,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Gust Strength", netatmo_name="GustStrength", entity_registry_enabled_default=False, - unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", state_class=STATE_CLASS_MEASUREMENT, ), @@ -239,7 +239,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Radio Level", netatmo_name="rf_status", entity_registry_enabled_default=False, - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, state_class=STATE_CLASS_MEASUREMENT, ), @@ -255,7 +255,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Wifi Level", netatmo_name="wifi_status", entity_registry_enabled_default=False, - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, state_class=STATE_CLASS_MEASUREMENT, ), @@ -518,25 +518,25 @@ class NetatmoSensor(NetatmoBase, SensorEntity): self._device_name, self._id, ) - self._attr_state = None + self._attr_native_value = None return try: state = data[self.entity_description.netatmo_name] if self.entity_description.key in {"temperature", "pressure", "sum_rain_1"}: - self._attr_state = round(state, 1) + self._attr_native_value = round(state, 1) elif self.entity_description.key in {"windangle_value", "gustangle_value"}: - self._attr_state = fix_angle(state) + self._attr_native_value = fix_angle(state) elif self.entity_description.key in {"windangle", "gustangle"}: - self._attr_state = process_angle(fix_angle(state)) + self._attr_native_value = process_angle(fix_angle(state)) elif self.entity_description.key == "rf_status": - self._attr_state = process_rf(state) + self._attr_native_value = process_rf(state) elif self.entity_description.key == "wifi_status": - self._attr_state = process_wifi(state) + self._attr_native_value = process_wifi(state) elif self.entity_description.key == "health_idx": - self._attr_state = process_health(state) + self._attr_native_value = process_health(state) else: - self._attr_state = state + self._attr_native_value = state except KeyError: if self.state: _LOGGER.debug( @@ -544,7 +544,7 @@ class NetatmoSensor(NetatmoBase, SensorEntity): self.entity_description.key, self._device_name, ) - self._attr_state = None + self._attr_native_value = None return self.async_write_ha_state() @@ -758,14 +758,14 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): self.entity_description.key, self._area_name, ) - self._attr_state = None + self._attr_native_value = None return if values := [x for x in data.values() if x is not None]: if self._mode == "avg": - self._attr_state = round(sum(values) / len(values), 1) + self._attr_native_value = round(sum(values) / len(values), 1) elif self._mode == "max": - self._attr_state = max(values) + self._attr_native_value = max(values) self._attr_available = self.state is not None self.async_write_ha_state() diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index 21e4cd1b005..d1fa87a6e5d 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -117,7 +117,7 @@ class NetdataSensor(SensorEntity): return f"{self._name} {self._sensor_name}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @@ -127,7 +127,7 @@ class NetdataSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the resources.""" return self._state @@ -162,7 +162,7 @@ class NetdataAlarms(SensorEntity): return f"{self._name} Alarms" @property - def state(self): + def native_value(self): """Return the state of the resources.""" return self._state diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index c8f07301e98..0996ad3d315 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -37,7 +37,7 @@ class LTESensor(LTEEntity, SensorEntity): """Base LTE sensor entity.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return SENSOR_UNITS[self.sensor_type] @@ -46,7 +46,7 @@ class SMSUnreadSensor(LTESensor): """Unread SMS sensor entity.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return sum(1 for x in self.modem_data.data.sms if x.unread) @@ -55,7 +55,7 @@ class SMSTotalSensor(LTESensor): """Total SMS sensor entity.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return len(self.modem_data.data.sms) @@ -64,7 +64,7 @@ class UsageSensor(LTESensor): """Data usage sensor entity.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return round(self.modem_data.data.usage / 1024 ** 2, 1) @@ -73,6 +73,6 @@ class GenericSensor(LTESensor): """Sensor entity with raw state.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return getattr(self.modem_data.data, self.sensor_type) diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index d74d6338c8b..37113dde8b7 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -149,12 +149,12 @@ class NeurioEnergy(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index a14931e41ee..6e44b8c9883 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -182,7 +182,7 @@ class NexiaThermostatSensor(NexiaThermostatEntity, SensorEntity): return self._class @property - def state(self): + def native_value(self): """Return the state of the sensor.""" val = getattr(self._thermostat, self._call)() if self._modifier: @@ -192,7 +192,7 @@ class NexiaThermostatSensor(NexiaThermostatEntity, SensorEntity): return val @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement @@ -230,7 +230,7 @@ class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity, SensorEntity): return self._class @property - def state(self): + def native_value(self): """Return the state of the sensor.""" val = getattr(self._zone, self._call)() if self._modifier: @@ -240,6 +240,6 @@ class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity, SensorEntity): return val @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index fb03bcd25b5..f9df0d60412 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -146,7 +146,7 @@ class NextBusDepartureSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return current state of the sensor.""" return self._state diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 5cd02f124e9..6a2d106bb10 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -34,7 +34,7 @@ class NextcloudSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state for this sensor.""" return self._state diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 183755298d6..1b37fa8da7c 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -58,7 +58,7 @@ class NightscoutSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @@ -68,7 +68,7 @@ class NightscoutSensor(SensorEntity): return self._available @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index 936d607a84e..4074cd47f50 100644 --- a/homeassistant/components/nissan_leaf/sensor.py +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -50,12 +50,12 @@ class LeafBatterySensor(LeafEntity, SensorEntity): return DEVICE_CLASS_BATTERY @property - def state(self): + def native_value(self): """Battery state percentage.""" return round(self.car.data[DATA_BATTERY]) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Battery state measured in percentage.""" return PERCENTAGE @@ -89,7 +89,7 @@ class LeafRangeSensor(LeafEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Battery range in miles or kms.""" if self._ac_on: ret = self.car.data[DATA_RANGE_AC] @@ -102,7 +102,7 @@ class LeafRangeSensor(LeafEntity, SensorEntity): return round(ret) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Battery range unit.""" if not self.car.hass.config.units.is_metric or self.car.force_miles: return LENGTH_MILES diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 26f7dbd2c8a..72e51837bb8 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -120,7 +120,7 @@ class NMBSLiveBoard(SensorEntity): return DEFAULT_ICON @property - def state(self): + def native_value(self): """Return sensor state.""" return self._state @@ -166,7 +166,7 @@ class NMBSLiveBoard(SensorEntity): class NMBSSensor(SensorEntity): """Get the the total travel time for a given connection.""" - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES def __init__( self, api_client, name, show_on_map, station_from, station_to, excl_vias @@ -238,7 +238,7 @@ class NMBSSensor(SensorEntity): return attrs @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index e637e953173..5dbee551bb7 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -107,7 +107,7 @@ class NOAATidesAndCurrentsSensor(SensorEntity): return attr @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.data is None: return None diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 48b9a25f783..cf6c394dbda 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -63,7 +63,7 @@ class NotionSensor(NotionEntity, SensorEntity): coordinator, task_id, sensor_id, bridge_id, system_id, name, device_class ) - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit @callback def _async_update_from_latest_data(self) -> None: @@ -71,7 +71,7 @@ class NotionSensor(NotionEntity, SensorEntity): task = self.coordinator.data["tasks"][self._task_id] if task["task_type"] == SENSOR_TEMPERATURE: - self._attr_state = round(float(task["status"]["value"]), 1) + self._attr_native_value = round(float(task["status"]["value"]), 1) else: LOGGER.error( "Unknown task type: %s: %s", diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index 52536e69027..139728a3405 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -99,7 +99,7 @@ class StationPriceSensor(CoordinatorEntity, SensorEntity): return f"{station_name} {self._fuel_type}" @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" if self.coordinator.data is None: return None @@ -117,7 +117,7 @@ class StationPriceSensor(CoordinatorEntity, SensorEntity): } @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the units of measurement.""" return f"{CURRENCY_CENT}/{VOLUME_LITERS}" diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index 19372de5258..fcf719c979e 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -78,12 +78,12 @@ class NumatoGpioAdc(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index b48121eeaf8..5bdd9049456 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -51,7 +51,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status.display": SensorEntityDescription( key="ups.status.display", name="Status", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -59,7 +59,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status": SensorEntityDescription( key="ups.status", name="Status Data", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -67,7 +67,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.alarm": SensorEntityDescription( key="ups.alarm", name="Alarms", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:alarm", device_class=None, state_class=None, @@ -75,7 +75,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.temperature": SensorEntityDescription( key="ups.temperature", name="UPS Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, icon=None, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, @@ -83,7 +83,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.load": SensorEntityDescription( key="ups.load", name="Load", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", device_class=None, state_class=STATE_CLASS_MEASUREMENT, @@ -91,7 +91,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.load.high": SensorEntityDescription( key="ups.load.high", name="Overload Setting", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", device_class=None, state_class=None, @@ -99,7 +99,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.id": SensorEntityDescription( key="ups.id", name="System identifier", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -107,7 +107,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.delay.start": SensorEntityDescription( key="ups.delay.start", name="Load Restart Delay", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", device_class=None, state_class=None, @@ -115,7 +115,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.delay.reboot": SensorEntityDescription( key="ups.delay.reboot", name="UPS Reboot Delay", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", device_class=None, state_class=None, @@ -123,7 +123,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.delay.shutdown": SensorEntityDescription( key="ups.delay.shutdown", name="UPS Shutdown Delay", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", device_class=None, state_class=None, @@ -131,7 +131,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.timer.start": SensorEntityDescription( key="ups.timer.start", name="Load Start Timer", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", device_class=None, state_class=None, @@ -139,7 +139,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.timer.reboot": SensorEntityDescription( key="ups.timer.reboot", name="Load Reboot Timer", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", device_class=None, state_class=None, @@ -147,7 +147,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.timer.shutdown": SensorEntityDescription( key="ups.timer.shutdown", name="Load Shutdown Timer", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", device_class=None, state_class=None, @@ -155,7 +155,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.test.interval": SensorEntityDescription( key="ups.test.interval", name="Self-Test Interval", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", device_class=None, state_class=None, @@ -163,7 +163,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.test.result": SensorEntityDescription( key="ups.test.result", name="Self-Test Result", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -171,7 +171,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.test.date": SensorEntityDescription( key="ups.test.date", name="Self-Test Date", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:calendar", device_class=None, state_class=None, @@ -179,7 +179,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.display.language": SensorEntityDescription( key="ups.display.language", name="Language", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -187,7 +187,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.contacts": SensorEntityDescription( key="ups.contacts", name="External Contacts", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -195,7 +195,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.efficiency": SensorEntityDescription( key="ups.efficiency", name="Efficiency", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", device_class=None, state_class=STATE_CLASS_MEASUREMENT, @@ -203,7 +203,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.power": SensorEntityDescription( key="ups.power", name="Current Apparent Power", - unit_of_measurement=POWER_VOLT_AMPERE, + native_unit_of_measurement=POWER_VOLT_AMPERE, icon="mdi:flash", device_class=None, state_class=STATE_CLASS_MEASUREMENT, @@ -211,7 +211,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.power.nominal": SensorEntityDescription( key="ups.power.nominal", name="Nominal Power", - unit_of_measurement=POWER_VOLT_AMPERE, + native_unit_of_measurement=POWER_VOLT_AMPERE, icon="mdi:flash", device_class=None, state_class=None, @@ -219,7 +219,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.realpower": SensorEntityDescription( key="ups.realpower", name="Current Real Power", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, icon=None, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, @@ -227,7 +227,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.realpower.nominal": SensorEntityDescription( key="ups.realpower.nominal", name="Nominal Real Power", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, icon=None, device_class=DEVICE_CLASS_POWER, state_class=None, @@ -235,7 +235,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.beeper.status": SensorEntityDescription( key="ups.beeper.status", name="Beeper Status", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -243,7 +243,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.type": SensorEntityDescription( key="ups.type", name="UPS Type", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -251,7 +251,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.watchdog.status": SensorEntityDescription( key="ups.watchdog.status", name="Watchdog Status", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -259,7 +259,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.start.auto": SensorEntityDescription( key="ups.start.auto", name="Start on AC", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -267,7 +267,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.start.battery": SensorEntityDescription( key="ups.start.battery", name="Start on Battery", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -275,7 +275,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.start.reboot": SensorEntityDescription( key="ups.start.reboot", name="Reboot on Battery", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -283,7 +283,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.shutdown": SensorEntityDescription( key="ups.shutdown", name="Shutdown Ability", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -291,7 +291,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.charge": SensorEntityDescription( key="battery.charge", name="Battery Charge", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon=None, device_class=DEVICE_CLASS_BATTERY, state_class=STATE_CLASS_MEASUREMENT, @@ -299,7 +299,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.charge.low": SensorEntityDescription( key="battery.charge.low", name="Low Battery Setpoint", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", device_class=None, state_class=None, @@ -307,7 +307,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.charge.restart": SensorEntityDescription( key="battery.charge.restart", name="Minimum Battery to Start", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", device_class=None, state_class=None, @@ -315,7 +315,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.charge.warning": SensorEntityDescription( key="battery.charge.warning", name="Warning Battery Setpoint", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", device_class=None, state_class=None, @@ -323,7 +323,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.charger.status": SensorEntityDescription( key="battery.charger.status", name="Charging Status", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -331,7 +331,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.voltage": SensorEntityDescription( key="battery.voltage", name="Battery Voltage", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, @@ -339,7 +339,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.voltage.nominal": SensorEntityDescription( key="battery.voltage.nominal", name="Nominal Battery Voltage", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=None, @@ -347,7 +347,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.voltage.low": SensorEntityDescription( key="battery.voltage.low", name="Low Battery Voltage", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=None, @@ -355,7 +355,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.voltage.high": SensorEntityDescription( key="battery.voltage.high", name="High Battery Voltage", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=None, @@ -363,7 +363,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.capacity": SensorEntityDescription( key="battery.capacity", name="Battery Capacity", - unit_of_measurement="Ah", + native_unit_of_measurement="Ah", icon="mdi:flash", device_class=None, state_class=None, @@ -371,7 +371,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.current": SensorEntityDescription( key="battery.current", name="Battery Current", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", device_class=None, state_class=STATE_CLASS_MEASUREMENT, @@ -379,7 +379,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.current.total": SensorEntityDescription( key="battery.current.total", name="Total Battery Current", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", device_class=None, state_class=None, @@ -387,7 +387,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.temperature": SensorEntityDescription( key="battery.temperature", name="Battery Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, icon=None, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, @@ -395,7 +395,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.runtime": SensorEntityDescription( key="battery.runtime", name="Battery Runtime", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", device_class=None, state_class=None, @@ -403,7 +403,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.runtime.low": SensorEntityDescription( key="battery.runtime.low", name="Low Battery Runtime", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", device_class=None, state_class=None, @@ -411,7 +411,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.runtime.restart": SensorEntityDescription( key="battery.runtime.restart", name="Minimum Battery Runtime to Start", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", device_class=None, state_class=None, @@ -419,7 +419,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.alarm.threshold": SensorEntityDescription( key="battery.alarm.threshold", name="Battery Alarm Threshold", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -427,7 +427,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.date": SensorEntityDescription( key="battery.date", name="Battery Date", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:calendar", device_class=None, state_class=None, @@ -435,7 +435,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.mfr.date": SensorEntityDescription( key="battery.mfr.date", name="Battery Manuf. Date", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:calendar", device_class=None, state_class=None, @@ -443,7 +443,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.packs": SensorEntityDescription( key="battery.packs", name="Number of Batteries", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -451,7 +451,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.packs.bad": SensorEntityDescription( key="battery.packs.bad", name="Number of Bad Batteries", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -459,7 +459,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.type": SensorEntityDescription( key="battery.type", name="Battery Chemistry", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -467,7 +467,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.sensitivity": SensorEntityDescription( key="input.sensitivity", name="Input Power Sensitivity", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -475,7 +475,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.transfer.low": SensorEntityDescription( key="input.transfer.low", name="Low Voltage Transfer", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=None, @@ -483,7 +483,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.transfer.high": SensorEntityDescription( key="input.transfer.high", name="High Voltage Transfer", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=None, @@ -491,7 +491,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.transfer.reason": SensorEntityDescription( key="input.transfer.reason", name="Voltage Transfer Reason", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -499,7 +499,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.voltage": SensorEntityDescription( key="input.voltage", name="Input Voltage", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, @@ -507,7 +507,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.voltage.nominal": SensorEntityDescription( key="input.voltage.nominal", name="Nominal Input Voltage", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=None, @@ -515,7 +515,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.frequency": SensorEntityDescription( key="input.frequency", name="Input Line Frequency", - unit_of_measurement=FREQUENCY_HERTZ, + native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", device_class=None, state_class=STATE_CLASS_MEASUREMENT, @@ -523,7 +523,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.frequency.nominal": SensorEntityDescription( key="input.frequency.nominal", name="Nominal Input Line Frequency", - unit_of_measurement=FREQUENCY_HERTZ, + native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", device_class=None, state_class=None, @@ -531,7 +531,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.frequency.status": SensorEntityDescription( key="input.frequency.status", name="Input Frequency Status", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:information-outline", device_class=None, state_class=None, @@ -539,7 +539,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "output.current": SensorEntityDescription( key="output.current", name="Output Current", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", device_class=None, state_class=STATE_CLASS_MEASUREMENT, @@ -547,7 +547,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "output.current.nominal": SensorEntityDescription( key="output.current.nominal", name="Nominal Output Current", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", device_class=None, state_class=None, @@ -555,7 +555,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "output.voltage": SensorEntityDescription( key="output.voltage", name="Output Voltage", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, @@ -563,7 +563,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "output.voltage.nominal": SensorEntityDescription( key="output.voltage.nominal", name="Nominal Output Voltage", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=None, @@ -571,7 +571,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "output.frequency": SensorEntityDescription( key="output.frequency", name="Output Frequency", - unit_of_measurement=FREQUENCY_HERTZ, + native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", device_class=None, state_class=STATE_CLASS_MEASUREMENT, @@ -579,7 +579,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "output.frequency.nominal": SensorEntityDescription( key="output.frequency.nominal", name="Nominal Output Frequency", - unit_of_measurement=FREQUENCY_HERTZ, + native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", device_class=None, state_class=None, @@ -587,7 +587,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ambient.humidity": SensorEntityDescription( key="ambient.humidity", name="Ambient Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon=None, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, @@ -595,7 +595,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ambient.temperature": SensorEntityDescription( key="ambient.temperature", name="Ambient Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, icon=None, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 456778c3ca5..971c194c1c9 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -130,7 +130,7 @@ class NUTSensor(CoordinatorEntity, SensorEntity): return f"{self._unique_id}_{self.entity_description.key}" @property - def state(self): + def native_value(self): """Return entity state from ups.""" if not self._data.status: return None diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 6e08ef408d3..32018bc40bb 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -113,7 +113,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Dew Point", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), NWSSensorEntityDescription( @@ -121,7 +121,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Temperature", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), NWSSensorEntityDescription( @@ -129,7 +129,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Chill", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), NWSSensorEntityDescription( @@ -137,7 +137,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Heat Index", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), NWSSensorEntityDescription( @@ -145,7 +145,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Relative Humidity", icon=None, device_class=DEVICE_CLASS_HUMIDITY, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, unit_convert=PERCENTAGE, ), NWSSensorEntityDescription( @@ -153,7 +153,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Speed", icon="mdi:weather-windy", device_class=None, - unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, unit_convert=SPEED_MILES_PER_HOUR, ), NWSSensorEntityDescription( @@ -161,7 +161,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Gust", icon="mdi:weather-windy", device_class=None, - unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, unit_convert=SPEED_MILES_PER_HOUR, ), NWSSensorEntityDescription( @@ -169,7 +169,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Direction", icon="mdi:compass-rose", device_class=None, - unit_of_measurement=DEGREE, + native_unit_of_measurement=DEGREE, unit_convert=DEGREE, ), NWSSensorEntityDescription( @@ -177,7 +177,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Barometric Pressure", icon=None, device_class=DEVICE_CLASS_PRESSURE, - unit_of_measurement=PRESSURE_PA, + native_unit_of_measurement=PRESSURE_PA, unit_convert=PRESSURE_INHG, ), NWSSensorEntityDescription( @@ -185,7 +185,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Sea Level Pressure", icon=None, device_class=DEVICE_CLASS_PRESSURE, - unit_of_measurement=PRESSURE_PA, + native_unit_of_measurement=PRESSURE_PA, unit_convert=PRESSURE_INHG, ), NWSSensorEntityDescription( @@ -193,7 +193,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Visibility", icon="mdi:eye", device_class=None, - unit_of_measurement=LENGTH_METERS, + native_unit_of_measurement=LENGTH_METERS, unit_convert=LENGTH_MILES, ), ) diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 409856831a2..85b60ffd475 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -73,16 +73,16 @@ class NWSSensor(CoordinatorEntity, SensorEntity): self._attr_name = f"{station} {description.name}" if not hass.config.units.is_metric: - self._attr_unit_of_measurement = description.unit_convert + self._attr_native_unit_of_measurement = description.unit_convert @property - def state(self): + def native_value(self): """Return the state.""" value = self._nws.observation.get(self.entity_description.key) if value is None: return None # Set alias to unit property -> prevent unnecessary hasattr calls - unit_of_measurement = self.unit_of_measurement + unit_of_measurement = self.native_unit_of_measurement if unit_of_measurement == SPEED_MILES_PER_HOUR: return round(convert_distance(value, LENGTH_KILOMETERS, LENGTH_MILES)) if unit_of_measurement == LENGTH_MILES: diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 97bced9e9c2..325438908a7 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -103,12 +103,12 @@ class NZBGetSensor(NZBGetEntity, SensorEntity): return self._unique_id @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit that the state of sensor is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the sensor.""" value = self.coordinator.data["status"].get(self._sensor_type) diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 71af8dacba2..4c9b583a36b 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -73,7 +73,7 @@ class OASATelematicsSensor(SensorEntity): return DEVICE_CLASS_TIMESTAMP @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 639b9eb332f..7ba28ee0741 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -85,7 +85,7 @@ class ObihaiServiceSensors(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 16f6efce004..5b2b0af494c 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -107,7 +107,7 @@ class OctoPrintSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" sensor_unit = self.unit_of_measurement if sensor_unit in (TEMP_CELSIUS, PERCENTAGE): @@ -118,7 +118,7 @@ class OctoPrintSensor(SensorEntity): return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py index b53c35e17b5..0638b32d105 100644 --- a/homeassistant/components/ohmconnect/sensor.py +++ b/homeassistant/components/ohmconnect/sensor.py @@ -48,7 +48,7 @@ class OhmconnectSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._data.get("active") == "True": return "Active" diff --git a/homeassistant/components/ombi/sensor.py b/homeassistant/components/ombi/sensor.py index c91cf429c94..50bb121dc4b 100644 --- a/homeassistant/components/ombi/sensor.py +++ b/homeassistant/components/ombi/sensor.py @@ -53,7 +53,7 @@ class OmbiSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index 1f8de082868..f0382c01342 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -86,7 +86,7 @@ class OmnilogicSensor(OmniLogicEntity, SensorEntity): return self._device_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the right unit of measure.""" return self._unit @@ -95,7 +95,7 @@ class OmniLogicTemperatureSensor(OmnilogicSensor): """Define an OmniLogic Temperature (Air/Water) Sensor.""" @property - def state(self): + def native_value(self): """Return the state for the temperature sensor.""" sensor_data = self.coordinator.data[self._item_id][self._state_key] @@ -123,7 +123,7 @@ class OmniLogicPumpSpeedSensor(OmnilogicSensor): """Define an OmniLogic Pump Speed Sensor.""" @property - def state(self): + def native_value(self): """Return the state for the pump speed sensor.""" pump_type = PUMP_TYPES[ @@ -158,7 +158,7 @@ class OmniLogicSaltLevelSensor(OmnilogicSensor): """Define an OmniLogic Salt Level Sensor.""" @property - def state(self): + def native_value(self): """Return the state for the salt level sensor.""" salt_return = self.coordinator.data[self._item_id][self._state_key] @@ -177,7 +177,7 @@ class OmniLogicChlorinatorSensor(OmnilogicSensor): """Define an OmniLogic Chlorinator Sensor.""" @property - def state(self): + def native_value(self): """Return the state for the chlorinator sensor.""" state = self.coordinator.data[self._item_id][self._state_key] @@ -188,7 +188,7 @@ class OmniLogicPHSensor(OmnilogicSensor): """Define an OmniLogic pH Sensor.""" @property - def state(self): + def native_value(self): """Return the state for the pH sensor.""" ph_state = self.coordinator.data[self._item_id][self._state_key] @@ -232,7 +232,7 @@ class OmniLogicORPSensor(OmnilogicSensor): ) @property - def state(self): + def native_value(self): """Return the state for the ORP sensor.""" orp_state = int(self.coordinator.data[self._item_id][self._state_key]) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 7449524d9e5..693d685f77c 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -28,49 +28,49 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", name="Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, icon=None, device_class=DEVICE_CLASS_TEMPERATURE, ), SensorEntityDescription( key="orp", name="Oxydo Reduction Potential", - unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, icon="mdi:pool", device_class=None, ), SensorEntityDescription( key="ph", name="pH", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:pool", device_class=None, ), SensorEntityDescription( key="tds", name="TDS", - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, icon="mdi:pool", device_class=None, ), SensorEntityDescription( key="battery", name="Battery", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon=None, device_class=DEVICE_CLASS_BATTERY, ), SensorEntityDescription( key="rssi", name="RSSI", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon=None, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, ), SensorEntityDescription( key="salt", name="Salt", - unit_of_measurement="mg/L", + native_unit_of_measurement="mg/L", icon="mdi:pool", device_class=None, ), @@ -164,7 +164,7 @@ class OndiloICO(CoordinatorEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Last value of the sensor.""" return self._devdata()["value"] diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 024d540c10a..215ba6c569b 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -368,7 +368,7 @@ class OneWireSensor(OneWireBaseEntity, SensorEntity): """Mixin for sensor specific attributes.""" @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self._unit_of_measurement @@ -377,7 +377,7 @@ class OneWireProxySensor(OneWireProxyEntity, OneWireSensor): """Implementation of a 1-Wire sensor connected through owserver.""" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the entity.""" return self._state @@ -405,7 +405,7 @@ class OneWireDirectSensor(OneWireSensor): self._owsensor = owsensor @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the entity.""" return self._state diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index 1c5766e3969..5c31644ba19 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -44,7 +44,7 @@ class ONVIFSensor(ONVIFBaseEntity, SensorEntity): super().__init__(device) @property - def state(self) -> None | str | int | float: + def native_value(self) -> None | str | int | float: """Return the state of the entity.""" return self.device.events.get_uid(self.uid).value @@ -59,7 +59,7 @@ class ONVIFSensor(ONVIFBaseEntity, SensorEntity): return self.device.events.get_uid(self.uid).device_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" return self.device.events.get_uid(self.uid).unit_of_measurement diff --git a/homeassistant/components/openerz/sensor.py b/homeassistant/components/openerz/sensor.py index 33305b677de..8a3c2c0460d 100644 --- a/homeassistant/components/openerz/sensor.py +++ b/homeassistant/components/openerz/sensor.py @@ -44,7 +44,7 @@ class OpenERZSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index 29eeceb232c..a1920e145bb 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -70,12 +70,12 @@ class OpenEVSESensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this sensor.""" return self._unit_of_measurement diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 8474cdab131..803123a88c3 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -73,7 +73,7 @@ class OpenexchangeratesSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 8f43c1e5e9b..280acab5d0e 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -62,12 +62,12 @@ class OpenHardwareMonitorDevice(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.value diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 122388b85b7..0502d6c6573 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -107,7 +107,7 @@ class OpenSkySensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -178,7 +178,7 @@ class OpenSkySensor(SensorEntity): return {ATTR_ATTRIBUTION: OPENSKY_ATTRIBUTION} @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return "flights" diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index 1d9904ea59f..28f139f188f 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -156,12 +156,12 @@ class OpenThermSensor(SensorEntity): return self._device_class @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 386527ebc3e..e115f9294a5 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -105,7 +105,7 @@ class OpenUvSensor(OpenUvEntity, SensorEntity): self._attr_icon = icon self._attr_name = name - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit @callback def update_from_latest_data(self) -> None: @@ -119,22 +119,22 @@ class OpenUvSensor(OpenUvEntity, SensorEntity): self._attr_available = True if self._sensor_type == TYPE_CURRENT_OZONE_LEVEL: - self._attr_state = data["ozone"] + self._attr_native_value = data["ozone"] elif self._sensor_type == TYPE_CURRENT_UV_INDEX: - self._attr_state = data["uv"] + self._attr_native_value = data["uv"] elif self._sensor_type == TYPE_CURRENT_UV_LEVEL: if data["uv"] >= 11: - self._attr_state = UV_LEVEL_EXTREME + self._attr_native_value = UV_LEVEL_EXTREME elif data["uv"] >= 8: - self._attr_state = UV_LEVEL_VHIGH + self._attr_native_value = UV_LEVEL_VHIGH elif data["uv"] >= 6: - self._attr_state = UV_LEVEL_HIGH + self._attr_native_value = UV_LEVEL_HIGH elif data["uv"] >= 3: - self._attr_state = UV_LEVEL_MODERATE + self._attr_native_value = UV_LEVEL_MODERATE else: - self._attr_state = UV_LEVEL_LOW + self._attr_native_value = UV_LEVEL_LOW elif self._sensor_type == TYPE_MAX_UV_INDEX: - self._attr_state = data["uv_max"] + self._attr_native_value = data["uv_max"] uv_max_time = parse_datetime(data["uv_max_time"]) if uv_max_time: self._attr_extra_state_attributes.update( @@ -148,6 +148,6 @@ class OpenUvSensor(OpenUvEntity, SensorEntity): TYPE_SAFE_EXPOSURE_TIME_5, TYPE_SAFE_EXPOSURE_TIME_6, ): - self._attr_state = data["safe_exposure_time"][ + self._attr_native_value = data["safe_exposure_time"][ EXPOSURE_TYPE_MAP[self._sensor_type] ] diff --git a/homeassistant/components/openweathermap/abstract_owm_sensor.py b/homeassistant/components/openweathermap/abstract_owm_sensor.py index ea12123b707..3c66ca50f3c 100644 --- a/homeassistant/components/openweathermap/abstract_owm_sensor.py +++ b/homeassistant/components/openweathermap/abstract_owm_sensor.py @@ -71,7 +71,7 @@ class AbstractOpenWeatherMapSensor(SensorEntity): return self._device_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 39c50c3b941..3586f958a6a 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -68,7 +68,7 @@ class OpenWeatherMapSensor(AbstractOpenWeatherMapSensor): self._weather_coordinator = weather_coordinator @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._weather_coordinator.data.get(self._sensor_type, None) @@ -91,7 +91,7 @@ class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor): self._weather_coordinator = weather_coordinator @property - def state(self): + def native_value(self): """Return the state of the device.""" forecasts = self._weather_coordinator.data.get(ATTR_API_FORECAST) if forecasts is not None and len(forecasts) > 0: diff --git a/homeassistant/components/oru/sensor.py b/homeassistant/components/oru/sensor.py index c17873aefea..dc2b7216534 100644 --- a/homeassistant/components/oru/sensor.py +++ b/homeassistant/components/oru/sensor.py @@ -42,7 +42,7 @@ class CurrentEnergyUsageSensor(SensorEntity): """Representation of the sensor.""" _attr_icon = SENSOR_ICON - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR def __init__(self, meter): """Initialize the sensor.""" @@ -61,7 +61,7 @@ class CurrentEnergyUsageSensor(SensorEntity): return SENSOR_NAME @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 7aee9d99208..45bedb6b499 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -63,7 +63,7 @@ class TOTPSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index f678caf02b0..91290238dce 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -81,7 +81,7 @@ class OVOEnergySensor(OVOEnergyDeviceEntity, SensorEntity): super().__init__(coordinator, client, key, name, icon) @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return self._unit_of_measurement @@ -103,7 +103,7 @@ class OVOEnergyLastElectricityReading(OVOEnergySensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" usage: OVODailyUsage = self.coordinator.data if usage is None or not usage.electricity: @@ -139,7 +139,7 @@ class OVOEnergyLastGasReading(OVOEnergySensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" usage: OVODailyUsage = self.coordinator.data if usage is None or not usage.gas: @@ -176,7 +176,7 @@ class OVOEnergyLastElectricityCost(OVOEnergySensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" usage: OVODailyUsage = self.coordinator.data if usage is None or not usage.electricity: @@ -213,7 +213,7 @@ class OVOEnergyLastGasCost(OVOEnergySensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" usage: OVODailyUsage = self.coordinator.data if usage is None or not usage.gas: diff --git a/homeassistant/components/ozw/sensor.py b/homeassistant/components/ozw/sensor.py index 0ff08a87d16..97b7b01d4d4 100644 --- a/homeassistant/components/ozw/sensor.py +++ b/homeassistant/components/ozw/sensor.py @@ -106,12 +106,12 @@ class ZWaveStringSensor(ZwaveSensorBase): """Representation of a Z-Wave sensor.""" @property - def state(self): + def native_value(self): """Return state of the sensor.""" return self.values.primary.value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return unit of measurement the value is expressed in.""" return self.values.primary.units @@ -125,12 +125,12 @@ class ZWaveNumericSensor(ZwaveSensorBase): """Representation of a Z-Wave sensor.""" @property - def state(self): + def native_value(self): """Return state of the sensor.""" return round(self.values.primary.value, 2) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return unit of measurement the value is expressed in.""" if self.values.primary.units == "C": return TEMP_CELSIUS @@ -144,7 +144,7 @@ class ZWaveListSensor(ZwaveSensorBase): """Representation of a Z-Wave list sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" # We use the id as value for backwards compatibility return self.values.primary.value["Selected_id"] diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index 52c638864a5..f1ec1c6efd6 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -41,55 +41,55 @@ SENSOR_TYPES: tuple[PiHoleSensorEntityDescription, ...] = ( PiHoleSensorEntityDescription( key="ads_blocked_today", name="Ads Blocked Today", - unit_of_measurement="ads", + native_unit_of_measurement="ads", icon="mdi:close-octagon-outline", ), PiHoleSensorEntityDescription( key="ads_percentage_today", name="Ads Percentage Blocked Today", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:close-octagon-outline", ), PiHoleSensorEntityDescription( key="clients_ever_seen", name="Seen Clients", - unit_of_measurement="clients", + native_unit_of_measurement="clients", icon="mdi:account-outline", ), PiHoleSensorEntityDescription( key="dns_queries_today", name="DNS Queries Today", - unit_of_measurement="queries", + native_unit_of_measurement="queries", icon="mdi:comment-question-outline", ), PiHoleSensorEntityDescription( key="domains_being_blocked", name="Domains Blocked", - unit_of_measurement="domains", + native_unit_of_measurement="domains", icon="mdi:block-helper", ), PiHoleSensorEntityDescription( key="queries_cached", name="DNS Queries Cached", - unit_of_measurement="queries", + native_unit_of_measurement="queries", icon="mdi:comment-question-outline", ), PiHoleSensorEntityDescription( key="queries_forwarded", name="DNS Queries Forwarded", - unit_of_measurement="queries", + native_unit_of_measurement="queries", icon="mdi:comment-question-outline", ), PiHoleSensorEntityDescription( key="unique_clients", name="DNS Unique Clients", - unit_of_measurement="clients", + native_unit_of_measurement="clients", icon="mdi:account-outline", ), PiHoleSensorEntityDescription( key="unique_domains", name="DNS Unique Domains", - unit_of_measurement="domains", + native_unit_of_measurement="domains", icon="mdi:domain", ), ) diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 14aed86a479..0e231868647 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -63,7 +63,7 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): self._attr_unique_id = f"{self._server_unique_id}/{description.name}" @property - def state(self) -> Any: + def native_value(self) -> Any: """Return the state of the device.""" try: return round(self.api.data[self.entity_description.key], 2) diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 3a4d3582f9c..57f24180c03 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant @@ -30,7 +31,7 @@ async def async_setup_entry( return True -class PicnicSensor(CoordinatorEntity): +class PicnicSensor(SensorEntity, CoordinatorEntity): """The CoordinatorEntity subclass representing Picnic sensors.""" def __init__( @@ -49,7 +50,7 @@ class PicnicSensor(CoordinatorEntity): self._service_unique_id = config_entry.unique_id @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return self.properties.get("unit") @@ -64,7 +65,7 @@ class PicnicSensor(CoordinatorEntity): return self._to_capitalized_name(self.sensor_type) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the entity.""" data_set = ( self.coordinator.data.get(self.properties["data_type"], {}) diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py index 97458acd5fc..bc8135c5932 100644 --- a/homeassistant/components/pilight/sensor.py +++ b/homeassistant/components/pilight/sensor.py @@ -63,12 +63,12 @@ class PilightSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the entity.""" return self._state diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index 9af16a1cacd..e3e37d4291e 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -75,11 +75,11 @@ class PlaatoSensor(PlaatoEntity, SensorEntity): return None @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._sensor_data.sensors.get(self._sensor_type) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._sensor_data.get_unit_of_measurement(self._sensor_type) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 8ca72e8fb83..0969967e673 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -62,7 +62,7 @@ class PlexSensor(SensorEntity): self._attr_name = NAME_FORMAT.format(plex_server.friendly_name) self._attr_should_poll = False self._attr_unique_id = f"sensor-{plex_server.machine_identifier}" - self._attr_unit_of_measurement = "Watching" + self._attr_native_unit_of_measurement = "Watching" self._server = plex_server self.async_refresh_sensor = Debouncer( @@ -87,7 +87,7 @@ class PlexSensor(SensorEntity): async def _async_refresh_sensor(self): """Set instance object and trigger an entity state update.""" _LOGGER.debug("Refreshing sensor [%s]", self.unique_id) - self._attr_state = len(self._server.sensor_attributes) + self._attr_native_value = len(self._server.sensor_attributes) self.async_write_ha_state() @property @@ -128,7 +128,7 @@ class PlexLibrarySectionSensor(SensorEntity): self._attr_name = f"{self.server_name} Library - {plex_library_section.title}" self._attr_should_poll = False self._attr_unique_id = f"library-{self.server_id}-{plex_library_section.uuid}" - self._attr_unit_of_measurement = "Items" + self._attr_native_unit_of_measurement = "Items" async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -164,7 +164,7 @@ class PlexLibrarySectionSensor(SensorEntity): self.library_type, self.library_type ) - self._attr_state = self.library_section.totalViewSize( + self._attr_native_value = self.library_section.totalViewSize( libtype=primary_libtype, includeCollections=False ) for libtype in LIBRARY_ATTRIBUTE_TYPES.get(self.library_type, []): diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 4152f9fdabd..854c2e6676c 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -272,12 +272,12 @@ class SmileSensor(SmileGateway, SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of this entity.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 55ae4a524fc..f745bd562bd 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -50,7 +50,7 @@ class PocketCastsSensor(SensorEntity): return SENSOR_NAME @property - def state(self): + def native_value(self): """Return the sensor state.""" return self._state diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index dc34e1f9367..87981d7b29e 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -70,13 +70,13 @@ class MinutPointSensor(MinutPointEntity, SensorEntity): return self._device_prop[0] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.value is None: return None return round(self.value, self._device_prop[1]) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._device_prop[2] diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index dd03111e85e..e9aeaca20f5 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -89,7 +89,7 @@ class PoolSenseSensor(PoolSenseEntity, SensorEntity): return f"PoolSense {SENSORS[self.info_type]['name']}" @property - def state(self): + def native_value(self): """State of the sensor.""" return self.coordinator.data[self.info_type] @@ -104,7 +104,7 @@ class PoolSenseSensor(PoolSenseEntity, SensorEntity): return SENSORS[self.info_type]["icon"] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return unit of measurement.""" return SENSORS[self.info_type]["unit"] diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index b2281c515ae..96542dc3929 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -87,7 +87,7 @@ class PowerWallChargeSensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall charge sensor.""" _attr_name = "Powerwall Charge" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_device_class = DEVICE_CLASS_BATTERY @property @@ -96,7 +96,7 @@ class PowerWallChargeSensor(PowerWallEntity, SensorEntity): return f"{self.base_unique_id}_charge" @property - def state(self): + def native_value(self): """Get the current value in percentage.""" return round(self.coordinator.data[POWERWALL_API_CHARGE]) @@ -105,7 +105,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Energy sensor.""" _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = POWER_KILO_WATT + _attr_native_unit_of_measurement = POWER_KILO_WATT _attr_device_class = DEVICE_CLASS_POWER def __init__( @@ -128,7 +128,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Get the current value in kW.""" return ( self.coordinator.data[POWERWALL_API_METERS] diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 4f8ec6a1700..3585c198a51 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -79,7 +79,7 @@ class PushBulletNotificationSensor(SensorEntity): return f"Pushbullet {self._element}" @property - def state(self): + def native_value(self): """Return the current state of the sensor.""" return self._state diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index 305615e4b2c..722aea8e868 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -79,7 +79,7 @@ class PvoutputSensor(SensorEntity, RestoreEntity): _attr_state_class = STATE_CLASS_MEASUREMENT _attr_device_class = DEVICE_CLASS_ENERGY - _attr_unit_of_measurement = ENERGY_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_WATT_HOUR _old_state: int | None = None @@ -104,7 +104,7 @@ class PvoutputSensor(SensorEntity, RestoreEntity): ) @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.pvcoutput is not None: return self.pvcoutput.energy_generation diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 75881f93f0a..157327fbd19 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -106,7 +106,7 @@ class ElecPriceSensor(RestoreEntity, SensorEntity): return self._name @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" return self._pvpc_data.state diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index c439d5181be..f568b41776f 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -93,12 +93,12 @@ class PyLoadSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 251407099b1..5f57cd19cfe 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -90,7 +90,7 @@ class QBittorrentSensor(SensorEntity): return f"{self.client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -100,7 +100,7 @@ class QBittorrentSensor(SensorEntity): return self._available @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index c175d89f60e..333ce46599a 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -243,7 +243,7 @@ class QNAPSensor(SensorEntity): return self.var_icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self.var_units @@ -256,7 +256,7 @@ class QNAPCPUSensor(QNAPSensor): """A QNAP sensor that monitors CPU stats.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.var_id == "cpu_temp": return self._api.data["system_stats"]["cpu"]["temp_c"] @@ -268,7 +268,7 @@ class QNAPMemorySensor(QNAPSensor): """A QNAP sensor that monitors memory stats.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" free = float(self._api.data["system_stats"]["memory"]["free"]) / 1024 if self.var_id == "memory_free": @@ -296,7 +296,7 @@ class QNAPNetworkSensor(QNAPSensor): """A QNAP sensor that monitors network stats.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.var_id == "network_link_status": nic = self._api.data["system_stats"]["nics"][self.monitor_device] @@ -329,7 +329,7 @@ class QNAPSystemSensor(QNAPSensor): """A QNAP sensor that monitors overall system health.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.var_id == "status": return self._api.data["system_health"] @@ -358,7 +358,7 @@ class QNAPDriveSensor(QNAPSensor): """A QNAP sensor that monitors HDD/SSD drive stats.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" data = self._api.data["smart_drive_health"][self.monitor_device] @@ -392,7 +392,7 @@ class QNAPVolumeSensor(QNAPSensor): """A QNAP sensor that monitors storage volume stats.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" data = self._api.data["volumes"][self.monitor_device] diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py index f6d0ce7ec28..5de7bc0dccf 100644 --- a/homeassistant/components/qwikswitch/sensor.py +++ b/homeassistant/components/qwikswitch/sensor.py @@ -57,7 +57,7 @@ class QSSensor(QSEntity, SensorEntity): self.async_write_ha_state() @property - def state(self): + def native_value(self): """Return the value of the sensor.""" return str(self._val) @@ -67,6 +67,6 @@ class QSSensor(QSEntity, SensorEntity): return f"qs{self.qsid}:{self.channel}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self.unit From 6de6a5dc141c433a1f4761ccb052bbd37115f746 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 12 Aug 2021 14:23:56 +0200 Subject: [PATCH 350/903] Move temperature conversions to sensor base class (3/8) (#54469) * Move temperature conversions to entity base class (3/8) * Fix FritzBox sensor * Fix tests --- homeassistant/components/fail2ban/sensor.py | 2 +- homeassistant/components/fastdotcom/sensor.py | 8 +- homeassistant/components/fibaro/sensor.py | 4 +- homeassistant/components/fido/sensor.py | 4 +- homeassistant/components/file/sensor.py | 4 +- homeassistant/components/filesize/sensor.py | 4 +- homeassistant/components/filter/sensor.py | 4 +- homeassistant/components/fints/sensor.py | 8 +- .../components/fireservicerota/sensor.py | 2 +- homeassistant/components/firmata/sensor.py | 2 +- homeassistant/components/fitbit/sensor.py | 4 +- homeassistant/components/fixer/sensor.py | 4 +- .../components/flick_electric/sensor.py | 4 +- homeassistant/components/flo/sensor.py | 26 +-- homeassistant/components/flume/sensor.py | 4 +- homeassistant/components/flunearyou/sensor.py | 8 +- homeassistant/components/folder/sensor.py | 4 +- homeassistant/components/foobot/sensor.py | 4 +- .../components/forecast_solar/const.py | 16 +- .../components/forecast_solar/sensor.py | 2 +- homeassistant/components/freebox/sensor.py | 4 +- homeassistant/components/freedompro/sensor.py | 6 +- homeassistant/components/fritz/sensor.py | 6 +- homeassistant/components/fritzbox/__init__.py | 7 - homeassistant/components/fritzbox/sensor.py | 31 +++- .../components/fritzbox_callmonitor/sensor.py | 2 +- homeassistant/components/fronius/sensor.py | 4 +- .../components/garages_amsterdam/sensor.py | 4 +- homeassistant/components/gdacs/sensor.py | 4 +- homeassistant/components/geniushub/sensor.py | 6 +- .../components/geo_rss_events/sensor.py | 4 +- .../components/geonetnz_quakes/sensor.py | 4 +- .../components/geonetnz_volcano/sensor.py | 4 +- homeassistant/components/gios/const.py | 14 +- homeassistant/components/gios/sensor.py | 4 +- homeassistant/components/github/sensor.py | 2 +- homeassistant/components/gitlab_ci/sensor.py | 2 +- homeassistant/components/gitter/sensor.py | 4 +- homeassistant/components/glances/const.py | 44 ++--- homeassistant/components/glances/sensor.py | 2 +- homeassistant/components/goalzero/sensor.py | 4 +- homeassistant/components/gogogate2/sensor.py | 6 +- .../components/google_travel_time/sensor.py | 4 +- .../components/google_wifi/sensor.py | 4 +- homeassistant/components/gpsd/sensor.py | 2 +- .../components/greeneye_monitor/sensor.py | 16 +- .../components/growatt_server/sensor.py | 168 +++++++++--------- homeassistant/components/gtfs/sensor.py | 2 +- homeassistant/components/guardian/sensor.py | 18 +- homeassistant/components/habitica/sensor.py | 8 +- homeassistant/components/hassio/sensor.py | 4 +- .../components/haveibeenpwned/sensor.py | 4 +- homeassistant/components/hddtemp/sensor.py | 4 +- .../components/here_travel_time/sensor.py | 4 +- .../components/history_stats/sensor.py | 4 +- homeassistant/components/hive/sensor.py | 4 +- .../components/home_connect/sensor.py | 4 +- .../components/homekit_controller/sensor.py | 24 +-- homeassistant/components/homematic/sensor.py | 4 +- .../components/homematicip_cloud/sensor.py | 34 ++-- homeassistant/components/hp_ilo/sensor.py | 4 +- homeassistant/components/htu21d/sensor.py | 4 +- homeassistant/components/huawei_lte/sensor.py | 4 +- homeassistant/components/hue/sensor.py | 12 +- homeassistant/components/huisbaasje/sensor.py | 4 +- .../hunterdouglas_powerview/sensor.py | 4 +- .../components/hvv_departures/sensor.py | 2 +- homeassistant/components/hydrawise/sensor.py | 4 +- tests/components/fail2ban/test_sensor.py | 8 + tests/components/google_wifi/test_sensor.py | 30 ++-- 70 files changed, 350 insertions(+), 324 deletions(-) diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index 908ab5d77c0..5a7e1052b67 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -70,7 +70,7 @@ class BanSensor(SensorEntity): return self.ban_dict @property - def state(self): + def native_value(self): """Return the most recently banned IP Address.""" return self.last_ban diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 14f63a99e5d..fa1f18815f1 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -30,10 +30,10 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): """Implementation of a FAst.com sensor.""" _attr_name = "Fast.com Download" - _attr_unit_of_measurement = DATA_RATE_MEGABITS_PER_SECOND + _attr_native_unit_of_measurement = DATA_RATE_MEGABITS_PER_SECOND _attr_icon = ICON _attr_should_poll = False - _attr_state = None + _attr_native_value = None def __init__(self, speedtest_data: dict[str, Any]) -> None: """Initialize the sensor.""" @@ -52,14 +52,14 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): state = await self.async_get_last_state() if not state: return - self._attr_state = state.state + self._attr_native_value = state.state def update(self) -> None: """Get the latest data and update the states.""" data = self._speedtest_data.data # type: ignore[attr-defined] if data is None: return - self._attr_state = data["download"] + self._attr_native_value = data["download"] @callback def _schedule_immediate_update(self) -> None: diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 3161e173b2a..a4b4e744af7 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -85,12 +85,12 @@ class FibaroSensor(FibaroDevice, SensorEntity): self._unit = self.fibaro_device.properties.unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.current_value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 55ec455d8f1..0723e097967 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -109,12 +109,12 @@ class FidoSensor(SensorEntity): return f"{self.client_name} {self._number} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index 5d8a9475235..73b262c9090 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -62,7 +62,7 @@ class FileSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @@ -72,7 +72,7 @@ class FileSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 856b29364ae..dc44d3d8255 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -63,7 +63,7 @@ class Filesize(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the size of the file in MB.""" decimals = 2 state_mb = round(self._size / 1e6, decimals) @@ -84,6 +84,6 @@ class Filesize(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index c40c703b846..f9705887549 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -332,7 +332,7 @@ class SensorFilter(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -342,7 +342,7 @@ class SensorFilter(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit_of_measurement of the device.""" return self._unit_of_measurement diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 9159e0df49a..d584bbed4bb 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -179,8 +179,8 @@ class FinTsAccount(SensorEntity): """Get the current balance and currency for the account.""" bank = self._client.client balance = bank.get_balance(self._account) - self._attr_state = balance.amount.amount - self._attr_unit_of_measurement = balance.amount.currency + self._attr_native_value = balance.amount.amount + self._attr_native_unit_of_measurement = balance.amount.currency _LOGGER.debug("updated balance of account %s", self.name) @@ -198,13 +198,13 @@ class FinTsHoldingsAccount(SensorEntity): self._account = account self._holdings: list[Any] = [] self._attr_icon = ICON - self._attr_unit_of_measurement = "EUR" + self._attr_native_unit_of_measurement = "EUR" def update(self) -> None: """Get the current holdings for the account.""" bank = self._client.client self._holdings = bank.get_holdings(self._account) - self._attr_state = sum(h.total_value for h in self._holdings) + self._attr_native_value = sum(h.total_value for h in self._holdings) @property def extra_state_attributes(self) -> dict: diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 58b3239331c..ec446621212 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -49,7 +49,7 @@ class IncidentsSensor(RestoreEntity, SensorEntity): return "mdi:fire-truck" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/firmata/sensor.py b/homeassistant/components/firmata/sensor.py index fedac6f76d9..b46e96f3c25 100644 --- a/homeassistant/components/firmata/sensor.py +++ b/homeassistant/components/firmata/sensor.py @@ -54,6 +54,6 @@ class FirmataSensor(FirmataPinEntity, SensorEntity): await self._api.stop_pin() @property - def state(self) -> int: + def native_value(self) -> int: """Return sensor state.""" return self._api.state diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 9f99b3d0bb0..0bd4ed36199 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -374,12 +374,12 @@ class FitbitSensor(SensorEntity): return self._name @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 9214dd6907e..3108f7d3272 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -64,12 +64,12 @@ class ExchangeRateSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._target @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index ab628e205c7..938507e4b0c 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -36,7 +36,7 @@ async def async_setup_entry( class FlickPricingSensor(SensorEntity): """Entity object for Flick Electric sensor.""" - _attr_unit_of_measurement = UNIT_NAME + _attr_native_unit_of_measurement = UNIT_NAME def __init__(self, api: FlickAPI) -> None: """Entity object for Flick Electric sensor.""" @@ -53,7 +53,7 @@ class FlickPricingSensor(SensorEntity): return FRIENDLY_NAME @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._price.price diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index 0504d451e14..b64ed9ee3e4 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -61,7 +61,7 @@ class FloDailyUsageSensor(FloEntity, SensorEntity): """Monitors the daily water usage.""" _attr_icon = WATER_ICON - _attr_unit_of_measurement = VOLUME_GALLONS + _attr_native_unit_of_measurement = VOLUME_GALLONS def __init__(self, device): """Initialize the daily water usage sensor.""" @@ -69,7 +69,7 @@ class FloDailyUsageSensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current daily usage.""" if self._device.consumption_today is None: return None @@ -85,7 +85,7 @@ class FloSystemModeSensor(FloEntity, SensorEntity): self._state: str = None @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the current system mode.""" if not self._device.current_system_mode: return None @@ -96,7 +96,7 @@ class FloCurrentFlowRateSensor(FloEntity, SensorEntity): """Monitors the current water flow rate.""" _attr_icon = GAUGE_ICON - _attr_unit_of_measurement = "gpm" + _attr_native_unit_of_measurement = "gpm" def __init__(self, device): """Initialize the flow rate sensor.""" @@ -104,7 +104,7 @@ class FloCurrentFlowRateSensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current flow rate.""" if self._device.current_flow_rate is None: return None @@ -115,7 +115,7 @@ class FloTemperatureSensor(FloEntity, SensorEntity): """Monitors the temperature.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_FAHRENHEIT + _attr_native_unit_of_measurement = TEMP_FAHRENHEIT def __init__(self, name, device): """Initialize the temperature sensor.""" @@ -123,7 +123,7 @@ class FloTemperatureSensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current temperature.""" if self._device.temperature is None: return None @@ -134,7 +134,7 @@ class FloHumiditySensor(FloEntity, SensorEntity): """Monitors the humidity.""" _attr_device_class = DEVICE_CLASS_HUMIDITY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device): """Initialize the humidity sensor.""" @@ -142,7 +142,7 @@ class FloHumiditySensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current humidity.""" if self._device.humidity is None: return None @@ -153,7 +153,7 @@ class FloPressureSensor(FloEntity, SensorEntity): """Monitors the water pressure.""" _attr_device_class = DEVICE_CLASS_PRESSURE - _attr_unit_of_measurement = PRESSURE_PSI + _attr_native_unit_of_measurement = PRESSURE_PSI def __init__(self, device): """Initialize the pressure sensor.""" @@ -161,7 +161,7 @@ class FloPressureSensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current water pressure.""" if self._device.current_psi is None: return None @@ -172,7 +172,7 @@ class FloBatterySensor(FloEntity, SensorEntity): """Monitors the battery level for battery-powered leak detectors.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device): """Initialize the battery sensor.""" @@ -180,6 +180,6 @@ class FloBatterySensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current battery level.""" return self._device.battery_level diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index d890443d238..ee67a863be6 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -136,7 +136,7 @@ class FlumeSensor(CoordinatorEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" sensor_key = self._flume_query_sensor[0] if sensor_key not in self._flume_device.values: @@ -145,7 +145,7 @@ class FlumeSensor(CoordinatorEntity, SensorEntity): return _format_state_value(self._flume_device.values[sensor_key]) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" # This is in gallons per SCAN_INTERVAL return self._flume_query_sensor[1]["unit_of_measurement"] diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index 88fb0147296..e28419c5d06 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -116,7 +116,7 @@ class FluNearYouSensor(CoordinatorEntity, SensorEntity): f"{entry.data[CONF_LATITUDE]}," f"{entry.data[CONF_LONGITUDE]}_{sensor_type}" ) - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit self._entry = entry self._sensor_type = sensor_type @@ -149,7 +149,7 @@ class CdcSensor(FluNearYouSensor): ATTR_STATE: self.coordinator.data["name"], } ) - self._attr_state = self.coordinator.data[self._sensor_type] + self._attr_native_value = self.coordinator.data[self._sensor_type] class UserSensor(FluNearYouSensor): @@ -181,7 +181,7 @@ class UserSensor(FluNearYouSensor): ] = self.coordinator.data["state"]["last_week_data"][states_key] if self._sensor_type == SENSOR_TYPE_USER_TOTAL: - self._attr_state = sum( + self._attr_native_value = sum( v for k, v in self.coordinator.data["local"].items() if k @@ -194,4 +194,4 @@ class UserSensor(FluNearYouSensor): ) ) else: - self._attr_state = self.coordinator.data["local"][self._sensor_type] + self._attr_native_value = self.coordinator.data["local"][self._sensor_type] diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py index 707f22f98ba..c7257d40237 100644 --- a/homeassistant/components/folder/sensor.py +++ b/homeassistant/components/folder/sensor.py @@ -79,7 +79,7 @@ class Folder(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" decimals = 2 size_mb = round(self._size / 1e6, decimals) @@ -102,6 +102,6 @@ class Folder(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index d635f231818..dd9f086d0d9 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -126,7 +126,7 @@ class FoobotSensor(SensorEntity): return SENSOR_TYPES[self.type][2] @property - def state(self): + def native_value(self): """Return the state of the device.""" try: data = self.foobot_data.data[self.type] @@ -140,7 +140,7 @@ class FoobotSensor(SensorEntity): return f"{self._uuid}_{self.type}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity.""" return self._unit_of_measurement diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index 7ae6fe01d42..ea76ed7da2a 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -30,14 +30,14 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( name="Estimated Energy Production - Today", state=lambda estimate: estimate.energy_production_today / 1000, device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), ForecastSolarSensorEntityDescription( key="energy_production_tomorrow", name="Estimated Energy Production - Tomorrow", state=lambda estimate: estimate.energy_production_tomorrow / 1000, device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), ForecastSolarSensorEntityDescription( key="power_highest_peak_time_today", @@ -55,7 +55,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( device_class=DEVICE_CLASS_POWER, state=lambda estimate: estimate.power_production_now, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), ForecastSolarSensorEntityDescription( key="power_production_next_hour", @@ -65,7 +65,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( name="Estimated Power Production - Next Hour", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), ForecastSolarSensorEntityDescription( key="power_production_next_12hours", @@ -75,7 +75,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( name="Estimated Power Production - Next 12 Hours", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), ForecastSolarSensorEntityDescription( key="power_production_next_24hours", @@ -85,20 +85,20 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( name="Estimated Power Production - Next 24 Hours", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), ForecastSolarSensorEntityDescription( key="energy_current_hour", name="Estimated Energy Production - This Hour", state=lambda estimate: estimate.energy_current_hour / 1000, device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), ForecastSolarSensorEntityDescription( key="energy_next_hour", state=lambda estimate: estimate.sum_energy_production(1) / 1000, name="Estimated Energy Production - Next Hour", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), ) diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 5d3f440f4b6..29ba14ac463 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -60,7 +60,7 @@ class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): } @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" if self.entity_description.state is None: state: StateType | datetime = getattr( diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index e68f7208538..939c53b47db 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -109,12 +109,12 @@ class FreeboxSensor(SensorEntity): return self._name @property - def state(self) -> str: + def native_value(self) -> str: """Return the state.""" return self._state @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit.""" return self._unit diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py index 0c12f20849c..e5322924864 100644 --- a/homeassistant/components/freedompro/sensor.py +++ b/homeassistant/components/freedompro/sensor.py @@ -64,8 +64,8 @@ class Device(CoordinatorEntity, SensorEntity): } self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] self._attr_state_class = STATE_CLASS_MAP[device["type"]] - self._attr_unit_of_measurement = UNIT_MAP[device["type"]] - self._attr_state = 0 + self._attr_native_unit_of_measurement = UNIT_MAP[device["type"]] + self._attr_native_value = 0 @callback def _handle_coordinator_update(self) -> None: @@ -80,7 +80,7 @@ class Device(CoordinatorEntity, SensorEntity): ) if device is not None and "state" in device: state = device["state"] - self._attr_state = state[DEVICE_KEY_MAP[self._type]] + self._attr_native_value = state[DEVICE_KEY_MAP[self._type]] super()._handle_coordinator_update() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index c7d3fc243a5..cbbaa40aaa6 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -290,7 +290,9 @@ class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): self._attr_icon = self._sensor_data.get("icon") self._attr_name = f"{device_friendly_name} {self._sensor_data['name']}" self._attr_state_class = self._sensor_data.get("state_class") - self._attr_unit_of_measurement = self._sensor_data.get("unit_of_measurement") + self._attr_native_unit_of_measurement = self._sensor_data.get( + "unit_of_measurement" + ) self._attr_unique_id = f"{fritzbox_tools.unique_id}-{sensor_type}" super().__init__(fritzbox_tools, device_friendly_name) @@ -311,7 +313,7 @@ class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): self._attr_available = False return - self._attr_state = self._last_device_value = self._state_provider( + self._attr_native_value = self._last_device_value = self._state_provider( status, self._last_device_value ) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index cef325a61f3..ce5e74cfeec 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -12,7 +12,6 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, @@ -139,7 +138,6 @@ class FritzBoxEntity(CoordinatorEntity): self.ain = ain self._name = entity_info[ATTR_NAME] self._unique_id = entity_info[ATTR_ENTITY_ID] - self._unit_of_measurement = entity_info[ATTR_UNIT_OF_MEASUREMENT] self._device_class = entity_info[ATTR_DEVICE_CLASS] self._attr_state_class = entity_info[ATTR_STATE_CLASS] @@ -174,11 +172,6 @@ class FritzBoxEntity(CoordinatorEntity): """Return the name of the device.""" return self._name - @property - def unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - return self._unit_of_measurement - @property def device_class(self) -> str | None: """Return the device class.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 9d78afca4de..01bea17fb3c 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import datetime +from pyfritzhome import FritzhomeDevice + from homeassistant.components.sensor import ( ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT, @@ -25,6 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import utc_from_timestamp from . import FritzBoxEntity @@ -34,7 +37,7 @@ from .const import ( CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN, ) -from .model import SensorExtraAttributes +from .model import EntityInfo, SensorExtraAttributes async def async_setup_entry( @@ -106,16 +109,30 @@ async def async_setup_entry( async_add_entities(entities) -class FritzBoxBatterySensor(FritzBoxEntity, SensorEntity): +class FritzBoxSensor(FritzBoxEntity, SensorEntity): + """The entity class for FRITZ!SmartHome sensors.""" + + def __init__( + self, + entity_info: EntityInfo, + coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], + ain: str, + ) -> None: + """Initialize the FritzBox entity.""" + FritzBoxEntity.__init__(self, entity_info, coordinator, ain) + self._attr_native_unit_of_measurement = entity_info[ATTR_UNIT_OF_MEASUREMENT] + + +class FritzBoxBatterySensor(FritzBoxSensor): """The entity class for FRITZ!SmartHome battery sensors.""" @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self.device.battery_level # type: ignore [no-any-return] -class FritzBoxPowerSensor(FritzBoxEntity, SensorEntity): +class FritzBoxPowerSensor(FritzBoxSensor): """The entity class for FRITZ!SmartHome power consumption sensors.""" @property @@ -126,7 +143,7 @@ class FritzBoxPowerSensor(FritzBoxEntity, SensorEntity): return 0.0 -class FritzBoxEnergySensor(FritzBoxEntity, SensorEntity): +class FritzBoxEnergySensor(FritzBoxSensor): """The entity class for FRITZ!SmartHome total energy sensors.""" @property @@ -143,11 +160,11 @@ class FritzBoxEnergySensor(FritzBoxEntity, SensorEntity): return utc_from_timestamp(0) -class FritzBoxTempSensor(FritzBoxEntity, SensorEntity): +class FritzBoxTempSensor(FritzBoxSensor): """The entity class for FRITZ!SmartHome temperature sensors.""" @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" return self.device.temperature # type: ignore [no-any-return] diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 63b3cd81aa5..31e04077656 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -158,7 +158,7 @@ class FritzBoxCallSensor(SensorEntity): return self._fritzbox_phonebook is not None @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 211fdaabafd..68430684d85 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -308,8 +308,8 @@ class FroniusTemplateSensor(SensorEntity): state = self._parent.data.get(self._key) self._attr_state = state.get("value") if isinstance(self._attr_state, float): - self._attr_state = round(self._attr_state, 2) - self._attr_unit_of_measurement = state.get("unit") + self._attr_native_value = round(self._attr_state, 2) + self._attr_native_unit_of_measurement = state.get("unit") @property def last_reset(self) -> dt.dt.datetime | None: diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index ed01862aba4..da3a7a4dc24 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -76,7 +76,7 @@ class GaragesamsterdamSensor(CoordinatorEntity, SensorEntity): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return getattr(self.coordinator.data[self._garage_name], self._info_type) @@ -86,7 +86,7 @@ class GaragesamsterdamSensor(CoordinatorEntity, SensorEntity): return SENSORS[self._info_type] @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return unit of measurement.""" return "cars" diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 2e4759088fc..8b4c60046db 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -105,7 +105,7 @@ class GdacsSensor(SensorEntity): self._removed = status_info.removed @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._total @@ -125,7 +125,7 @@ class GdacsSensor(SensorEntity): return DEFAULT_ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return DEFAULT_UNIT_OF_MEASUREMENT diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index 0c96ec595b6..362e729f57a 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -79,12 +79,12 @@ class GeniusBattery(GeniusDevice, SensorEntity): return DEVICE_CLASS_BATTERY @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of the sensor.""" return PERCENTAGE @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" level = self._device.data["state"][self._state_attr] return level if level != 255 else 0 @@ -105,7 +105,7 @@ class GeniusIssue(GeniusEntity, SensorEntity): self._issues = [] @property - def state(self) -> str: + def native_value(self) -> str: """Return the number of issues.""" return len(self._issues) diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index df5f11850fd..f5797121603 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -121,12 +121,12 @@ class GeoRssServiceSensor(SensorEntity): return f"{self._service_name} {'Any' if self._category is None else self._category}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index 94c7965663a..605f56b1272 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -106,7 +106,7 @@ class GeonetnzQuakesSensor(SensorEntity): self._removed = status_info.removed @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._total @@ -126,7 +126,7 @@ class GeonetnzQuakesSensor(SensorEntity): return DEFAULT_ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return DEFAULT_UNIT_OF_MEASUREMENT diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index c0cc6801437..fc9f0f30b2c 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -130,7 +130,7 @@ class GeonetnzVolcanoSensor(SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._alert_level @@ -145,7 +145,7 @@ class GeonetnzVolcanoSensor(SensorEntity): return f"Volcano {self._title}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return "alert level" diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 9b890442166..fb96a08ab5b 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -41,43 +41,43 @@ SENSOR_TYPES: Final[tuple[GiosSensorEntityDescription, ...]] = ( GiosSensorEntityDescription( key=ATTR_C6H6, name="C6H6", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_CO, name="CO", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_NO2, name="NO2", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_O3, name="O3", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_PM10, name="PM10", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_PM25, name="PM2.5", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_SO2, name="SO2", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), ) diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index b651112b9db..c58f08965ec 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -107,7 +107,7 @@ class GiosSensor(CoordinatorEntity, SensorEntity): return self._attrs @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" state = getattr(self.coordinator.data, self.entity_description.key).value assert self.entity_description.value is not None @@ -118,7 +118,7 @@ class GiosAqiSensor(GiosSensor): """Define an GIOS AQI sensor.""" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" return cast( StateType, getattr(self.coordinator.data, self.entity_description.key).value diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index c7812fa621d..fb7a0167d8a 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -108,7 +108,7 @@ class GitHubSensor(SensorEntity): return self._unique_id @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py index 0b619853348..e63e07d6c85 100644 --- a/homeassistant/components/gitlab_ci/sensor.py +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -88,7 +88,7 @@ class GitLabSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 20b68b2e5a9..9e13e155f27 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -65,12 +65,12 @@ class GitterSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index b74662db22b..491dd297a05 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -44,154 +44,154 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( key="disk_use_percent", type="fs", name_suffix="used percent", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", ), GlancesSensorEntityDescription( key="disk_use", type="fs", name_suffix="used", - unit_of_measurement=DATA_GIBIBYTES, + native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:harddisk", ), GlancesSensorEntityDescription( key="disk_free", type="fs", name_suffix="free", - unit_of_measurement=DATA_GIBIBYTES, + native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:harddisk", ), GlancesSensorEntityDescription( key="memory_use_percent", type="mem", name_suffix="RAM used percent", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", ), GlancesSensorEntityDescription( key="memory_use", type="mem", name_suffix="RAM used", - unit_of_measurement=DATA_MEBIBYTES, + native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:memory", ), GlancesSensorEntityDescription( key="memory_free", type="mem", name_suffix="RAM free", - unit_of_measurement=DATA_MEBIBYTES, + native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:memory", ), GlancesSensorEntityDescription( key="swap_use_percent", type="memswap", name_suffix="Swap used percent", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", ), GlancesSensorEntityDescription( key="swap_use", type="memswap", name_suffix="Swap used", - unit_of_measurement=DATA_GIBIBYTES, + native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:memory", ), GlancesSensorEntityDescription( key="swap_free", type="memswap", name_suffix="Swap free", - unit_of_measurement=DATA_GIBIBYTES, + native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:memory", ), GlancesSensorEntityDescription( key="processor_load", type="load", name_suffix="CPU load", - unit_of_measurement="15 min", + native_unit_of_measurement="15 min", icon=CPU_ICON, ), GlancesSensorEntityDescription( key="process_running", type="processcount", name_suffix="Running", - unit_of_measurement="Count", + native_unit_of_measurement="Count", icon=CPU_ICON, ), GlancesSensorEntityDescription( key="process_total", type="processcount", name_suffix="Total", - unit_of_measurement="Count", + native_unit_of_measurement="Count", icon=CPU_ICON, ), GlancesSensorEntityDescription( key="process_thread", type="processcount", name_suffix="Thread", - unit_of_measurement="Count", + native_unit_of_measurement="Count", icon=CPU_ICON, ), GlancesSensorEntityDescription( key="process_sleeping", type="processcount", name_suffix="Sleeping", - unit_of_measurement="Count", + native_unit_of_measurement="Count", icon=CPU_ICON, ), GlancesSensorEntityDescription( key="cpu_use_percent", type="cpu", name_suffix="CPU used", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon=CPU_ICON, ), GlancesSensorEntityDescription( key="temperature_core", type="sensors", name_suffix="Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, ), GlancesSensorEntityDescription( key="temperature_hdd", type="sensors", name_suffix="Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, ), GlancesSensorEntityDescription( key="fan_speed", type="sensors", name_suffix="Fan speed", - unit_of_measurement="RPM", + native_unit_of_measurement="RPM", icon="mdi:fan", ), GlancesSensorEntityDescription( key="battery", type="sensors", name_suffix="Charge", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:battery", ), GlancesSensorEntityDescription( key="docker_active", type="docker", name_suffix="Containers active", - unit_of_measurement="", + native_unit_of_measurement="", icon="mdi:docker", ), GlancesSensorEntityDescription( key="docker_cpu_use", type="docker", name_suffix="Containers CPU used", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:docker", ), GlancesSensorEntityDescription( key="docker_memory_use", type="docker", name_suffix="Containers RAM used", - unit_of_measurement=DATA_MEBIBYTES, + native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:docker", ), ) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index fd31ee37faf..76e2a1c617a 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -83,7 +83,7 @@ class GlancesSensor(SensorEntity): return self.glances_data.available @property - def state(self): + def native_value(self): """Return the state of the resources.""" return self._state diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 594e1f0046b..31eadd55969 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -48,12 +48,12 @@ class YetiSensor(YetiEntity): self._attr_entity_registry_enabled_default = sensor.get(ATTR_DEFAULT_ENABLED) self._attr_last_reset = sensor.get(ATTR_LAST_RESET) self._attr_name = f"{name} {sensor.get(ATTR_NAME)}" + self._attr_native_unit_of_measurement = sensor.get(ATTR_UNIT_OF_MEASUREMENT) self._attr_state_class = sensor.get(ATTR_STATE_CLASS) self._attr_unique_id = f"{server_unique_id}/{sensor_name}" - self._attr_unit_of_measurement = sensor.get(ATTR_UNIT_OF_MEASUREMENT) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state.""" if self.api.data: return self.api.data.get(self._condition) diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index 99edc855733..a9be18d06a6 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -72,7 +72,7 @@ class DoorSensorBattery(GoGoGate2Entity, SensorEntity): return DEVICE_CLASS_BATTERY @property - def state(self): + def native_value(self): """Return the state of the entity.""" door = self._get_door() return door.voltage # This is a percentage, not an absolute voltage @@ -110,13 +110,13 @@ class DoorSensorTemperature(GoGoGate2Entity, SensorEntity): return DEVICE_CLASS_TEMPERATURE @property - def state(self): + def native_value(self): """Return the state of the entity.""" door = self._get_door() return door.temperature @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit_of_measurement.""" return TEMP_CELSIUS diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 6dbe6aa698b..c8cb9d54510 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -196,7 +196,7 @@ class GoogleTravelTimeSensor(SensorEntity): await self.first_update() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._matrix is None: return None @@ -250,7 +250,7 @@ class GoogleTravelTimeSensor(SensorEntity): return res @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 28ec5df7486..4a062edaae2 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -95,7 +95,7 @@ class GoogleWifiSensor(SensorEntity): return self._var_icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._var_units @@ -105,7 +105,7 @@ class GoogleWifiSensor(SensorEntity): return self._api.available @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 2f97f62337c..1b502827996 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -84,7 +84,7 @@ class GpsdSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of GPSD.""" if self.agps_thread.data_stream.mode == 3: return "3D Fix" diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index fac11395c8b..7fbfa717229 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -147,7 +147,7 @@ class CurrentSensor(GEMSensor): """Entity showing power usage on one channel of the monitor.""" _attr_icon = CURRENT_SENSOR_ICON - _attr_unit_of_measurement = UNIT_WATTS + _attr_native_unit_of_measurement = UNIT_WATTS def __init__(self, monitor_serial_number, number, name, net_metering): """Construct the entity.""" @@ -158,7 +158,7 @@ class CurrentSensor(GEMSensor): return monitor.channels[self._number - 1] @property - def state(self): + def native_value(self): """Return the current number of watts being used by the channel.""" if not self._sensor: return None @@ -203,7 +203,7 @@ class PulseCounter(GEMSensor): return monitor.pulse_counters[self._number - 1] @property - def state(self): + def native_value(self): """Return the current rate of change for the given pulse counter.""" if not self._sensor or self._sensor.pulses_per_second is None: return None @@ -225,7 +225,7 @@ class PulseCounter(GEMSensor): return 3600 @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement for this pulse counter.""" return f"{self._counted_quantity}/{self._time_unit}" @@ -253,7 +253,7 @@ class TemperatureSensor(GEMSensor): return monitor.temperature_sensors[self._number - 1] @property - def state(self): + def native_value(self): """Return the current temperature being reported by this sensor.""" if not self._sensor: return None @@ -261,7 +261,7 @@ class TemperatureSensor(GEMSensor): return self._sensor.temperature @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement for this sensor (user specified).""" return self._unit @@ -270,7 +270,7 @@ class VoltageSensor(GEMSensor): """Entity showing voltage.""" _attr_icon = VOLTAGE_ICON - _attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT + _attr_native_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT def __init__(self, monitor_serial_number, number, name): """Construct the entity.""" @@ -281,7 +281,7 @@ class VoltageSensor(GEMSensor): return monitor @property - def state(self): + def native_value(self): """Return the current voltage being reported by this sensor.""" if not self._sensor: return None diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 6eb225e7535..671631c5406 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -60,40 +60,40 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="total_money_today", name="Total money today", api_key="plantMoneyText", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), GrowattSensorEntityDescription( key="total_money_total", name="Money lifetime", api_key="totalMoneyText", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), GrowattSensorEntityDescription( key="total_energy_today", name="Energy Today", api_key="todayEnergy", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="total_output_power", name="Output Power", api_key="invTodayPpv", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="total_energy_output", name="Lifetime energy output", api_key="totalEnergy", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="total_maximum_output", name="Maximum power", api_key="nominalPower", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), ) @@ -103,7 +103,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_energy_today", name="Energy today", api_key="powerToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, precision=1, ), @@ -111,7 +111,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_energy_total", name="Lifetime energy output", api_key="powerTotal", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, precision=1, ), @@ -119,7 +119,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_voltage_input_1", name="Input 1 voltage", api_key="vpv1", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, precision=2, ), @@ -127,7 +127,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_amperage_input_1", name="Input 1 Amperage", api_key="ipv1", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, precision=1, ), @@ -135,7 +135,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_wattage_input_1", name="Input 1 Wattage", api_key="ppv1", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, precision=1, ), @@ -143,7 +143,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_voltage_input_2", name="Input 2 voltage", api_key="vpv2", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, precision=1, ), @@ -151,7 +151,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_amperage_input_2", name="Input 2 Amperage", api_key="ipv2", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, precision=1, ), @@ -159,7 +159,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_wattage_input_2", name="Input 2 Wattage", api_key="ppv2", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, precision=1, ), @@ -167,7 +167,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_voltage_input_3", name="Input 3 voltage", api_key="vpv3", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, precision=1, ), @@ -175,7 +175,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_amperage_input_3", name="Input 3 Amperage", api_key="ipv3", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, precision=1, ), @@ -183,7 +183,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_wattage_input_3", name="Input 3 Wattage", api_key="ppv3", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, precision=1, ), @@ -191,7 +191,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_internal_wattage", name="Internal wattage", api_key="ppv", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, precision=1, ), @@ -199,7 +199,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_reactive_voltage", name="Reactive voltage", api_key="vacr", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, precision=1, ), @@ -207,7 +207,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_inverter_reactive_amperage", name="Reactive amperage", api_key="iacr", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, precision=1, ), @@ -215,14 +215,14 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_frequency", name="AC frequency", api_key="fac", - unit_of_measurement=FREQUENCY_HERTZ, + native_unit_of_measurement=FREQUENCY_HERTZ, precision=1, ), GrowattSensorEntityDescription( key="inverter_current_wattage", name="Output power", api_key="pac", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, precision=1, ), @@ -230,7 +230,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_current_reactive_wattage", name="Reactive wattage", api_key="pacr", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, precision=1, ), @@ -238,7 +238,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_ipm_temperature", name="Intelligent Power Management temperature", api_key="ipmTemperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, precision=1, ), @@ -246,7 +246,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="inverter_temperature", name="Temperature", api_key="temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, precision=1, ), @@ -257,118 +257,118 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="storage_storage_production_today", name="Storage production today", api_key="eBatDisChargeToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="storage_storage_production_lifetime", name="Lifetime Storage production", api_key="eBatDisChargeTotal", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="storage_grid_discharge_today", name="Grid discharged today", api_key="eacDisChargeToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="storage_load_consumption_today", name="Load consumption today", api_key="eopDischrToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="storage_load_consumption_lifetime", name="Lifetime load consumption", api_key="eopDischrTotal", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="storage_grid_charged_today", name="Grid charged today", api_key="eacChargeToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="storage_charge_storage_lifetime", name="Lifetime storaged charged", api_key="eChargeTotal", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="storage_solar_production", name="Solar power production", api_key="ppv", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="storage_battery_percentage", name="Battery percentage", api_key="capacity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_BATTERY, ), GrowattSensorEntityDescription( key="storage_power_flow", name="Storage charging/ discharging(-ve)", api_key="pCharge", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="storage_load_consumption_solar_storage", name="Load consumption(Solar + Storage)", api_key="rateVA", - unit_of_measurement="VA", + native_unit_of_measurement="VA", ), GrowattSensorEntityDescription( key="storage_charge_today", name="Charge today", api_key="eChargeToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="storage_import_from_grid", name="Import from grid", api_key="pAcInPut", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="storage_import_from_grid_today", name="Import from grid today", api_key="eToUserToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="storage_import_from_grid_total", name="Import from grid total", api_key="eToUserTotal", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="storage_load_consumption", name="Load consumption", api_key="outPutPower", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="storage_grid_voltage", name="AC input voltage", api_key="vGrid", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, precision=2, ), @@ -376,7 +376,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="storage_pv_charging_voltage", name="PV charging voltage", api_key="vpv", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, precision=2, ), @@ -384,14 +384,14 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="storage_ac_input_frequency_out", name="AC input frequency", api_key="freqOutPut", - unit_of_measurement=FREQUENCY_HERTZ, + native_unit_of_measurement=FREQUENCY_HERTZ, precision=2, ), GrowattSensorEntityDescription( key="storage_output_voltage", name="Output voltage", api_key="outPutVolt", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, precision=2, ), @@ -399,14 +399,14 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="storage_ac_output_frequency", name="Ac output frequency", api_key="freqGrid", - unit_of_measurement=FREQUENCY_HERTZ, + native_unit_of_measurement=FREQUENCY_HERTZ, precision=2, ), GrowattSensorEntityDescription( key="storage_current_PV", name="Solar charge current", api_key="iAcCharge", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, precision=2, ), @@ -414,7 +414,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="storage_current_1", name="Solar current to storage", api_key="iChargePV1", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, precision=2, ), @@ -422,7 +422,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="storage_grid_amperage_input", name="Grid charge current", api_key="chgCurr", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, precision=2, ), @@ -430,7 +430,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="storage_grid_out_current", name="Grid out current", api_key="outPutCurrent", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, precision=2, ), @@ -438,7 +438,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="storage_battery_voltage", name="Battery voltage", api_key="vBat", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, precision=2, ), @@ -446,7 +446,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="storage_load_percentage", name="Load percentage", api_key="loadPercent", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_BATTERY, precision=2, ), @@ -458,77 +458,77 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="mix_statement_of_charge", name="Statement of charge", api_key="capacity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_BATTERY, ), GrowattSensorEntityDescription( key="mix_battery_charge_today", name="Battery charged today", api_key="eBatChargeToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_battery_charge_lifetime", name="Lifetime battery charged", api_key="eBatChargeTotal", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_battery_discharge_today", name="Battery discharged today", api_key="eBatDisChargeToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_battery_discharge_lifetime", name="Lifetime battery discharged", api_key="eBatDisChargeTotal", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_solar_generation_today", name="Solar energy today", api_key="epvToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_solar_generation_lifetime", name="Lifetime solar energy", api_key="epvTotal", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_battery_discharge_w", name="Battery discharging W", api_key="pDischarge1", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="mix_battery_voltage", name="Battery voltage", api_key="vbat", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, ), GrowattSensorEntityDescription( key="mix_pv1_voltage", name="PV1 voltage", api_key="vpv1", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, ), GrowattSensorEntityDescription( key="mix_pv2_voltage", name="PV2 voltage", api_key="vpv2", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, ), # Values from 'mix_totals' API call @@ -536,28 +536,28 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="mix_load_consumption_today", name="Load consumption today", api_key="elocalLoadToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_load_consumption_lifetime", name="Lifetime load consumption", api_key="elocalLoadTotal", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_export_to_grid_today", name="Export to grid today", api_key="etoGridToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_export_to_grid_lifetime", name="Lifetime export to grid", api_key="etogridTotal", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), # Values from 'mix_system_status' API call @@ -565,63 +565,63 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="mix_battery_charge", name="Battery charging", api_key="chargePower", - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="mix_load_consumption", name="Load consumption", api_key="pLocalLoad", - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="mix_wattage_pv_1", name="PV1 Wattage", api_key="pPv1", - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="mix_wattage_pv_2", name="PV2 Wattage", api_key="pPv2", - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="mix_wattage_pv_all", name="All PV Wattage", api_key="ppv", - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="mix_export_to_grid", name="Export to grid", api_key="pactogrid", - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="mix_import_from_grid", name="Import from grid", api_key="pactouser", - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="mix_battery_discharge_kw", name="Battery discharging kW", api_key="pdisCharge1", - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, ), GrowattSensorEntityDescription( key="mix_grid_voltage", name="Grid voltage", api_key="vAc1", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, ), # Values from 'mix_detail' API call @@ -629,35 +629,35 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="mix_system_production_today", name="System production today (self-consumption + export)", api_key="eCharge", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_load_consumption_solar_today", name="Load consumption today (solar)", api_key="eChargeToday", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_self_consumption_today", name="Self consumption today (solar + battery)", api_key="eChargeToday1", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_load_consumption_battery_today", name="Load consumption today (battery)", api_key="echarge1", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), GrowattSensorEntityDescription( key="mix_import_from_grid_today", name="Import from grid today (load)", api_key="etouser", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), # This sensor is manually created using the most recent X-Axis value from the chartData @@ -665,7 +665,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="mix_last_update", name="Last Data Update", api_key="lastdataupdate", - unit_of_measurement=None, + native_unit_of_measurement=None, device_class=DEVICE_CLASS_TIMESTAMP, ), # Values from 'dashboard_data' API call @@ -673,7 +673,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="mix_import_from_grid_today_combined", name="Import from grid today (load + charging)", api_key="etouser_combined", # This id is not present in the raw API data, it is added by the sensor - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), ) @@ -774,7 +774,7 @@ class GrowattInverter(SensorEntity): self._attr_icon = "mdi:solar-power" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" result = self.probe.get_data(self.entity_description.api_key) if self.entity_description.precision is not None: diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 812e6a58f28..f8f89b1ea36 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -559,7 +559,7 @@ class GTFSDepartureSensor(SensorEntity): return self._name @property - def state(self) -> str | None: # type: ignore + def native_value(self) -> str | None: # type: ignore """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 2d7cde86cca..ed3cfedba0e 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -126,15 +126,15 @@ class PairedSensorSensor(PairedSensorEntity, SensorEntity): """Initialize.""" super().__init__(entry, coordinator, kind, name, device_class, icon) - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" if self._kind == SENSOR_KIND_BATTERY: - self._attr_state = self.coordinator.data["battery"] + self._attr_native_value = self.coordinator.data["battery"] elif self._kind == SENSOR_KIND_TEMPERATURE: - self._attr_state = self.coordinator.data["temperature"] + self._attr_native_value = self.coordinator.data["temperature"] class ValveControllerSensor(ValveControllerEntity, SensorEntity): @@ -153,7 +153,7 @@ class ValveControllerSensor(ValveControllerEntity, SensorEntity): """Initialize.""" super().__init__(entry, coordinators, kind, name, device_class, icon) - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit async def _async_continue_entity_setup(self) -> None: """Register API interest (and related tasks) when the entity is added.""" @@ -167,11 +167,13 @@ class ValveControllerSensor(ValveControllerEntity, SensorEntity): self._attr_available = self.coordinators[ API_SYSTEM_ONBOARD_SENSOR_STATUS ].last_update_success - self._attr_state = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ - "temperature" - ] + self._attr_native_value = self.coordinators[ + API_SYSTEM_ONBOARD_SENSOR_STATUS + ].data["temperature"] elif self._kind == SENSOR_KIND_UPTIME: self._attr_available = self.coordinators[ API_SYSTEM_DIAGNOSTICS ].last_update_success - self._attr_state = self.coordinators[API_SYSTEM_DIAGNOSTICS].data["uptime"] + self._attr_native_value = self.coordinators[API_SYSTEM_DIAGNOSTICS].data[ + "uptime" + ] diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 52748ddadad..eb42426e8ea 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -155,12 +155,12 @@ class HabitipySensor(SensorEntity): return f"{DOMAIN}_{self._name}_{self._sensor_name}" @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._sensor_type.unit @@ -195,7 +195,7 @@ class HabitipyTaskSensor(SensorEntity): return f"{DOMAIN}_{self._name}_{self._task_name}" @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -220,6 +220,6 @@ class HabitipyTaskSensor(SensorEntity): return attrs @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._task_type.unit diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index e81980d78e1..c0c3e63715c 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -39,7 +39,7 @@ class HassioAddonSensor(HassioAddonEntity, SensorEntity): """Sensor to track a Hass.io add-on attribute.""" @property - def state(self) -> str: + def native_value(self) -> str: """Return state of entity.""" return self.addon_info[self.attribute_name] @@ -48,6 +48,6 @@ class HassioOSSensor(HassioOSEntity, SensorEntity): """Sensor to track a Hass.io add-on attribute.""" @property - def state(self) -> str: + def native_value(self) -> str: """Return state of entity.""" return self.os_info[self.attribute_name] diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index 55b369c2fde..738837989b9 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -69,12 +69,12 @@ class HaveIBeenPwnedSensor(SensorEntity): return f"Breaches {self._email}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 8169fa811e0..49d1c2f28fa 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -78,7 +78,7 @@ class HddTempSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -88,7 +88,7 @@ class HddTempSensor(SensorEntity): return DEVICE_CLASS_TEMPERATURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 11fd19bd895..7606a2772d6 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -256,7 +256,7 @@ class HERETravelTimeSensor(SensorEntity): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" if self._here_data.traffic_mode and self._here_data.traffic_time is not None: return str(round(self._here_data.traffic_time / 60)) @@ -292,7 +292,7 @@ class HERETravelTimeSensor(SensorEntity): return res @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index e8ff9afc4e3..0db311b0354 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -153,7 +153,7 @@ class HistoryStatsSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.value is None or self.count is None: return None @@ -168,7 +168,7 @@ class HistoryStatsSensor(SensorEntity): return self.count @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index f21afc51801..5ea81bff123 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -57,7 +57,7 @@ class HiveSensorEntity(HiveEntity, SensorEntity): return DEVICETYPE[self.device["hiveType"]].get("type") @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return DEVICETYPE[self.device["hiveType"]].get("unit") @@ -67,7 +67,7 @@ class HiveSensorEntity(HiveEntity, SensorEntity): return self.device["haName"] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.device["status"]["state"] diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 463de6cda51..373ad6be295 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -42,7 +42,7 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): self._sign = sign @property - def state(self): + def native_value(self): """Return true if the binary sensor is on.""" return self._state @@ -83,7 +83,7 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): _LOGGER.debug("Updated, new state: %s", self._state) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 2de80eefd7e..b599e7263c8 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -79,7 +79,7 @@ class HomeKitHumiditySensor(HomeKitEntity, SensorEntity): """Representation of a Homekit humidity sensor.""" _attr_device_class = DEVICE_CLASS_HUMIDITY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -96,7 +96,7 @@ class HomeKitHumiditySensor(HomeKitEntity, SensorEntity): return HUMIDITY_ICON @property - def state(self): + def native_value(self): """Return the current humidity.""" return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) @@ -105,7 +105,7 @@ class HomeKitTemperatureSensor(HomeKitEntity, SensorEntity): """Representation of a Homekit temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -122,7 +122,7 @@ class HomeKitTemperatureSensor(HomeKitEntity, SensorEntity): return TEMP_C_ICON @property - def state(self): + def native_value(self): """Return the current temperature in Celsius.""" return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT) @@ -131,7 +131,7 @@ class HomeKitLightSensor(HomeKitEntity, SensorEntity): """Representation of a Homekit light level sensor.""" _attr_device_class = DEVICE_CLASS_ILLUMINANCE - _attr_unit_of_measurement = LIGHT_LUX + _attr_native_unit_of_measurement = LIGHT_LUX def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -148,7 +148,7 @@ class HomeKitLightSensor(HomeKitEntity, SensorEntity): return BRIGHTNESS_ICON @property - def state(self): + def native_value(self): """Return the current light level in lux.""" return self.service.value(CharacteristicsTypes.LIGHT_LEVEL_CURRENT) @@ -157,7 +157,7 @@ class HomeKitCarbonDioxideSensor(HomeKitEntity, SensorEntity): """Representation of a Homekit Carbon Dioxide sensor.""" _attr_icon = CO2_ICON - _attr_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -169,7 +169,7 @@ class HomeKitCarbonDioxideSensor(HomeKitEntity, SensorEntity): return f"{super().name} CO2" @property - def state(self): + def native_value(self): """Return the current CO2 level in ppm.""" return self.service.value(CharacteristicsTypes.CARBON_DIOXIDE_LEVEL) @@ -178,7 +178,7 @@ class HomeKitBatterySensor(HomeKitEntity, SensorEntity): """Representation of a Homekit battery sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -229,7 +229,7 @@ class HomeKitBatterySensor(HomeKitEntity, SensorEntity): return self.service.value(CharacteristicsTypes.CHARGING_STATE) == 1 @property - def state(self): + def native_value(self): """Return the current battery level percentage.""" return self.service.value(CharacteristicsTypes.BATTERY_LEVEL) @@ -281,7 +281,7 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): return self._state_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return units for the sensor.""" return self._unit @@ -296,7 +296,7 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): return f"{super().name} - {self._name}" @property - def state(self): + def native_value(self): """Return the current sensor value.""" return self._char.value diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index ad62001d5f9..7cfe0ffc944 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -107,7 +107,7 @@ class HMSensor(HMDevice, SensorEntity): """Representation of a HomeMatic sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" # Does a cast exist for this class? name = self._hmdevice.__class__.__name__ @@ -118,7 +118,7 @@ class HMSensor(HMDevice, SensorEntity): return self._hm_get_state() @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return HM_UNIT_HA_CAST.get(self._state) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 475df8ec2af..df8ed33ded0 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -137,12 +137,12 @@ class HomematicipAccesspointDutyCycle(HomematicipGenericEntity, SensorEntity): return "mdi:access-point-network" @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the access point.""" return self._device.dutyCycleLevel @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return PERCENTAGE @@ -164,14 +164,14 @@ class HomematicipHeatingThermostat(HomematicipGenericEntity, SensorEntity): return "mdi:radiator" @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the radiator valve.""" if self._device.valveState != ValveState.ADAPTION_DONE: return self._device.valveState return round(self._device.valvePosition * 100) @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return PERCENTAGE @@ -189,12 +189,12 @@ class HomematicipHumiditySensor(HomematicipGenericEntity, SensorEntity): return DEVICE_CLASS_HUMIDITY @property - def state(self) -> int: + def native_value(self) -> int: """Return the state.""" return self._device.humidity @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return PERCENTAGE @@ -212,7 +212,7 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): return DEVICE_CLASS_TEMPERATURE @property - def state(self) -> float: + def native_value(self) -> float: """Return the state.""" if hasattr(self._device, "valveActualTemperature"): return self._device.valveActualTemperature @@ -220,7 +220,7 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): return self._device.actualTemperature @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return TEMP_CELSIUS @@ -249,7 +249,7 @@ class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): return DEVICE_CLASS_ILLUMINANCE @property - def state(self) -> float: + def native_value(self) -> float: """Return the state.""" if hasattr(self._device, "averageIllumination"): return self._device.averageIllumination @@ -257,7 +257,7 @@ class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): return self._device.illumination @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return LIGHT_LUX @@ -287,12 +287,12 @@ class HomematicipPowerSensor(HomematicipGenericEntity, SensorEntity): return DEVICE_CLASS_POWER @property - def state(self) -> float: + def native_value(self) -> float: """Return the power consumption value.""" return self._device.currentPowerConsumption @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return POWER_WATT @@ -305,12 +305,12 @@ class HomematicipWindspeedSensor(HomematicipGenericEntity, SensorEntity): super().__init__(hap, device, post="Windspeed") @property - def state(self) -> float: + def native_value(self) -> float: """Return the wind speed value.""" return self._device.windSpeed @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return SPEED_KILOMETERS_PER_HOUR @@ -338,12 +338,12 @@ class HomematicipTodayRainSensor(HomematicipGenericEntity, SensorEntity): super().__init__(hap, device, post="Today Rain") @property - def state(self) -> float: + def native_value(self) -> float: """Return the today's rain value.""" return round(self._device.todayRainCounter, 2) @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return LENGTH_MILLIMETERS @@ -352,7 +352,7 @@ class HomematicipPassageDetectorDeltaCounter(HomematicipGenericEntity, SensorEnt """Representation of the HomematicIP passage detector delta counter.""" @property - def state(self) -> int: + def native_value(self) -> int: """Return the passage detector delta counter value.""" return self._device.leftRightCounterDelta diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index 297bfa5264f..5a44a2937e8 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -133,12 +133,12 @@ class HpIloSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of the sensor.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py index ccbe6a31de2..4f93ecbc42d 100644 --- a/homeassistant/components/htu21d/sensor.py +++ b/homeassistant/components/htu21d/sensor.py @@ -98,12 +98,12 @@ class HTU21DSensor(SensorEntity): return self._name @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of the sensor.""" return self._unit_of_measurement diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 4340d5912c9..47987e5607e 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -426,7 +426,7 @@ class HuaweiLteSensor(HuaweiLteBaseEntity, SensorEntity): return f"{self.key}.{self.item}" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return sensor state.""" return self._state @@ -436,7 +436,7 @@ class HuaweiLteSensor(HuaweiLteBaseEntity, SensorEntity): return self.meta.device_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return sensor's unit of measurement.""" return self.meta.unit or self._unit diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index a512012bc68..80658fff21e 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -42,10 +42,10 @@ class HueLightLevel(GenericHueGaugeSensorEntity): """The light level sensor entity for a Hue motion sensor device.""" _attr_device_class = DEVICE_CLASS_ILLUMINANCE - _attr_unit_of_measurement = LIGHT_LUX + _attr_native_unit_of_measurement = LIGHT_LUX @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.sensor.lightlevel is None: return None @@ -78,10 +78,10 @@ class HueTemperature(GenericHueGaugeSensorEntity): _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.sensor.temperature is None: return None @@ -94,7 +94,7 @@ class HueBattery(GenericHueSensor, SensorEntity): _attr_device_class = DEVICE_CLASS_BATTERY _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE @property def unique_id(self): @@ -102,7 +102,7 @@ class HueBattery(GenericHueSensor, SensorEntity): return f"{self.sensor.uniqueid}-battery" @property - def state(self): + def native_value(self): """Return the state of the battery.""" return self.sensor.battery diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 3cda3cdec00..6f18ad27796 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -75,7 +75,7 @@ class HuisbaasjeSensor(CoordinatorEntity, SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.coordinator.data[self._source_type][self._sensor_type] is not None: return round( @@ -85,7 +85,7 @@ class HuisbaasjeSensor(CoordinatorEntity, SensorEntity): return None @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index d66671fe1ea..14501a9c528 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -50,7 +50,7 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): """Representation of an shade battery charge sensor.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE @@ -70,7 +70,7 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): return f"{self._unique_id}_charge" @property - def state(self): + def native_value(self): """Get the current value in percentage.""" return round( self._shade.raw_data[SHADE_BATTERY_LEVEL] / SHADE_BATTERY_LEVEL_MAX * 100 diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index a3df466da74..8a188f7dde8 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -177,7 +177,7 @@ class HVVDepartureSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 62108afbded..0e9afb6d729 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -40,12 +40,12 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return DEVICE_MAP[self._sensor_type][ DEVICE_MAP_INDEX.index("UNIT_OF_MEASURE_INDEX") diff --git a/tests/components/fail2ban/test_sensor.py b/tests/components/fail2ban/test_sensor.py index f9c78e14888..0240ffc6d11 100644 --- a/tests/components/fail2ban/test_sensor.py +++ b/tests/components/fail2ban/test_sensor.py @@ -83,6 +83,7 @@ async def test_single_ban(hass): """Test that log is parsed correctly for single ban.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("single_ban")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): @@ -97,6 +98,7 @@ async def test_ipv6_ban(hass): """Test that log is parsed correctly for IPV6 bans.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("ipv6_ban")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): @@ -111,6 +113,7 @@ async def test_multiple_ban(hass): """Test that log is parsed correctly for multiple ban.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("multi_ban")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): @@ -131,6 +134,7 @@ async def test_unban_all(hass): """Test that log is parsed correctly when unbanning.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("unban_all")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): @@ -148,6 +152,7 @@ async def test_unban_one(hass): """Test that log is parsed correctly when unbanning one ip.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("unban_one")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): @@ -166,6 +171,8 @@ async def test_multi_jail(hass): log_parser = BanLogParser("/test/fail2ban.log") sensor1 = BanSensor("fail2ban", "jail_one", log_parser) sensor2 = BanSensor("fail2ban", "jail_two", log_parser) + sensor1.hass = hass + sensor2.hass = hass assert sensor1.name == "fail2ban jail_one" assert sensor2.name == "fail2ban jail_two" mock_fh = mock_open(read_data=fake_log("multi_jail")) @@ -185,6 +192,7 @@ async def test_ban_active_after_update(hass): """Test that ban persists after subsequent update.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("single_ban")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index 6f4b4652e76..9b430fa5fae 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -68,7 +68,7 @@ async def test_setup_get(hass, requests_mock): assert_setup_component(6, "sensor") -def setup_api(data, requests_mock): +def setup_api(hass, data, requests_mock): """Set up API with fake data.""" resource = f"http://localhost{google_wifi.ENDPOINT}" now = datetime(1970, month=1, day=1) @@ -84,6 +84,10 @@ def setup_api(data, requests_mock): "units": cond_list[1], "icon": cond_list[2], } + for name in sensor_dict: + sensor = sensor_dict[name]["sensor"] + sensor.hass = hass + return api, sensor_dict @@ -96,7 +100,7 @@ def fake_delay(hass, ha_delay): def test_name(requests_mock): """Test the name.""" - api, sensor_dict = setup_api(MOCK_DATA, requests_mock) + api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for name in sensor_dict: sensor = sensor_dict[name]["sensor"] test_name = sensor_dict[name]["name"] @@ -105,7 +109,7 @@ def test_name(requests_mock): def test_unit_of_measurement(requests_mock): """Test the unit of measurement.""" - api, sensor_dict = setup_api(MOCK_DATA, requests_mock) + api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for name in sensor_dict: sensor = sensor_dict[name]["sensor"] assert sensor_dict[name]["units"] == sensor.unit_of_measurement @@ -113,7 +117,7 @@ def test_unit_of_measurement(requests_mock): def test_icon(requests_mock): """Test the icon.""" - api, sensor_dict = setup_api(MOCK_DATA, requests_mock) + api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for name in sensor_dict: sensor = sensor_dict[name]["sensor"] assert sensor_dict[name]["icon"] == sensor.icon @@ -121,7 +125,7 @@ def test_icon(requests_mock): def test_state(hass, requests_mock): """Test the initial state.""" - api, sensor_dict = setup_api(MOCK_DATA, requests_mock) + api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for name in sensor_dict: @@ -140,7 +144,7 @@ def test_state(hass, requests_mock): def test_update_when_value_is_none(hass, requests_mock): """Test state gets updated to unknown when sensor returns no data.""" - api, sensor_dict = setup_api(None, requests_mock) + api, sensor_dict = setup_api(hass, None, requests_mock) for name in sensor_dict: sensor = sensor_dict[name]["sensor"] fake_delay(hass, 2) @@ -150,7 +154,7 @@ def test_update_when_value_is_none(hass, requests_mock): def test_update_when_value_changed(hass, requests_mock): """Test state gets updated when sensor returns a new status.""" - api, sensor_dict = setup_api(MOCK_DATA_NEXT, requests_mock) + api, sensor_dict = setup_api(hass, MOCK_DATA_NEXT, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for name in sensor_dict: @@ -173,7 +177,7 @@ def test_update_when_value_changed(hass, requests_mock): def test_when_api_data_missing(hass, requests_mock): """Test state logs an error when data is missing.""" - api, sensor_dict = setup_api(MOCK_DATA_MISSING, requests_mock) + api, sensor_dict = setup_api(hass, MOCK_DATA_MISSING, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for name in sensor_dict: @@ -183,12 +187,12 @@ def test_when_api_data_missing(hass, requests_mock): assert sensor.state == STATE_UNKNOWN -def test_update_when_unavailable(requests_mock): +def test_update_when_unavailable(hass, requests_mock): """Test state updates when Google Wifi unavailable.""" - api, sensor_dict = setup_api(None, requests_mock) + api, sensor_dict = setup_api(hass, None, requests_mock) api.update = Mock( "google_wifi.GoogleWifiAPI.update", - side_effect=update_side_effect(requests_mock), + side_effect=update_side_effect(hass, requests_mock), ) for name in sensor_dict: sensor = sensor_dict[name]["sensor"] @@ -196,8 +200,8 @@ def test_update_when_unavailable(requests_mock): assert sensor.state is None -def update_side_effect(requests_mock): +def update_side_effect(hass, requests_mock): """Mock representation of update function.""" - api, sensor_dict = setup_api(MOCK_DATA, requests_mock) + api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) api.data = None api.available = False From e558b3463e39bb0d7edd0bc89ef6176f2a477130 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 12 Aug 2021 17:40:55 +0200 Subject: [PATCH 351/903] Move temperature conversions to sensor base class (6/8) (#54476) * Move temperature conversions to entity base class (6/8) * Fix tests --- homeassistant/components/radarr/sensor.py | 4 +- homeassistant/components/rainbird/sensor.py | 4 +- homeassistant/components/raincloud/sensor.py | 4 +- .../components/rainforest_eagle/sensor.py | 4 +- .../components/rainmachine/sensor.py | 14 +++--- homeassistant/components/random/sensor.py | 4 +- .../components/recollect_waste/sensor.py | 2 +- homeassistant/components/reddit/sensor.py | 2 +- .../components/rejseplanen/sensor.py | 4 +- homeassistant/components/repetier/sensor.py | 8 ++-- homeassistant/components/rest/sensor.py | 4 +- homeassistant/components/rflink/sensor.py | 4 +- homeassistant/components/rfxtrx/sensor.py | 46 +++++++++---------- homeassistant/components/ring/sensor.py | 8 ++-- homeassistant/components/ripple/sensor.py | 4 +- homeassistant/components/risco/sensor.py | 2 +- .../rituals_perfume_genie/sensor.py | 12 ++--- .../components/rmvtransport/sensor.py | 4 +- homeassistant/components/roomba/sensor.py | 4 +- homeassistant/components/rova/sensor.py | 2 +- homeassistant/components/rtorrent/sensor.py | 4 +- homeassistant/components/sabnzbd/sensor.py | 4 +- homeassistant/components/saj/sensor.py | 4 +- homeassistant/components/scrape/sensor.py | 4 +- .../components/screenlogic/sensor.py | 6 +-- homeassistant/components/season/sensor.py | 2 +- homeassistant/components/sense/sensor.py | 22 ++++----- homeassistant/components/sensehat/sensor.py | 4 +- homeassistant/components/serial/sensor.py | 2 +- homeassistant/components/serial_pm/sensor.py | 4 +- .../components/seventeentrack/sensor.py | 6 +-- homeassistant/components/shelly/sensor.py | 12 ++--- homeassistant/components/shodan/sensor.py | 4 +- homeassistant/components/sht31/sensor.py | 6 +-- homeassistant/components/sigfox/sensor.py | 2 +- homeassistant/components/simplisafe/sensor.py | 4 +- homeassistant/components/simulated/sensor.py | 4 +- homeassistant/components/skybeacon/sensor.py | 8 ++-- homeassistant/components/skybell/sensor.py | 2 +- homeassistant/components/sleepiq/sensor.py | 2 +- homeassistant/components/sma/sensor.py | 4 +- homeassistant/components/smappee/sensor.py | 4 +- .../components/smart_meter_texas/sensor.py | 4 +- .../components/smartthings/sensor.py | 6 +-- homeassistant/components/smarttub/sensor.py | 6 +-- homeassistant/components/smarty/sensor.py | 4 +- homeassistant/components/sms/sensor.py | 4 +- homeassistant/components/snmp/sensor.py | 4 +- homeassistant/components/sochain/sensor.py | 4 +- homeassistant/components/solaredge/const.py | 12 ++--- homeassistant/components/solaredge/sensor.py | 16 +++---- .../components/solaredge_local/sensor.py | 4 +- homeassistant/components/solarlog/sensor.py | 4 +- homeassistant/components/solax/sensor.py | 4 +- homeassistant/components/soma/sensor.py | 4 +- homeassistant/components/somfy/sensor.py | 4 +- homeassistant/components/sonarr/sensor.py | 14 +++--- homeassistant/components/sonos/sensor.py | 4 +- .../components/speedtestdotnet/const.py | 6 +-- .../components/speedtestdotnet/sensor.py | 12 +++-- homeassistant/components/sql/sensor.py | 4 +- homeassistant/components/srp_energy/sensor.py | 4 +- tests/components/sleepiq/test_sensor.py | 5 +- tests/components/srp_energy/test_sensor.py | 4 ++ 64 files changed, 199 insertions(+), 188 deletions(-) diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 407f491f63a..02e898c8e0f 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -127,7 +127,7 @@ class RadarrSensor(SensorEntity): return "{} {}".format("Radarr", self._name) @property - def state(self): + def native_value(self): """Return sensor state.""" return self._state @@ -137,7 +137,7 @@ class RadarrSensor(SensorEntity): return self._available @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of the sensor.""" return self._unit diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 2158bc5cf97..0f6ad41b4e3 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -45,6 +45,6 @@ class RainBirdSensor(SensorEntity): """Get the latest data and updates the states.""" _LOGGER.debug("Updating sensor: %s", self.name) if self.entity_description.key == SENSOR_TYPE_RAINSENSOR: - self._attr_state = self._controller.get_rain_sensor_state() + self._attr_native_value = self._controller.get_rain_sensor_state() elif self.entity_description.key == SENSOR_TYPE_RAINDELAY: - self._attr_state = self._controller.get_rain_delay() + self._attr_native_value = self._controller.get_rain_delay() diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py index ee8f68734ad..c550e43285b 100644 --- a/homeassistant/components/raincloud/sensor.py +++ b/homeassistant/components/raincloud/sensor.py @@ -48,12 +48,12 @@ class RainCloudSensor(RainCloudEntity, SensorEntity): """A sensor implementation for raincloud device.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return UNIT_OF_MEASUREMENT_MAP.get(self._sensor_type) diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 53e94d2070e..64eb243c15d 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -131,7 +131,7 @@ class EagleSensor(SensorEntity): self._type = sensor_type sensor_info = SENSORS[sensor_type] self._attr_name = sensor_info.name - self._attr_unit_of_measurement = sensor_info.unit_of_measurement + self._attr_native_unit_of_measurement = sensor_info.unit_of_measurement self._attr_device_class = sensor_info.device_class self._attr_state_class = sensor_info.state_class self._attr_last_reset = sensor_info.last_reset @@ -139,7 +139,7 @@ class EagleSensor(SensorEntity): def update(self): """Get the energy information from the Rainforest Eagle.""" self.eagle_data.update() - self._attr_state = self.eagle_data.get_state(self._type) + self._attr_native_value = self.eagle_data.get_state(self._type) class EagleData: diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 808c6a06bc2..2316b27acbf 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -134,7 +134,7 @@ class RainMachineSensor(RainMachineEntity, SensorEntity): self._attr_entity_registry_enabled_default = enabled_by_default self._attr_icon = icon self._attr_name = name - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit class ProvisionSettingsSensor(RainMachineSensor): @@ -144,7 +144,7 @@ class ProvisionSettingsSensor(RainMachineSensor): def update_from_latest_data(self) -> None: """Update the state.""" if self._entity_type == TYPE_FLOW_SENSOR_CLICK_M3: - self._attr_state = self.coordinator.data["system"].get( + self._attr_native_value = self.coordinator.data["system"].get( "flowSensorClicksPerCubicMeter" ) elif self._entity_type == TYPE_FLOW_SENSOR_CONSUMED_LITERS: @@ -154,15 +154,15 @@ class ProvisionSettingsSensor(RainMachineSensor): ) if clicks and clicks_per_m3: - self._attr_state = (clicks * 1000) / clicks_per_m3 + self._attr_native_value = (clicks * 1000) / clicks_per_m3 else: - self._attr_state = None + self._attr_native_value = None elif self._entity_type == TYPE_FLOW_SENSOR_START_INDEX: - self._attr_state = self.coordinator.data["system"].get( + self._attr_native_value = self.coordinator.data["system"].get( "flowSensorStartIndex" ) elif self._entity_type == TYPE_FLOW_SENSOR_WATERING_CLICKS: - self._attr_state = self.coordinator.data["system"].get( + self._attr_native_value = self.coordinator.data["system"].get( "flowSensorWateringClicks" ) @@ -174,4 +174,4 @@ class UniversalRestrictionsSensor(RainMachineSensor): def update_from_latest_data(self) -> None: """Update the state.""" if self._entity_type == TYPE_FREEZE_TEMP: - self._attr_state = self.coordinator.data["freezeProtectTemp"] + self._attr_native_value = self.coordinator.data["freezeProtectTemp"] diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 6465b828be1..91a34639de1 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -58,7 +58,7 @@ class RandomSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -68,7 +68,7 @@ class RandomSensor(SensorEntity): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index beb7c182351..304eaafb85f 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -127,4 +127,4 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): ATTR_NEXT_PICKUP_DATE: as_utc(next_pickup_event.date).isoformat(), } ) - self._attr_state = as_utc(pickup_event.date).isoformat() + self._attr_native_value = as_utc(pickup_event.date).isoformat() diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py index 7472ad42301..2e1ec5dc18a 100644 --- a/homeassistant/components/reddit/sensor.py +++ b/homeassistant/components/reddit/sensor.py @@ -99,7 +99,7 @@ class RedditSensor(SensorEntity): return f"reddit_{self._subreddit}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return len(self._subreddit_data) diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 78b713c286c..99e2f90c879 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -105,7 +105,7 @@ class RejseplanenTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -131,7 +131,7 @@ class RejseplanenTransportSensor(SensorEntity): return attributes @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 46818095647..04cff82bcf3 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -77,7 +77,7 @@ class RepetierSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return SENSOR_TYPES[self._sensor_type][1] @@ -92,7 +92,7 @@ class RepetierSensor(SensorEntity): return False @property - def state(self): + def native_value(self): """Return sensor state.""" return self._state @@ -134,7 +134,7 @@ class RepetierTempSensor(RepetierSensor): """Represent a Repetier temp sensor.""" @property - def state(self): + def native_value(self): """Return sensor state.""" if self._state is None: return None @@ -156,7 +156,7 @@ class RepetierJobSensor(RepetierSensor): """Represent a Repetier job sensor.""" @property - def state(self): + def native_value(self): """Return sensor state.""" if self._state is None: return None diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 7727b5f09ab..f0355014986 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -115,12 +115,12 @@ class RestSensor(RestEntity, SensorEntity): self._json_attrs_path = json_attrs_path @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 497c9b8cee6..6b0c9efe157 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -152,12 +152,12 @@ class RflinkSensor(RflinkDevice, SensorEntity): self.handle_event_callback(self._initial_event) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return measurement unit.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return value.""" return self._state diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 8b9d5e5c389..49a8bbb974c 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -78,92 +78,92 @@ SENSOR_TYPES = ( key="Barameter", device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=PRESSURE_HPA, + native_unit_of_measurement=PRESSURE_HPA, ), RfxtrxSensorEntityDescription( key="Battery numeric", device_class=DEVICE_CLASS_BATTERY, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, convert=_battery_convert, ), RfxtrxSensorEntityDescription( key="Current", device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 1", device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 2", device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 3", device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, ), RfxtrxSensorEntityDescription( key="Energy usage", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), RfxtrxSensorEntityDescription( key="Humidity", device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), RfxtrxSensorEntityDescription( key="Rssi numeric", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, convert=_rssi_convert, ), RfxtrxSensorEntityDescription( key="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, ), RfxtrxSensorEntityDescription( key="Temperature2", device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, ), RfxtrxSensorEntityDescription( key="Total usage", device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_MEASUREMENT, last_reset=dt.utc_from_timestamp(0), - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), RfxtrxSensorEntityDescription( key="Voltage", device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, ), RfxtrxSensorEntityDescription( key="Wind direction", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=DEGREE, + native_unit_of_measurement=DEGREE, ), RfxtrxSensorEntityDescription( key="Rain rate", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, ), RfxtrxSensorEntityDescription( key="Sound", @@ -175,34 +175,34 @@ SENSOR_TYPES = ( key="Count", state_class=STATE_CLASS_MEASUREMENT, last_reset=dt.utc_from_timestamp(0), - unit_of_measurement="count", + native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Counter value", state_class=STATE_CLASS_MEASUREMENT, last_reset=dt.utc_from_timestamp(0), - unit_of_measurement="count", + native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Chill", device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, ), RfxtrxSensorEntityDescription( key="Wind average speed", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=SPEED_METERS_PER_SECOND, + native_unit_of_measurement=SPEED_METERS_PER_SECOND, ), RfxtrxSensorEntityDescription( key="Wind gust", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=SPEED_METERS_PER_SECOND, + native_unit_of_measurement=SPEED_METERS_PER_SECOND, ), RfxtrxSensorEntityDescription( key="Rain total", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=LENGTH_MILLIMETERS, + native_unit_of_measurement=LENGTH_MILLIMETERS, ), RfxtrxSensorEntityDescription( key="Forecast", @@ -216,7 +216,7 @@ SENSOR_TYPES = ( RfxtrxSensorEntityDescription( key="UV", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=UV_INDEX, + native_unit_of_measurement=UV_INDEX, ), ) @@ -313,7 +313,7 @@ class RfxtrxSensor(RfxtrxEntity, SensorEntity): self._apply_event(get_rfx_object(event)) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if not self._event: return None diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 97fb8ec9d21..192ba03c010 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -56,7 +56,7 @@ class RingSensor(RingEntityMixin, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._sensor_type == "volume": return self._device.volume @@ -84,7 +84,7 @@ class RingSensor(RingEntityMixin, SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return SENSOR_TYPES.get(self._sensor_type)[2] @@ -120,7 +120,7 @@ class HealthDataRingSensor(RingSensor): return False @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._sensor_type == "wifi_signal_category": return self._device.wifi_signal_category @@ -172,7 +172,7 @@ class HistoryRingSensor(RingSensor): self.async_write_ha_state() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._latest_event is None: return None diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py index f36e2c58ec8..2746f5789cd 100644 --- a/homeassistant/components/ripple/sensor.py +++ b/homeassistant/components/ripple/sensor.py @@ -46,12 +46,12 @@ class RippleSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index b39655949b2..0068e8c0f04 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -87,7 +87,7 @@ class RiscoSensor(CoordinatorEntity, SensorEntity): self.async_write_ha_state() @property - def state(self): + def native_value(self): """Value of sensor.""" if self._event is None: return None diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 7c957722384..c4b330d1ccf 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -59,7 +59,7 @@ class DiffuserPerfumeSensor(DiffuserEntity): return "mdi:tag-remove" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the perfume sensor.""" return self._diffuser.perfume @@ -81,7 +81,7 @@ class DiffuserFillSensor(DiffuserEntity): return "mdi:beaker-question" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the fill sensor.""" return self._diffuser.fill @@ -90,7 +90,7 @@ class DiffuserBatterySensor(DiffuserEntity): """Representation of a diffuser battery sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__( self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator @@ -99,7 +99,7 @@ class DiffuserBatterySensor(DiffuserEntity): super().__init__(diffuser, coordinator, BATTERY_SUFFIX) @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the battery sensor.""" return self._diffuser.battery_percentage @@ -108,7 +108,7 @@ class DiffuserWifiSensor(DiffuserEntity): """Representation of a diffuser wifi sensor.""" _attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__( self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator @@ -117,6 +117,6 @@ class DiffuserWifiSensor(DiffuserEntity): super().__init__(diffuser, coordinator, WIFI_SUFFIX) @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the wifi sensor.""" return self._diffuser.wifi_percentage diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 9e4e7f3d588..bf2eab2d7b7 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -145,7 +145,7 @@ class RMVDepartureSensor(SensorEntity): return self._state is not None @property - def state(self): + def native_value(self): """Return the next departure time.""" return self._state @@ -171,7 +171,7 @@ class RMVDepartureSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 4a99d9f71af..bc20b4397e2 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -36,7 +36,7 @@ class RoombaBattery(IRobotEntity, SensorEntity): return DEVICE_CLASS_BATTERY @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit_of_measurement of the device.""" return PERCENTAGE @@ -50,6 +50,6 @@ class RoombaBattery(IRobotEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._battery_level diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index 35d8c0ae2c0..54e2c315a4e 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -116,7 +116,7 @@ class RovaSensor(SensorEntity): self.data_service.update() pickup_date = self.data_service.data.get(self.entity_description.key) if pickup_date is not None: - self._attr_state = pickup_date.isoformat() + self._attr_native_value = pickup_date.isoformat() class RovaData: diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index c750c7aa83c..5379cb2ce2e 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -94,7 +94,7 @@ class RTorrentSensor(SensorEntity): return f"{self.client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -104,7 +104,7 @@ class RTorrentSensor(SensorEntity): return self._available @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index c0930f2c114..ffe57e608bf 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -45,7 +45,7 @@ class SabnzbdSensor(SensorEntity): return f"{self._client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -55,7 +55,7 @@ class SabnzbdSensor(SensorEntity): return False @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 1b46632051e..795823b9e9f 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -191,12 +191,12 @@ class SAJsensor(SensorEntity): return f"saj_{self._sensor.name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return SAJ_UNIT_MAPPINGS[self._sensor.unit] diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 921ab29f714..1f5b543f9ee 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -108,12 +108,12 @@ class ScrapeSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 1ad18298655..c8e4f84caf0 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -114,7 +114,7 @@ class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): return f"{self.gateway_name} {self.sensor['name']}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.sensor.get("unit") @@ -125,7 +125,7 @@ class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): return SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(device_type) @property - def state(self): + def native_value(self): """State of the sensor.""" value = self.sensor["value"] return (value - 1) if "supply" in self._data_key else value @@ -160,7 +160,7 @@ class ScreenLogicChemistrySensor(ScreenLogicSensor): self._key = key @property - def state(self): + def native_value(self): """State of the sensor.""" value = self.sensor["value"] if "dosing_state" in self._key: diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index 165920dd8e5..80fb71f594b 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -126,7 +126,7 @@ class Season(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the current season.""" return self.season diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 5a352969c3b..69cae55ff31 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -125,7 +125,7 @@ class SenseActiveSensor(SensorEntity): """Implementation of a Sense energy sensor.""" _attr_icon = ICON - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_should_poll = False _attr_available = False @@ -168,9 +168,9 @@ class SenseActiveSensor(SensorEntity): if self._is_production else self._data.active_power ) - if self._attr_available and self._attr_state == new_state: + if self._attr_available and self._attr_native_value == new_state: return - self._attr_state = new_state + self._attr_native_value = new_state self._attr_available = True self.async_write_ha_state() @@ -178,7 +178,7 @@ class SenseActiveSensor(SensorEntity): class SenseVoltageSensor(SensorEntity): """Implementation of a Sense energy voltage sensor.""" - _attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT + _attr_native_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON _attr_should_poll = False @@ -212,10 +212,10 @@ class SenseVoltageSensor(SensorEntity): def _async_update_from_data(self): """Update the sensor from the data. Must not do I/O.""" new_state = round(self._data.active_voltage[self._voltage_index], 1) - if self._attr_available and self._attr_state == new_state: + if self._attr_available and self._attr_native_value == new_state: return self._attr_available = True - self._attr_state = new_state + self._attr_native_value = new_state self.async_write_ha_state() @@ -224,7 +224,7 @@ class SenseTrendsSensor(SensorEntity): _attr_device_class = DEVICE_CLASS_ENERGY _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON _attr_should_poll = False @@ -249,7 +249,7 @@ class SenseTrendsSensor(SensorEntity): self._had_any_update = False @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return round(self._data.get_trend(self._sensor_type, self._is_production), 1) @@ -288,7 +288,7 @@ class SenseEnergyDevice(SensorEntity): _attr_available = False _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_device_class = DEVICE_CLASS_POWER _attr_should_poll = False @@ -320,8 +320,8 @@ class SenseEnergyDevice(SensorEntity): new_state = 0 else: new_state = int(device_data["w"]) - if self._attr_available and self._attr_state == new_state: + if self._attr_available and self._attr_native_value == new_state: return - self._attr_state = new_state + self._attr_native_value = new_state self._attr_available = True self.async_write_ha_state() diff --git a/homeassistant/components/sensehat/sensor.py b/homeassistant/components/sensehat/sensor.py index 379301b0fa7..9274f133441 100644 --- a/homeassistant/components/sensehat/sensor.py +++ b/homeassistant/components/sensehat/sensor.py @@ -86,12 +86,12 @@ class SenseHatSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index 1e73ae9ac83..cbdba50b6b6 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -245,6 +245,6 @@ class SerialSensor(SensorEntity): return self._attributes @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index fd017661de2..9332f268308 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -71,12 +71,12 @@ class ParticulateMatterSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index ab0f0779656..44720db2fcb 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -125,7 +125,7 @@ class SeventeenTrackSummarySensor(SensorEntity): return f"Seventeentrack Packages {self._status}" @property - def state(self): + def native_value(self): """Return the state.""" return self._state @@ -135,7 +135,7 @@ class SeventeenTrackSummarySensor(SensorEntity): return f"summary_{self._data.account_id}_{slugify(self._status)}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return "packages" @@ -211,7 +211,7 @@ class SeventeenTrackPackageSensor(SensorEntity): return f"Seventeentrack Package: {name}" @property - def state(self): + def native_value(self): """Return the state.""" return self._state diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 07e4f4a4fe3..e3af10571d5 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -280,7 +280,7 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): ).replace(second=0, microsecond=0) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return value of sensor.""" if ( self.description.last_reset == LAST_RESET_UPTIME @@ -302,7 +302,7 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): return self.description.state_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return unit of sensor.""" return cast(str, self._unit) @@ -311,7 +311,7 @@ class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): """Represent a shelly REST sensor.""" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return value of sensor.""" return self.attribute_value @@ -321,7 +321,7 @@ class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): return self.description.state_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return unit of sensor.""" return self.description.unit @@ -330,7 +330,7 @@ class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): """Represent a shelly sleeping sensor.""" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return value of sensor.""" if self.block is not None: return self.attribute_value @@ -343,6 +343,6 @@ class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): return self.description.state_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return unit of sensor.""" return cast(str, self._unit) diff --git a/homeassistant/components/shodan/sensor.py b/homeassistant/components/shodan/sensor.py index fa0fc2d3906..1423a3b9327 100644 --- a/homeassistant/components/shodan/sensor.py +++ b/homeassistant/components/shodan/sensor.py @@ -62,12 +62,12 @@ class ShodanSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/sht31/sensor.py b/homeassistant/components/sht31/sensor.py index a894623db47..1b1e1427e51 100644 --- a/homeassistant/components/sht31/sensor.py +++ b/homeassistant/components/sht31/sensor.py @@ -108,7 +108,7 @@ class SHTSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -123,7 +123,7 @@ class SHTSensorTemperature(SHTSensor): _attr_device_class = DEVICE_CLASS_TEMPERATURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.hass.config.units.temperature_unit @@ -141,7 +141,7 @@ class SHTSensorHumidity(SHTSensor): """Representation of a humidity sensor.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py index 75c2a4f0f63..41fb3469293 100644 --- a/homeassistant/components/sigfox/sensor.py +++ b/homeassistant/components/sigfox/sensor.py @@ -149,7 +149,7 @@ class SigfoxDevice(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the payload of the last message.""" return self._state diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index 149319cd5bd..c3f8d7c3ab0 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -34,9 +34,9 @@ class SimplisafeFreezeSensor(SimpliSafeBaseSensor, SensorEntity): """Define a SimpliSafe freeze sensor entity.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_FAHRENHEIT + _attr_native_unit_of_measurement = TEMP_FAHRENHEIT @callback def async_update_from_rest_api(self) -> None: """Update the entity with the provided REST API data.""" - self._attr_state = self._sensor.temperature + self._attr_native_value = self._sensor.temperature diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py index 3fe7aedfbb0..819f9c7147c 100644 --- a/homeassistant/components/simulated/sensor.py +++ b/homeassistant/components/simulated/sensor.py @@ -121,7 +121,7 @@ class SimulatedSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -131,7 +131,7 @@ class SimulatedSensor(SensorEntity): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 5b6eae96a7e..a72e1372ca0 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -65,7 +65,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class SkybeaconHumid(SensorEntity): """Representation of a Skybeacon humidity sensor.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, name, mon): """Initialize a sensor.""" @@ -78,7 +78,7 @@ class SkybeaconHumid(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.mon.data["humid"] @@ -92,7 +92,7 @@ class SkybeaconTemp(SensorEntity): """Representation of a Skybeacon temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS def __init__(self, name, mon): """Initialize a sensor.""" @@ -105,7 +105,7 @@ class SkybeaconTemp(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.mon.data["temp"] diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index cee864911b4..0ac26c1c76b 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -71,4 +71,4 @@ class SkybellSensor(SkybellDevice, SensorEntity): super().update() if self.entity_description.key == "chime_level": - self._attr_state = self._device.outdoor_chime_level + self._attr_native_value = self._device.outdoor_chime_level diff --git a/homeassistant/components/sleepiq/sensor.py b/homeassistant/components/sleepiq/sensor.py index 8f5c17dad89..eec096e56c2 100644 --- a/homeassistant/components/sleepiq/sensor.py +++ b/homeassistant/components/sleepiq/sensor.py @@ -37,7 +37,7 @@ class SleepNumberSensor(SleepIQSensor, SensorEntity): self.update() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 6f3f7c2dca9..36084c53bb3 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -179,12 +179,12 @@ class SMAsensor(CoordinatorEntity, SensorEntity): return self._sensor.name @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" return self._sensor.value @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self._sensor.unit diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index c7f30a8b954..fb879e3cef5 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -282,7 +282,7 @@ class SmappeeSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -292,7 +292,7 @@ class SmappeeSensor(SensorEntity): return self._device_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index f63edcce0fc..6914d3ef1ac 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -33,7 +33,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Representation of an Smart Meter Texas sensor.""" - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator) -> None: """Initialize the sensor.""" @@ -58,7 +58,7 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): return self._available @property - def state(self): + def native_value(self): """Get the latest reading.""" return self._state diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index cb8fa4bb6d2..c4d84ec5f69 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -492,7 +492,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): return f"{self._device.device_id}.{self._attribute}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.status.attributes[self._attribute].value @@ -502,7 +502,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): return self._device_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """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 @@ -534,7 +534,7 @@ class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): return f"{self._device.device_id}.{THREE_AXIS_NAMES[self._index]}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" three_axis = self._device.status.attributes[Attribute.three_axis].value try: diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index 95a862502cd..9922792ba12 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -87,7 +87,7 @@ class SmartTubSensor(SmartTubSensorBase, SensorEntity): """Generic class for SmartTub status sensors.""" @property - def state(self) -> str: + def native_value(self) -> str: """Return the current state of the sensor.""" if isinstance(self._state, Enum): return self._state.name.lower() @@ -109,7 +109,7 @@ class SmartTubPrimaryFiltrationCycle(SmartTubSensor): return self._state @property - def state(self) -> str: + def native_value(self) -> str: """Return the current state of the sensor.""" return self.cycle.status.name.lower() @@ -147,7 +147,7 @@ class SmartTubSecondaryFiltrationCycle(SmartTubSensor): return self._state @property - def state(self) -> str: + def native_value(self) -> str: """Return the current state of the sensor.""" return self.cycle.status.name.lower() diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index b958185f9bd..a76e4b0f567 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -64,12 +64,12 @@ class SmartySensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index eb6c6ab22e1..d405c817656 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): key="signal", name=f"gsm_signal_imei_{imei}", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, entity_registry_enabled_default=False, ), ) @@ -55,7 +55,7 @@ class GSMSignalSensor(SensorEntity): return self._state is not None @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state["SignalStrength"] diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 7de2bfb91e2..09bfe3856cc 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -155,12 +155,12 @@ class SnmpSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/sochain/sensor.py b/homeassistant/components/sochain/sensor.py index 1f735da4995..a4cdd595f90 100644 --- a/homeassistant/components/sochain/sensor.py +++ b/homeassistant/components/sochain/sensor.py @@ -54,7 +54,7 @@ class SochainSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return ( self.chainso.data.get("confirmed_balance") @@ -63,7 +63,7 @@ class SochainSensor(SensorEntity): ) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 872781bf19c..06d8813130e 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -42,7 +42,7 @@ SENSOR_TYPES = [ icon="mdi:solar-power", last_reset=dt_util.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), SolarEdgeSensorEntityDescription( @@ -51,7 +51,7 @@ SENSOR_TYPES = [ name="Energy this year", entity_registry_enabled_default=False, icon="mdi:solar-power", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), SolarEdgeSensorEntityDescription( @@ -60,7 +60,7 @@ SENSOR_TYPES = [ name="Energy this month", entity_registry_enabled_default=False, icon="mdi:solar-power", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), SolarEdgeSensorEntityDescription( @@ -69,7 +69,7 @@ SENSOR_TYPES = [ name="Energy today", entity_registry_enabled_default=False, icon="mdi:solar-power", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), SolarEdgeSensorEntityDescription( @@ -78,7 +78,7 @@ SENSOR_TYPES = [ name="Current Power", icon="mdi:solar-power", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), SolarEdgeSensorEntityDescription( @@ -185,6 +185,6 @@ SENSOR_TYPES = [ name="Storage Level", entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), ] diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 85e01a2d7ee..23aa269cf36 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -133,7 +133,7 @@ class SolarEdgeOverviewSensor(SolarEdgeSensorEntity): """Representation of an SolarEdge Monitoring API overview sensor.""" @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data.get(self.entity_description.json_key) @@ -147,7 +147,7 @@ class SolarEdgeDetailsSensor(SolarEdgeSensorEntity): return self.data_service.attributes @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data @@ -161,7 +161,7 @@ class SolarEdgeInventorySensor(SolarEdgeSensorEntity): return self.data_service.attributes.get(self.entity_description.json_key) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data.get(self.entity_description.json_key) @@ -173,7 +173,7 @@ class SolarEdgeEnergyDetailsSensor(SolarEdgeSensorEntity): """Initialize the power flow sensor.""" super().__init__(platform_name, sensor_type, data_service) - self._attr_unit_of_measurement = data_service.unit + self._attr_native_unit_of_measurement = data_service.unit @property def extra_state_attributes(self) -> dict[str, Any]: @@ -181,7 +181,7 @@ class SolarEdgeEnergyDetailsSensor(SolarEdgeSensorEntity): return self.data_service.attributes.get(self.entity_description.json_key) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data.get(self.entity_description.json_key) @@ -200,7 +200,7 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): """Initialize the power flow sensor.""" super().__init__(platform_name, description, data_service) - self._attr_unit_of_measurement = data_service.unit + self._attr_native_unit_of_measurement = data_service.unit @property def extra_state_attributes(self) -> dict[str, Any]: @@ -208,7 +208,7 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): return self.data_service.attributes.get(self.entity_description.json_key) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data.get(self.entity_description.json_key) @@ -219,7 +219,7 @@ class SolarEdgeStorageLevelSensor(SolarEdgeSensorEntity): _attr_device_class = DEVICE_CLASS_BATTERY @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" attr = self.data_service.attributes.get(self.entity_description.json_key) if attr and "soc" in attr: diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 3f159ce4480..f9ac2b853e7 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -285,7 +285,7 @@ class SolarEdgeSensor(SensorEntity): return f"{self._platform_name} ({self._name})" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement @@ -305,7 +305,7 @@ class SolarEdgeSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index 85a1531090d..a6a35bad80c 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -82,7 +82,7 @@ class SolarlogSensor(SensorEntity): return f"{self.device_name} {self._label}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the state of the sensor.""" return self._unit_of_measurement @@ -92,7 +92,7 @@ class SolarlogSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index e47f5c57802..4d1652e8b12 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -84,7 +84,7 @@ class Inverter(SensorEntity): self.unit = unit @property - def state(self): + def native_value(self): """State of this inverter attribute.""" return self.value @@ -99,7 +99,7 @@ class Inverter(SensorEntity): return f"Solax {self.serial} {self.key}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.unit diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 4df12c9f8f5..948e8d1e1e1 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -30,7 +30,7 @@ class SomaSensor(SomaEntity, SensorEntity): """Representation of a Soma cover device.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE @property def name(self): @@ -38,7 +38,7 @@ class SomaSensor(SomaEntity, SensorEntity): return self.device["name"] + " battery level" @property - def state(self): + def native_value(self): """Return the state of the entity.""" return self.battery_state diff --git a/homeassistant/components/somfy/sensor.py b/homeassistant/components/somfy/sensor.py index 1817ba3fd8c..9a0602cb592 100644 --- a/homeassistant/components/somfy/sensor.py +++ b/homeassistant/components/somfy/sensor.py @@ -30,7 +30,7 @@ class SomfyThermostatBatterySensor(SomfyEntity, SensorEntity): """Representation of a Somfy thermostat battery.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, coordinator, device_id): """Initialize the Somfy device.""" @@ -43,6 +43,6 @@ class SomfyThermostatBatterySensor(SomfyEntity, SensorEntity): self._climate = Thermostat(self.device, self.coordinator.client) @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self._climate.get_battery() diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index d173d42eaf7..3f5ef275fef 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -85,7 +85,7 @@ class SonarrSensor(SonarrEntity, SensorEntity): self._attr_name = name self._attr_icon = icon self._attr_unique_id = f"{entry_id}_{key}" - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._attr_entity_registry_enabled_default = enabled_default self.last_update_success = False @@ -134,7 +134,7 @@ class SonarrCommandsSensor(SonarrSensor): return attrs @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return len(self._commands) @@ -181,7 +181,7 @@ class SonarrDiskspaceSensor(SonarrSensor): return attrs @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" free = self._total_free / 1024 ** 3 return f"{free:.2f}" @@ -223,7 +223,7 @@ class SonarrQueueSensor(SonarrSensor): return attrs @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return len(self._queue) @@ -261,7 +261,7 @@ class SonarrSeriesSensor(SonarrSensor): return attrs @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return len(self._items) @@ -304,7 +304,7 @@ class SonarrUpcomingSensor(SonarrSensor): return attrs @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return len(self._upcoming) @@ -347,6 +347,6 @@ class SonarrWantedSensor(SonarrSensor): return attrs @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._total diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index 9e5277819a7..1a13e6f55f4 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -45,7 +45,7 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): return DEVICE_CLASS_BATTERY @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Get the unit of measurement.""" return PERCENTAGE @@ -54,7 +54,7 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): await self.speaker.async_poll_battery() @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self.speaker.battery_info.get("Level") diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index 897ffa126fa..c9962362406 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -17,19 +17,19 @@ SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( key="ping", name="Ping", - unit_of_measurement=TIME_MILLISECONDS, + native_unit_of_measurement=TIME_MILLISECONDS, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="download", name="Download", - unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="upload", name="Upload", - unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, state_class=STATE_CLASS_MEASUREMENT, ), ) diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 1c6c80a6af1..2dc12c956de 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -85,7 +85,7 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): await super().async_added_to_hass() state = await self.async_get_last_state() if state: - self._attr_state = state.state + self._attr_native_value = state.state @callback def update() -> None: @@ -100,8 +100,12 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Update sensors state.""" if self.coordinator.data: if self.entity_description.key == "ping": - self._attr_state = self.coordinator.data["ping"] + self._attr_native_value = self.coordinator.data["ping"] elif self.entity_description.key == "download": - self._attr_state = round(self.coordinator.data["download"] / 10 ** 6, 2) + self._attr_native_value = round( + self.coordinator.data["download"] / 10 ** 6, 2 + ) elif self.entity_description.key == "upload": - self._attr_state = round(self.coordinator.data["upload"] / 10 ** 6, 2) + self._attr_native_value = round( + self.coordinator.data["upload"] / 10 ** 6, 2 + ) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 4c1c29b82a6..1b0ae5a9076 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -123,12 +123,12 @@ class SQLSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the query's current state.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 6973c58600e..97b65840e83 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -93,14 +93,14 @@ class SrpEntity(SensorEntity): return self.type @property - def state(self): + def native_value(self): """Return the state of the device.""" if self._state: return f"{self._state:.2f}" return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/tests/components/sleepiq/test_sensor.py b/tests/components/sleepiq/test_sensor.py index c6a584802b9..7a7e47f03fa 100644 --- a/tests/components/sleepiq/test_sensor.py +++ b/tests/components/sleepiq/test_sensor.py @@ -21,15 +21,17 @@ async def test_setup(hass, requests_mock): assert len(devices) == 2 left_side = devices[1] + left_side.hass = hass assert left_side.name == "SleepNumber ILE Test1 SleepNumber" assert left_side.state == 40 right_side = devices[0] + right_side.hass = hass assert right_side.name == "SleepNumber ILE Test2 SleepNumber" assert right_side.state == 80 -async def test_setup_sigle(hass, requests_mock): +async def test_setup_single(hass, requests_mock): """Test for successfully setting up the SleepIQ platform.""" mock_responses(requests_mock, single=True) @@ -41,5 +43,6 @@ async def test_setup_sigle(hass, requests_mock): assert len(devices) == 1 right_side = devices[0] + right_side.hass = hass assert right_side.name == "SleepNumber ILE Test1 SleepNumber" assert right_side.state == 40 diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 069dc9eb64f..3e830b7fc93 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -82,6 +82,7 @@ async def test_srp_entity(hass): """Test the SrpEntity.""" fake_coordinator = MagicMock(data=1.99999999999) srp_entity = SrpEntity(fake_coordinator) + srp_entity.hass = hass assert srp_entity is not None assert srp_entity.name == f"{DEFAULT_NAME} {SENSOR_NAME}" @@ -104,6 +105,7 @@ async def test_srp_entity_no_data(hass): """Test the SrpEntity.""" fake_coordinator = MagicMock(data=False) srp_entity = SrpEntity(fake_coordinator) + srp_entity.hass = hass assert srp_entity.extra_state_attributes is None @@ -111,6 +113,7 @@ async def test_srp_entity_no_coord_data(hass): """Test the SrpEntity.""" fake_coordinator = MagicMock(data=False) srp_entity = SrpEntity(fake_coordinator) + srp_entity.hass = hass assert srp_entity.usage is None @@ -124,6 +127,7 @@ async def test_srp_entity_async_update(hass): MagicMock.__await__ = lambda x: async_magic().__await__() fake_coordinator = MagicMock(data=False) srp_entity = SrpEntity(fake_coordinator) + srp_entity.hass = hass await srp_entity.async_update() assert fake_coordinator.async_request_refresh.called From 87e0b1428283bfdd32f6988a24916dd5472e08f3 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 12 Aug 2021 11:46:07 -0500 Subject: [PATCH 352/903] Log gathered exceptions during Sonos unsubscriptions (#54190) --- homeassistant/components/sonos/__init__.py | 3 +-- homeassistant/components/sonos/speaker.py | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 45f5cf9276c..ae3652683d4 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -187,8 +187,7 @@ class SonosDiscoveryManager: async def _async_stop_event_listener(self, event: Event | None = None) -> None: await asyncio.gather( - *(speaker.async_unsubscribe() for speaker in self.data.discovered.values()), - return_exceptions=True, + *(speaker.async_unsubscribe() for speaker in self.data.discovered.values()) ) if events_asyncio.event_listener: await events_asyncio.event_listener.async_stop() diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 919e03cf39b..18f05d7341c 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -346,10 +346,13 @@ class SonosSpeaker: async def async_unsubscribe(self) -> None: """Cancel all subscriptions.""" _LOGGER.debug("Unsubscribing from events for %s", self.zone_name) - await asyncio.gather( + results = await asyncio.gather( *(subscription.unsubscribe() for subscription in self._subscriptions), return_exceptions=True, ) + for result in results: + if isinstance(result, Exception): + _LOGGER.debug("Unsubscribe failed for %s: %s", self.zone_name, result) self._subscriptions = [] @callback From 81e1c4459228361cd335789527437f0a1939f873 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 12 Aug 2021 12:18:10 -0700 Subject: [PATCH 353/903] Remove unused import step in OpenUV config flow (#54554) --- homeassistant/components/openuv/config_flow.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 3595b124053..d8652ae09c5 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -51,10 +51,6 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors if errors else {}, ) - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: From 084737dd01bc6405700d2c557dfce3e60b121c24 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 12 Aug 2021 15:01:34 -0500 Subject: [PATCH 354/903] Cleanup Sonos grouping event callback method (#54542) --- homeassistant/components/sonos/speaker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 18f05d7341c..6e887dfdc53 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -636,8 +636,8 @@ class SonosSpeaker: def async_update_groups(self, event: SonosEvent) -> None: """Handle callback for topology change event.""" if not hasattr(event, "zone_player_uui_ds_in_group"): - return None - self.hass.async_add_job(self.create_update_groups_coro(event)) + return + self.hass.async_create_task(self.create_update_groups_coro(event)) def create_update_groups_coro(self, event: SonosEvent | None = None) -> Coroutine: """Handle callback for topology change event.""" From 3f80c31bd51d41fb1b31eace41bc7e0d7fce6134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 12 Aug 2021 23:40:42 +0300 Subject: [PATCH 355/903] Remove obsolete upcloud YAML config support (#54516) --- homeassistant/components/upcloud/__init__.py | 73 ++----------------- .../components/upcloud/config_flow.py | 7 -- 2 files changed, 5 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 9b76c209403..82d42e28589 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -8,11 +8,10 @@ from typing import Any, Dict import requests.exceptions import upcloud_api -import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -23,18 +22,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -58,21 +55,6 @@ SIGNAL_UPDATE_UPCLOUD = "upcloud_update" STATE_MAP = {"error": STATE_PROBLEM, "started": STATE_ON, "stopped": STATE_OFF} -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - class UpCloudDataUpdateCoordinator( DataUpdateCoordinator[Dict[str, upcloud_api.Server]] @@ -115,37 +97,6 @@ class UpCloudHassData: coordinators: dict[str, UpCloudDataUpdateCoordinator] = dataclasses.field( default_factory=dict ) - scan_interval_migrations: dict[str, int] = dataclasses.field(default_factory=dict) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up UpCloud component.""" - domain_config = config.get(DOMAIN) - if not domain_config: - return True - - _LOGGER.warning( - "Loading upcloud via top level config is deprecated and no longer " - "necessary as of 0.117; Please remove it from your YAML configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_USERNAME: domain_config[CONF_USERNAME], - CONF_PASSWORD: domain_config[CONF_PASSWORD], - }, - ) - ) - - if domain_config[CONF_SCAN_INTERVAL]: - hass.data[DATA_UPCLOUD] = UpCloudHassData() - hass.data[DATA_UPCLOUD].scan_interval_migrations[ - domain_config[CONF_USERNAME] - ] = domain_config[CONF_SCAN_INTERVAL] - - return True def _config_entry_update_signal_name(config_entry: ConfigEntry) -> str: @@ -178,22 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Failed to connect", exc_info=True) raise ConfigEntryNotReady from err - upcloud_data = hass.data.setdefault(DATA_UPCLOUD, UpCloudHassData()) - - # Handle pre config entry (0.117) scan interval migration to options - migrated_scan_interval = upcloud_data.scan_interval_migrations.pop( - entry.data[CONF_USERNAME], None - ) - if migrated_scan_interval and ( - not entry.options.get(CONF_SCAN_INTERVAL) - or entry.options[CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL.total_seconds() - ): - update_interval = migrated_scan_interval - hass.config_entries.async_update_entry( - entry, - options={CONF_SCAN_INTERVAL: update_interval.total_seconds()}, - ) - elif entry.options.get(CONF_SCAN_INTERVAL): + if entry.options.get(CONF_SCAN_INTERVAL): update_interval = timedelta(seconds=entry.options[CONF_SCAN_INTERVAL]) else: update_interval = DEFAULT_SCAN_INTERVAL @@ -218,7 +154,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - upcloud_data.coordinators[entry.data[CONF_USERNAME]] = coordinator + hass.data[DATA_UPCLOUD] = UpCloudHassData() + hass.data[DATA_UPCLOUD].coordinators[entry.data[CONF_USERNAME]] = coordinator # Forward entry setup hass.config_entries.async_setup_platforms(entry, CONFIG_ENTRY_DOMAINS) diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index 1a16a78cfa1..e6868be29b9 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -57,13 +57,6 @@ class UpCloudConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle import initiated flow.""" - await self.async_set_unique_id(user_input[CONF_USERNAME]) - self._abort_if_unique_id_configured() - - return await self.async_step_user(user_input=user_input) - @callback def _async_show_form( self, From b1fb8de0f5905e08dcb0ec8d439718b43a4dccdd Mon Sep 17 00:00:00 2001 From: carstenschroeder Date: Thu, 12 Aug 2021 22:40:56 +0200 Subject: [PATCH 356/903] Add state_class attribute to keba integration (#54271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- homeassistant/components/keba/sensor.py | 126 +++++++++++------------- 1 file changed, 58 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py index 37b42cb3cbe..2c0108ca1cd 100644 --- a/homeassistant/components/keba/sensor.py +++ b/homeassistant/components/keba/sensor.py @@ -1,10 +1,18 @@ """Support for KEBA charging station sensors.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( +from homeassistant.components.sensor import ( + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, + POWER_KILO_WATT, ) +from homeassistant.util import dt from . import DOMAIN @@ -19,44 +27,56 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sensors = [ KebaSensor( keba, - "Curr user", - "Max Current", "max_current", - "mdi:flash", - ELECTRIC_CURRENT_AMPERE, + SensorEntityDescription( + key="Curr user", + name="Max Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + ), ), KebaSensor( keba, - "Setenergy", - "Energy Target", "energy_target", - "mdi:gauge", - ENERGY_KILO_WATT_HOUR, + SensorEntityDescription( + key="Setenergy", + name="Energy Target", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), ), KebaSensor( keba, - "P", - "Charging Power", "charging_power", - "mdi:flash", - "kW", - DEVICE_CLASS_POWER, + SensorEntityDescription( + key="P", + name="Charging Power", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), ), KebaSensor( keba, - "E pres", - "Session Energy", "session_energy", - "mdi:gauge", - ENERGY_KILO_WATT_HOUR, + SensorEntityDescription( + key="E pres", + name="Session Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), ), KebaSensor( keba, - "E total", - "Total Energy", "total_energy", - "mdi:gauge", - ENERGY_KILO_WATT_HOUR, + SensorEntityDescription( + key="E total", + name="Total Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + ), ), ] async_add_entities(sensors) @@ -65,53 +85,23 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class KebaSensor(SensorEntity): """The entity class for KEBA charging stations sensors.""" - def __init__(self, keba, key, name, entity_type, icon, unit, device_class=None): + _attr_should_poll = False + + def __init__( + self, + keba, + entity_type, + description: SensorEntityDescription, + ): """Initialize the KEBA Sensor.""" self._keba = keba - self._key = key - self._name = name + self.entity_description = description self._entity_type = entity_type - self._icon = icon - self._unit = unit - self._device_class = device_class - self._state = None - self._attributes = {} + self._attr_name = f"{keba.device_name} {description.name}" + self._attr_unique_id = f"{keba.device_id}_{entity_type}" - @property - def should_poll(self): - """Deactivate polling. Data updated by KebaHandler.""" - return False - - @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return f"{self._keba.device_id}_{self._entity_type}" - - @property - def name(self): - """Return the name of the device.""" - return f"{self._keba.device_name} {self._name}" - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Get the unit of measurement.""" - return self._unit + self._attributes: dict[str, str] = {} @property def extra_state_attributes(self): @@ -120,9 +110,9 @@ class KebaSensor(SensorEntity): async def async_update(self): """Get latest cached states from the device.""" - self._state = self._keba.get_value(self._key) + self._attr_native_value = self._keba.get_value(self.entity_description.key) - if self._key == "P": + if self.entity_description.key == "P": self._attributes["power_factor"] = self._keba.get_value("PF") self._attributes["voltage_u1"] = str(self._keba.get_value("U1")) self._attributes["voltage_u2"] = str(self._keba.get_value("U2")) @@ -130,7 +120,7 @@ class KebaSensor(SensorEntity): self._attributes["current_i1"] = str(self._keba.get_value("I1")) self._attributes["current_i2"] = str(self._keba.get_value("I2")) self._attributes["current_i3"] = str(self._keba.get_value("I3")) - elif self._key == "Curr user": + elif self.entity_description.key == "Curr user": self._attributes["max_current_hardware"] = self._keba.get_value("Curr HW") def update_callback(self): From 84f568abb17ff4ff9cb47d5a5931888de41d59ca Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 12 Aug 2021 23:08:51 +0200 Subject: [PATCH 357/903] Updated ZHA to also poll Philips Hue lights with new firmware (#54513) --- homeassistant/components/zha/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 628d9c3b9be..a340ffae736 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -523,7 +523,7 @@ class Light(BaseLight, ZhaEntity): @STRICT_MATCH( channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, - manufacturers="Philips", + manufacturers={"Philips", "Signify Netherlands B.V."}, ) class HueLight(Light): """Representation of a HUE light which does not report attributes.""" From 50bcb3f821243e4cfac2706b2488d92f013db91f Mon Sep 17 00:00:00 2001 From: Gerard Date: Thu, 12 Aug 2021 23:33:02 +0200 Subject: [PATCH 358/903] Fix attributes not showing after using entity class attributes (#54558) --- .../bmw_connected_drive/__init__.py | 5 -- .../bmw_connected_drive/binary_sensor.py | 51 ++++++++----------- .../bmw_connected_drive/device_tracker.py | 1 + 3 files changed, 21 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 3bd2365f88e..17e57b5d09c 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -335,11 +335,6 @@ class BMWConnectedDriveBaseEntity(Entity): "manufacturer": vehicle.attributes.get("brand"), } - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return self._attrs - def update_callback(self): """Schedule a state update.""" self.schedule_update_ha_state(True) diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index d7f0d150193..a7fd72fc1a7 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -85,54 +85,38 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): def update(self): """Read new state data from the library.""" vehicle_state = self._vehicle.state + result = self._attrs.copy() # device class opening: On means open, Off means closed if self._attribute == "lids": _LOGGER.debug("Status of lid: %s", vehicle_state.all_lids_closed) - self._attr_state = not vehicle_state.all_lids_closed - if self._attribute == "windows": - self._attr_state = not vehicle_state.all_windows_closed - # device class lock: On means unlocked, Off means locked - if self._attribute == "door_lock_state": - # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED - self._attr_state = vehicle_state.door_lock_state not in [ - LockState.LOCKED, - LockState.SECURED, - ] - # device class light: On means light detected, Off means no light - if self._attribute == "lights_parking": - self._attr_state = vehicle_state.are_parking_lights_on - # device class problem: On means problem detected, Off means no problem - if self._attribute == "condition_based_services": - self._attr_state = not vehicle_state.are_all_cbs_ok - if self._attribute == "check_control_messages": - self._attr_state = vehicle_state.has_check_control_messages - # device class power: On means power detected, Off means no power - if self._attribute == "charging_status": - self._attr_state = vehicle_state.charging_status in [ChargingState.CHARGING] - # device class plug: On means device is plugged in, - # Off means device is unplugged - if self._attribute == "connection_status": - self._attr_state = vehicle_state.connection_status == "CONNECTED" - - vehicle_state = self._vehicle.state - result = self._attrs.copy() - - if self._attribute == "lids": + self._attr_is_on = not vehicle_state.all_lids_closed for lid in vehicle_state.lids: result[lid.name] = lid.state.value elif self._attribute == "windows": + self._attr_is_on = not vehicle_state.all_windows_closed for window in vehicle_state.windows: result[window.name] = window.state.value + # device class lock: On means unlocked, Off means locked elif self._attribute == "door_lock_state": + # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED + self._attr_is_on = vehicle_state.door_lock_state not in [ + LockState.LOCKED, + LockState.SECURED, + ] result["door_lock_state"] = vehicle_state.door_lock_state.value result["last_update_reason"] = vehicle_state.last_update_reason + # device class light: On means light detected, Off means no light elif self._attribute == "lights_parking": + self._attr_is_on = vehicle_state.are_parking_lights_on result["lights_parking"] = vehicle_state.parking_lights.value + # device class problem: On means problem detected, Off means no problem elif self._attribute == "condition_based_services": + self._attr_is_on = not vehicle_state.are_all_cbs_ok for report in vehicle_state.condition_based_services: result.update(self._format_cbs_report(report)) elif self._attribute == "check_control_messages": + self._attr_is_on = vehicle_state.has_check_control_messages check_control_messages = vehicle_state.check_control_messages has_check_control_messages = vehicle_state.has_check_control_messages if has_check_control_messages: @@ -142,13 +126,18 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): result["check_control_messages"] = cbs_list else: result["check_control_messages"] = "OK" + # device class power: On means power detected, Off means no power elif self._attribute == "charging_status": + self._attr_is_on = vehicle_state.charging_status in [ChargingState.CHARGING] result["charging_status"] = vehicle_state.charging_status.value result["last_charging_end_result"] = vehicle_state.last_charging_end_result + # device class plug: On means device is plugged in, + # Off means device is unplugged elif self._attribute == "connection_status": + self._attr_is_on = vehicle_state.connection_status == "CONNECTED" result["connection_status"] = vehicle_state.connection_status - self._attr_extra_state_attributes = sorted(result.items()) + self._attr_extra_state_attributes = result def _format_cbs_report(self, report): result = {} diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 62b2ed9b9d9..c788051dc9a 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -59,6 +59,7 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): def update(self): """Update state of the decvice tracker.""" + self._attr_extra_state_attributes = self._attrs self._location = ( self._vehicle.state.gps_position if self._vehicle.state.is_vehicle_tracking_enabled From b71a0c5d4bb22f1d7ec84e892cff851ad1d6f283 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 13 Aug 2021 00:17:12 +0000 Subject: [PATCH 359/903] [ci skip] Translation update --- .../components/sensor/translations/cs.json | 2 ++ .../components/sensor/translations/no.json | 2 ++ .../components/sensor/translations/ru.json | 2 ++ .../components/uptimerobot/translations/cs.json | 2 +- .../components/uptimerobot/translations/no.json | 13 ++++++++++++- .../xiaomi_miio/translations/select.cs.json | 7 +++++++ 6 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/xiaomi_miio/translations/select.cs.json diff --git a/homeassistant/components/sensor/translations/cs.json b/homeassistant/components/sensor/translations/cs.json index dfa2a263783..f493f4134ed 100644 --- a/homeassistant/components/sensor/translations/cs.json +++ b/homeassistant/components/sensor/translations/cs.json @@ -4,6 +4,7 @@ "is_battery_level": "Aktu\u00e1ln\u00ed \u00farove\u0148 nabit\u00ed baterie {entity_name}", "is_current": "Aktu\u00e1ln\u00ed proud {entity_name}", "is_energy": "Aktu\u00e1ln\u00ed energie {entity_name}", + "is_gas": "Aktu\u00e1ln\u00ed mno\u017estv\u00ed plynu {entity_name}", "is_humidity": "Aktu\u00e1ln\u00ed vlhkost {entity_name}", "is_illuminance": "Aktu\u00e1ln\u00ed osv\u011btlen\u00ed {entity_name}", "is_power": "Aktu\u00e1ln\u00ed v\u00fdkon {entity_name}", @@ -18,6 +19,7 @@ "battery_level": "P\u0159i zm\u011bn\u011b \u00farovn\u011b baterie {entity_name}", "current": "P\u0159i zm\u011bn\u011b proudu {entity_name}", "energy": "P\u0159i zm\u011bn\u011b energie {entity_name}", + "gas": "P\u0159i zm\u011bn\u011b mno\u017estv\u00ed plynu {entity_name}", "humidity": "P\u0159i zm\u011bn\u011b vlhkosti {entity_name}", "illuminance": "P\u0159i zm\u011bn\u011b osv\u011btlen\u00ed {entity_name}", "power": "P\u0159i zm\u011bn\u011b el. v\u00fdkonu {entity_name}", diff --git a/homeassistant/components/sensor/translations/no.json b/homeassistant/components/sensor/translations/no.json index 02204a4a49a..c9c9542b92b 100644 --- a/homeassistant/components/sensor/translations/no.json +++ b/homeassistant/components/sensor/translations/no.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Gjeldende {entity_name} karbonmonoksid konsentrasjonsniv\u00e5", "is_current": "Gjeldende {entity_name} str\u00f8m", "is_energy": "Gjeldende {entity_name} effekt", + "is_gas": "Gjeldende {entity_name} gass", "is_humidity": "Gjeldende {entity_name} fuktighet", "is_illuminance": "Gjeldende {entity_name} belysningsstyrke", "is_power": "Gjeldende {entity_name}-effekt", @@ -22,6 +23,7 @@ "carbon_monoxide": "{entity_name} endringer i konsentrasjonen av karbonmonoksid", "current": "{entity_name} gjeldende endringer", "energy": "{entity_name} effektendringer", + "gas": "{entity_name} gass endres", "humidity": "{entity_name} fuktighets endringer", "illuminance": "{entity_name} belysningsstyrke endringer", "power": "{entity_name} effektendringer", diff --git a/homeassistant/components/sensor/translations/ru.json b/homeassistant/components/sensor/translations/ru.json index c44c9002fef..930459c4fc5 100644 --- a/homeassistant/components/sensor/translations/ru.json +++ b/homeassistant/components/sensor/translations/ru.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0433\u0430\u0440\u043d\u043e\u0433\u043e \u0433\u0430\u0437\u0430", "is_current": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u0438\u043b\u044b \u0442\u043e\u043a\u0430", "is_energy": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", + "is_gas": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_humidity": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_illuminance": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_power": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", @@ -22,6 +23,7 @@ "carbon_monoxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "current": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u0438\u043b\u044b \u0442\u043e\u043a\u0430", "energy": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", + "gas": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435 \u0433\u0430\u0437\u0430", "humidity": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "illuminance": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "power": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", diff --git a/homeassistant/components/uptimerobot/translations/cs.json b/homeassistant/components/uptimerobot/translations/cs.json index dc5ccb72741..fb6f0bfa70d 100644 --- a/homeassistant/components/uptimerobot/translations/cs.json +++ b/homeassistant/components/uptimerobot/translations/cs.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "already_configured": "\u00da\u010det je ji\u017e nastaven", "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, diff --git a/homeassistant/components/uptimerobot/translations/no.json b/homeassistant/components/uptimerobot/translations/no.json index ee44ef0fdbc..8c6351d78c4 100644 --- a/homeassistant/components/uptimerobot/translations/no.json +++ b/homeassistant/components/uptimerobot/translations/no.json @@ -2,18 +2,29 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", + "reauth_failed_existing": "Kunne ikke oppdatere konfigurasjonsoppf\u00f8ringen. Fjern integrasjonen og sett den opp igjen.", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", "unknown": "Uventet feil" }, "error": { "cannot_connect": "Tilkobling mislyktes", "invalid_api_key": "Ugyldig API-n\u00f8kkel", + "reauth_failed_matching_account": "API-n\u00f8kkelen du oppgav, samsvarer ikke med konto-IDen for eksisterende konfigurasjon.", "unknown": "Uventet feil" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API-n\u00f8kkel" + }, + "description": "Du m\u00e5 angi en ny skrivebeskyttet API-n\u00f8kkel fra Uptime Robot", + "title": "Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "api_key": "API-n\u00f8kkel" - } + }, + "description": "Du m\u00e5 angi en skrivebeskyttet API-n\u00f8kkel fra Uptime Robot" } } } diff --git a/homeassistant/components/xiaomi_miio/translations/select.cs.json b/homeassistant/components/xiaomi_miio/translations/select.cs.json new file mode 100644 index 00000000000..d7f5e8b6c84 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.cs.json @@ -0,0 +1,7 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "off": "Vypnuto" + } + } +} \ No newline at end of file From 821b93b0d096c3622101caf32e29ef7cc8b8d8a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 13 Aug 2021 11:38:14 +0200 Subject: [PATCH 360/903] Fix bug in ambiclimate (#54579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix ambiclimate Signed-off-by: Daniel Hjelseth Høyer * Fix ambiclimate Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/ambiclimate/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index 8cfebb1bf69..aa4be202865 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -154,8 +154,6 @@ class AmbiclimateEntity(ClimateEntity): "name": self.name, "manufacturer": "Ambiclimate", } - self._attr_min_temp = heater.get_min_temp() - self._attr_max_temp = heater.get_max_temp() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -184,6 +182,8 @@ class AmbiclimateEntity(ClimateEntity): await self._store.async_save(token_info) data = await self._heater.update_device() + self._attr_min_temp = self._heater.get_min_temp() + self._attr_max_temp = self._heater.get_max_temp() self._attr_target_temperature = data.get("target_temperature") self._attr_current_temperature = data.get("temperature") self._attr_current_humidity = data.get("humidity") From 029873a0887e24583e46b3f456b88ee787b13210 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 13 Aug 2021 12:35:23 +0200 Subject: [PATCH 361/903] Add support for total and total_increasing sensor state classes (#54523) * Add support for amount and meter sensor state classes * Ignore last_reset for STATE_CLASS_METER sensors * Update tests * Rename STATE_CLASS_METER to STATE_CLASS_AMOUNT_INCREASING * Rename STATE_CLASS_AMOUNT to STATE_CLASS_TOTAL * Fix typo * Log warning if last_reset set together with state_class measurement * Fix warning message --- homeassistant/components/sensor/__init__.py | 30 +++- homeassistant/components/sensor/recorder.py | 93 +++++++--- tests/components/sensor/test_init.py | 22 +++ tests/components/sensor/test_recorder.py | 169 +++++++++++++++++- .../custom_components/test/sensor.py | 10 ++ 5 files changed, 296 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 483d8b88f2e..087328ed4a6 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -73,8 +73,16 @@ DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) # The state represents a measurement in present time STATE_CLASS_MEASUREMENT: Final = "measurement" +# The state represents a total amount, e.g. a value of a stock portfolio +STATE_CLASS_TOTAL: Final = "total" +# The state represents a monotonically increasing total, e.g. an amount of consumed gas +STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" -STATE_CLASSES: Final[list[str]] = [STATE_CLASS_MEASUREMENT] +STATE_CLASSES: Final[list[str]] = [ + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, + STATE_CLASS_TOTAL_INCREASING, +] STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(STATE_CLASSES)) @@ -118,6 +126,7 @@ class SensorEntity(Entity): _attr_native_unit_of_measurement: str | None _attr_native_value: StateType = None _attr_state_class: str | None + _last_reset_reported = False _temperature_conversion_reported = False @property @@ -151,6 +160,25 @@ class SensorEntity(Entity): def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" if last_reset := self.last_reset: + if ( + last_reset is not None + and self.state_class == STATE_CLASS_MEASUREMENT + and not self._last_reset_reported + ): + self._last_reset_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Entity %s (%s) with state_class %s has set last_reset. Setting " + "last_reset for entities with state_class other than 'total' is " + "deprecated and will be removed from Home Assistant Core 2021.10. " + "Please update your configuration if state_class is manually " + "configured, otherwise %s", + self.entity_id, + type(self), + self.state_class, + report_issue, + ) + return {ATTR_LAST_RESET: last_reset.isoformat()} return None diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index fb7393cfe1d..66366934d27 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -17,6 +17,9 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, + STATE_CLASS_TOTAL_INCREASING, + STATE_CLASSES, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -50,15 +53,27 @@ from . import ATTR_LAST_RESET, DOMAIN _LOGGER = logging.getLogger(__name__) DEVICE_CLASS_OR_UNIT_STATISTICS = { - DEVICE_CLASS_BATTERY: {"mean", "min", "max"}, - DEVICE_CLASS_ENERGY: {"sum"}, - DEVICE_CLASS_HUMIDITY: {"mean", "min", "max"}, - DEVICE_CLASS_MONETARY: {"sum"}, - DEVICE_CLASS_POWER: {"mean", "min", "max"}, - DEVICE_CLASS_PRESSURE: {"mean", "min", "max"}, - DEVICE_CLASS_TEMPERATURE: {"mean", "min", "max"}, - DEVICE_CLASS_GAS: {"sum"}, - PERCENTAGE: {"mean", "min", "max"}, + STATE_CLASS_TOTAL: { + DEVICE_CLASS_ENERGY: {"sum"}, + DEVICE_CLASS_GAS: {"sum"}, + DEVICE_CLASS_MONETARY: {"sum"}, + }, + STATE_CLASS_MEASUREMENT: { + DEVICE_CLASS_BATTERY: {"mean", "min", "max"}, + DEVICE_CLASS_HUMIDITY: {"mean", "min", "max"}, + DEVICE_CLASS_POWER: {"mean", "min", "max"}, + DEVICE_CLASS_PRESSURE: {"mean", "min", "max"}, + DEVICE_CLASS_TEMPERATURE: {"mean", "min", "max"}, + PERCENTAGE: {"mean", "min", "max"}, + # Deprecated, support will be removed in Home Assistant 2021.10 + DEVICE_CLASS_ENERGY: {"sum"}, + DEVICE_CLASS_GAS: {"sum"}, + DEVICE_CLASS_MONETARY: {"sum"}, + }, + STATE_CLASS_TOTAL_INCREASING: { + DEVICE_CLASS_ENERGY: {"sum"}, + DEVICE_CLASS_GAS: {"sum"}, + }, } # Normalized units which will be stored in the statistics table @@ -109,24 +124,28 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { WARN_UNSUPPORTED_UNIT = set() -def _get_entities(hass: HomeAssistant) -> list[tuple[str, str]]: - """Get (entity_id, device_class) of all sensors for which to compile statistics.""" +def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str]]: + """Get (entity_id, state_class, key) of all sensors for which to compile statistics. + + Key is either a device class or a unit and is used to index the + DEVICE_CLASS_OR_UNIT_STATISTICS map. + """ all_sensors = hass.states.all(DOMAIN) entity_ids = [] for state in all_sensors: - if state.attributes.get(ATTR_STATE_CLASS) != STATE_CLASS_MEASUREMENT: + if (state_class := state.attributes.get(ATTR_STATE_CLASS)) not in STATE_CLASSES: continue if ( key := state.attributes.get(ATTR_DEVICE_CLASS) - ) in DEVICE_CLASS_OR_UNIT_STATISTICS: - entity_ids.append((state.entity_id, key)) + ) in DEVICE_CLASS_OR_UNIT_STATISTICS[state_class]: + entity_ids.append((state.entity_id, state_class, key)) if ( key := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - ) in DEVICE_CLASS_OR_UNIT_STATISTICS: - entity_ids.append((state.entity_id, key)) + ) in DEVICE_CLASS_OR_UNIT_STATISTICS[state_class]: + entity_ids.append((state.entity_id, state_class, key)) return entity_ids @@ -228,8 +247,8 @@ def compile_statistics( hass, start - datetime.timedelta.resolution, end, [i[0] for i in entities] ) - for entity_id, key in entities: - wanted_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[key] + for entity_id, state_class, key in entities: + wanted_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[state_class][key] if entity_id not in history_list: continue @@ -272,9 +291,28 @@ def compile_statistics( for fstate, state in fstates: - if "last_reset" not in state.attributes: + # Deprecated, will be removed in Home Assistant 2021.10 + if ( + "last_reset" not in state.attributes + and state_class == STATE_CLASS_MEASUREMENT + ): continue - if (last_reset := state.attributes["last_reset"]) != old_last_reset: + + reset = False + if ( + state_class != STATE_CLASS_TOTAL_INCREASING + and (last_reset := state.attributes.get("last_reset")) + != old_last_reset + ): + reset = True + elif old_state is None and last_reset is None: + reset = True + elif state_class == STATE_CLASS_TOTAL_INCREASING and ( + old_state is None or fstate < old_state + ): + reset = True + + if reset: # The sensor has been reset, update the sum if old_state is not None: _sum += new_state - old_state @@ -285,14 +323,21 @@ def compile_statistics( else: new_state = fstate - if last_reset is None or new_state is None or old_state is None: + # Deprecated, will be removed in Home Assistant 2021.10 + if last_reset is None and state_class == STATE_CLASS_MEASUREMENT: + # No valid updates + result.pop(entity_id) + continue + + if new_state is None or old_state is None: # No valid updates result.pop(entity_id) continue # Update the sum with the last state _sum += new_state - old_state - stat["last_reset"] = dt_util.parse_datetime(last_reset) + if last_reset is not None: + stat["last_reset"] = dt_util.parse_datetime(last_reset) stat["sum"] = _sum stat["state"] = new_state @@ -307,8 +352,8 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - statistic_ids = {} - for entity_id, key in entities: - provided_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[key] + for entity_id, state_class, key in entities: + provided_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[state_class][key] if statistic_type is not None and statistic_type not in provided_statistics: continue diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index f09cd489489..793bcaf4f99 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1,6 +1,7 @@ """The test for sensor device automation.""" from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util async def test_deprecated_temperature_conversion( @@ -28,3 +29,24 @@ async def test_deprecated_temperature_conversion( "your configuration if device_class is manually configured, otherwise report it " "to the custom component author." ) in caplog.text + + +async def test_deprecated_last_reset(hass, caplog, enable_custom_integrations): + """Test warning on deprecated last reset.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", state_class="measurement", last_reset=dt_util.utc_from_timestamp(0) + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "Entity sensor.test () " + "with state_class measurement has set last_reset. Setting last_reset for " + "entities with state_class other than 'total' is deprecated and will be " + "removed from Home Assistant Core 2021.10. Please update your configuration if " + "state_class is manually configured, otherwise report it to the custom " + "component author." + ) in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index a612bc75a77..45d81e4b678 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -154,6 +154,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize("state_class", ["measurement", "total"]) @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -165,8 +166,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes ("gas", "ft³", "m³", 0.0283168466), ], ) -def test_compile_hourly_sum_statistics( - hass_recorder, caplog, device_class, unit, native_unit, factor +def test_compile_hourly_sum_statistics_amount( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor ): """Test compiling hourly statistics.""" zero = dt_util.utcnow() @@ -175,7 +176,7 @@ def test_compile_hourly_sum_statistics( setup_component(hass, "sensor", {}) attributes = { "device_class": device_class, - "state_class": "measurement", + "state_class": state_class, "unit_of_measurement": unit, "last_reset": None, } @@ -237,6 +238,168 @@ def test_compile_hourly_sum_statistics( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ("energy", "Wh", "kWh", 1 / 1000), + ("monetary", "EUR", "EUR", 1), + ("monetary", "SEK", "SEK", 1), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), + ], +) +def test_compile_hourly_sum_statistics_total_no_reset( + hass_recorder, caplog, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "total", + "unit_of_measurement": unit, + } + seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] + + four, eight, states = record_meter_states( + hass, zero, "sensor.test1", attributes, seq + ) + hist = history.get_significant_states( + hass, zero - timedelta.resolution, eight + timedelta.resolution + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[2]), + "sum": approx(factor * 10.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[5]), + "sum": approx(factor * 30.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[8]), + "sum": approx(factor * 60.0), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ("energy", "Wh", "kWh", 1 / 1000), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), + ], +) +def test_compile_hourly_sum_statistics_total_increasing( + hass_recorder, caplog, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "total_increasing", + "unit_of_measurement": unit, + } + seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] + + four, eight, states = record_meter_states( + hass, zero, "sensor.test1", attributes, seq + ) + hist = history.get_significant_states( + hass, zero - timedelta.resolution, eight + timedelta.resolution + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[2]), + "sum": approx(factor * 10.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[5]), + "sum": approx(factor * 40.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[8]), + "sum": approx(factor * 70.0), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): """Test compiling hourly statistics.""" zero = dt_util.utcnow() diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index f4b2e96321e..63f47a0f854 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -71,6 +71,11 @@ class MockSensor(MockEntity, sensor.SensorEntity): """Return the class of this sensor.""" return self._handle("device_class") + @property + def last_reset(self): + """Return the last_reset of this sensor.""" + return self._handle("last_reset") + @property def native_unit_of_measurement(self): """Return the native unit_of_measurement of this sensor.""" @@ -80,3 +85,8 @@ class MockSensor(MockEntity, sensor.SensorEntity): def native_value(self): """Return the native value of this sensor.""" return self._handle("native_value") + + @property + def state_class(self): + """Return the state class of this sensor.""" + return self._handle("state_class") From 3454102dc87bd5451cfddeadea4ba6e388dec8c7 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 13 Aug 2021 17:03:13 +0200 Subject: [PATCH 362/903] Fix for 'list index out of range' (#54588) --- homeassistant/components/solaredge_local/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index f9ac2b853e7..9d162e919f4 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -50,6 +50,7 @@ SENSOR_TYPES = { ELECTRIC_POTENTIAL_VOLT, "mdi:current-ac", None, + None, ], "current_DC_voltage": [ "dcvoltage", @@ -57,6 +58,7 @@ SENSOR_TYPES = { ELECTRIC_POTENTIAL_VOLT, "mdi:current-dc", None, + None, ], "current_frequency": [ "gridfrequency", From 2c1728022df0484d75033c82b219e5bfb061f918 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Fri, 13 Aug 2021 18:13:25 +0200 Subject: [PATCH 363/903] Use ssdp callbacks in upnp (#53840) --- .../components/dlna_dmr/manifest.json | 2 +- homeassistant/components/ssdp/__init__.py | 34 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/__init__.py | 66 ++- homeassistant/components/upnp/config_flow.py | 133 ++++-- homeassistant/components/upnp/const.py | 10 +- homeassistant/components/upnp/device.py | 45 -- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ssdp/test_init.py | 98 +++- tests/components/upnp/common.py | 23 + tests/components/upnp/mock_ssdp_scanner.py | 49 ++ .../{mock_device.py => mock_upnp_device.py} | 23 +- tests/components/upnp/test_config_flow.py | 429 +++++++----------- tests/components/upnp/test_init.py | 53 +-- 17 files changed, 531 insertions(+), 444 deletions(-) create mode 100644 tests/components/upnp/common.py create mode 100644 tests/components/upnp/mock_ssdp_scanner.py rename tests/components/upnp/{mock_device.py => mock_upnp_device.py} (77%) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index e9ac437fe46..1975128a8cc 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dlna_dmr", "name": "DLNA Digital Media Renderer", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.19.1"], + "requirements": ["async-upnp-client==0.19.2"], "dependencies": ["network"], "codeowners": [], "iot_class": "local_push" diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 31ebb0d1a92..96bf47d920d 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -9,6 +9,7 @@ import logging from typing import Any, Callable from async_upnp_client.search import SSDPListener +from async_upnp_client.ssdp import SSDP_PORT from async_upnp_client.utils import CaseInsensitiveDict from homeassistant import config_entries @@ -228,6 +229,21 @@ class Scanner: for listener in self._ssdp_listeners: listener.async_search() + self.async_scan_broadcast() + + @core_callback + def async_scan_broadcast(self, *_: Any) -> None: + """Scan for new entries using broadcast target.""" + # Some sonos devices only seem to respond if we send to the broadcast + # address. This matches pysonos' behavior + # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 + for listener in self._ssdp_listeners: + try: + IPv4Address(listener.source_ip) + except ValueError: + continue + listener.async_search((str(IPV4_BROADCAST), SSDP_PORT)) + async def async_start(self) -> None: """Start the scanner.""" self.description_manager = DescriptionManager(self.hass) @@ -238,20 +254,6 @@ class Scanner: async_callback=self._async_process_entry, source_ip=source_ip ) ) - try: - IPv4Address(source_ip) - except ValueError: - continue - # Some sonos devices only seem to respond if we send to the broadcast - # address. This matches pysonos' behavior - # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 - self._ssdp_listeners.append( - SSDPListener( - async_callback=self._async_process_entry, - source_ip=source_ip, - target_ip=IPV4_BROADCAST, - ) - ) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start @@ -275,6 +277,10 @@ class Scanner: self.hass, self.async_scan, SCAN_INTERVAL ) + # Trigger a broadcast-scan. Regular scan is implicitly triggered + # by SSDPListener. + self.async_scan_broadcast() + @core_callback def _async_get_matching_callbacks( self, headers: Mapping[str, str] diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 432686d9027..ef4b92b4a14 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ssdp", "requirements": [ "defusedxml==0.7.1", - "async-upnp-client==0.19.1" + "async-upnp-client==0.19.2" ], "dependencies": ["network"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 6ad7111ae12..08e6a35f5b3 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -1,6 +1,10 @@ """Open ports in your router for Home Assistant and provide statistics.""" +from __future__ import annotations + import asyncio +from collections.abc import Mapping from ipaddress import ip_address +from typing import Any import voluptuous as vol @@ -9,7 +13,7 @@ from homeassistant.components import ssdp from homeassistant.components.network import async_get_source_ip from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType @@ -44,21 +48,6 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_construct_device(hass: HomeAssistant, udn: str, st: str) -> Device: - """Discovery devices and construct a Device for one.""" - # pylint: disable=invalid-name - _LOGGER.debug("Constructing device: %s::%s", udn, st) - discovery_info = ssdp.async_get_discovery_info_by_udn_st(hass, udn, st) - - if not discovery_info: - _LOGGER.info("Device not discovered") - return None - - return await Device.async_create_device( - hass, discovery_info[ssdp.ATTR_SSDP_LOCATION] - ) - - async def async_setup(hass: HomeAssistant, config: ConfigType): """Set up UPnP component.""" _LOGGER.debug("async_setup, config: %s", config) @@ -86,20 +75,47 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up UPnP/IGD device from a config entry.""" _LOGGER.debug("Setting up config entry: %s", entry.unique_id) - # Discover and construct. udn = entry.data[CONFIG_ENTRY_UDN] st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name - try: - device = await async_construct_device(hass, udn, st) - except asyncio.TimeoutError as err: - raise ConfigEntryNotReady from err + usn = f"{udn}::{st}" - if not device: - _LOGGER.info("Unable to create UPnP/IGD, aborting") - raise ConfigEntryNotReady + # Register device discovered-callback. + device_discovered_event = asyncio.Event() + discovery_info: Mapping[str, Any] | None = None + + @callback + def device_discovered(info: Mapping[str, Any]) -> None: + nonlocal discovery_info + _LOGGER.debug( + "Device discovered: %s, at: %s", usn, info[ssdp.ATTR_SSDP_LOCATION] + ) + discovery_info = info + device_discovered_event.set() + + cancel_discovered_callback = ssdp.async_register_callback( + hass, + device_discovered, + { + "usn": usn, + }, + ) + + try: + await asyncio.wait_for(device_discovered_event.wait(), timeout=10) + except asyncio.TimeoutError as err: + _LOGGER.debug("Device not discovered: %s", usn) + raise ConfigEntryNotReady from err + finally: + cancel_discovered_callback() + + # Create device. + location = discovery_info[ # pylint: disable=unsubscriptable-object + ssdp.ATTR_SSDP_LOCATION + ] + device = await Device.async_create_device(hass, location) # Save device. - hass.data[DOMAIN][DOMAIN_DEVICES][device.udn] = device + hass.data[DOMAIN][DOMAIN_DEVICES][udn] = device # Ensure entry has a unique_id. if not entry.unique_id: diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 0679d9ffcb5..89e1e5c71d0 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -1,6 +1,7 @@ """Config flow for UPNP.""" from __future__ import annotations +import asyncio from collections.abc import Mapping from datetime import timedelta from typing import Any @@ -10,7 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from .const import ( CONFIG_ENTRY_HOSTNAME, @@ -18,18 +19,70 @@ from .const import ( CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, - DISCOVERY_HOSTNAME, - DISCOVERY_LOCATION, - DISCOVERY_NAME, - DISCOVERY_ST, - DISCOVERY_UDN, - DISCOVERY_UNIQUE_ID, - DISCOVERY_USN, DOMAIN, DOMAIN_DEVICES, LOGGER as _LOGGER, + SSDP_SEARCH_TIMEOUT, + ST_IGD_V1, + ST_IGD_V2, ) -from .device import Device, discovery_info_to_discovery + + +def _friendly_name_from_discovery(discovery_info: Mapping[str, Any]) -> str: + """Extract user-friendly name from discovery.""" + return ( + discovery_info.get("friendlyName") + or discovery_info.get("modeName") + or discovery_info.get("_host", "") + ) + + +async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: + """Wait for a device to be discovered.""" + device_discovered_event = asyncio.Event() + + @callback + def device_discovered(info: Mapping[str, Any]) -> None: + _LOGGER.info( + "Device discovered: %s, at: %s", + info[ssdp.ATTR_SSDP_USN], + info[ssdp.ATTR_SSDP_LOCATION], + ) + device_discovered_event.set() + + cancel_discovered_callback_1 = ssdp.async_register_callback( + hass, + device_discovered, + { + ssdp.ATTR_SSDP_ST: ST_IGD_V1, + }, + ) + cancel_discovered_callback_2 = ssdp.async_register_callback( + hass, + device_discovered, + { + ssdp.ATTR_SSDP_ST: ST_IGD_V2, + }, + ) + + try: + await asyncio.wait_for( + device_discovered_event.wait(), timeout=SSDP_SEARCH_TIMEOUT + ) + except asyncio.TimeoutError: + return False + finally: + cancel_discovered_callback_1() + cancel_discovered_callback_2() + + return True + + +def _discovery_igd_devices(hass: HomeAssistant) -> list[Mapping[str, Any]]: + """Discovery IGD devices.""" + return ssdp.async_get_discovery_info_by_st( + hass, ST_IGD_V1 + ) + ssdp.async_get_discovery_info_by_st(hass, ST_IGD_V2) class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -57,22 +110,19 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): matching_discoveries = [ discovery for discovery in self._discoveries - if discovery[DISCOVERY_UNIQUE_ID] == user_input["unique_id"] + if discovery[ssdp.ATTR_SSDP_USN] == user_input["unique_id"] ] if not matching_discoveries: return self.async_abort(reason="no_devices_found") discovery = matching_discoveries[0] await self.async_set_unique_id( - discovery[DISCOVERY_UNIQUE_ID], raise_on_progress=False + discovery[ssdp.ATTR_SSDP_USN], raise_on_progress=False ) return await self._async_create_entry_from_discovery(discovery) # Discover devices. - discoveries = [ - await Device.async_supplement_discovery(self.hass, discovery) - for discovery in await Device.async_discover(self.hass) - ] + discoveries = _discovery_igd_devices(self.hass) # Store discoveries which have not been configured. current_unique_ids = { @@ -81,7 +131,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._discoveries = [ discovery for discovery in discoveries - if discovery[DISCOVERY_UNIQUE_ID] not in current_unique_ids + if discovery[ssdp.ATTR_SSDP_USN] not in current_unique_ids ] # Ensure anything to add. @@ -92,7 +142,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): { vol.Required("unique_id"): vol.In( { - discovery[DISCOVERY_UNIQUE_ID]: discovery[DISCOVERY_NAME] + discovery[ssdp.ATTR_SSDP_USN]: _friendly_name_from_discovery( + discovery + ) for discovery in self._discoveries } ), @@ -119,27 +171,27 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") # Discover devices. - self._discoveries = await Device.async_discover(self.hass) + await _async_wait_for_discoveries(self.hass) + discoveries = _discovery_igd_devices(self.hass) # Ensure anything to add. If not, silently abort. - if not self._discoveries: + if not discoveries: _LOGGER.info("No UPnP devices discovered, aborting") return self.async_abort(reason="no_devices_found") # Ensure complete discovery. - discovery = self._discoveries[0] + discovery = discoveries[0] if ( - DISCOVERY_UDN not in discovery - or DISCOVERY_ST not in discovery - or DISCOVERY_LOCATION not in discovery - or DISCOVERY_USN not in discovery + ssdp.ATTR_UPNP_UDN not in discovery + or ssdp.ATTR_SSDP_ST not in discovery + or ssdp.ATTR_SSDP_LOCATION not in discovery + or ssdp.ATTR_SSDP_USN not in discovery ): _LOGGER.debug("Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") # Ensure not already configuring/configured. - discovery = await Device.async_supplement_discovery(self.hass, discovery) - unique_id = discovery[DISCOVERY_UNIQUE_ID] + unique_id = discovery[ssdp.ATTR_SSDP_USN] await self.async_set_unique_id(unique_id) return await self._async_create_entry_from_discovery(discovery) @@ -162,35 +214,28 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug("Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") - # Convert to something we understand/speak. - discovery = discovery_info_to_discovery(discovery_info) - # Ensure not already configuring/configured. - unique_id = discovery[DISCOVERY_USN] + unique_id = discovery_info[ssdp.ATTR_SSDP_USN] await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured( - updates={CONFIG_ENTRY_HOSTNAME: discovery[DISCOVERY_HOSTNAME]} - ) + hostname = discovery_info["_host"] + self._abort_if_unique_id_configured(updates={CONFIG_ENTRY_HOSTNAME: hostname}) - # Handle devices changing their UDN, only allow a single + # Handle devices changing their UDN, only allow a single host. existing_entries = self._async_current_entries() for config_entry in existing_entries: entry_hostname = config_entry.data.get(CONFIG_ENTRY_HOSTNAME) - if entry_hostname == discovery[DISCOVERY_HOSTNAME]: + if entry_hostname == hostname: _LOGGER.debug( "Found existing config_entry with same hostname, discovery ignored" ) return self.async_abort(reason="discovery_ignored") - # Get more data about the device. - discovery = await Device.async_supplement_discovery(self.hass, discovery) - # Store discovery. - self._discoveries = [discovery] + self._discoveries = [discovery_info] # Ensure user recognizable. self.context["title_placeholders"] = { - "name": discovery[DISCOVERY_NAME], + "name": _friendly_name_from_discovery(discovery_info), } return await self.async_step_ssdp_confirm() @@ -224,11 +269,11 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): discovery, ) - title = discovery.get(DISCOVERY_NAME, "") + title = _friendly_name_from_discovery(discovery) data = { - CONFIG_ENTRY_UDN: discovery[DISCOVERY_UDN], - CONFIG_ENTRY_ST: discovery[DISCOVERY_ST], - CONFIG_ENTRY_HOSTNAME: discovery[DISCOVERY_HOSTNAME], + CONFIG_ENTRY_UDN: discovery["_udn"], + CONFIG_ENTRY_ST: discovery[ssdp.ATTR_SSDP_ST], + CONFIG_ENTRY_HOSTNAME: discovery["_host"], } return self.async_create_entry(title=title, data=data) diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index 0611176350a..cbb071bc15e 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -20,15 +20,11 @@ DATA_PACKETS = "packets" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}" KIBIBYTE = 1024 UPDATE_INTERVAL = timedelta(seconds=30) -DISCOVERY_HOSTNAME = "hostname" -DISCOVERY_LOCATION = "location" -DISCOVERY_NAME = "name" -DISCOVERY_ST = "st" -DISCOVERY_UDN = "udn" -DISCOVERY_UNIQUE_ID = "unique_id" -DISCOVERY_USN = "usn" CONFIG_ENTRY_SCAN_INTERVAL = "scan_interval" CONFIG_ENTRY_ST = "st" CONFIG_ENTRY_UDN = "udn" CONFIG_ENTRY_HOSTNAME = "hostname" DEFAULT_SCAN_INTERVAL = timedelta(seconds=30).total_seconds() +ST_IGD_V1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" +ST_IGD_V2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2" +SSDP_SEARCH_TIMEOUT = 4 diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index cf76aa41f8a..5e6f8ef5023 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -12,7 +12,6 @@ from async_upnp_client.aiohttp import AiohttpSessionRequester from async_upnp_client.device_updater import DeviceUpdater from async_upnp_client.profiles.igd import IgdDevice -from homeassistant.components import ssdp from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -22,13 +21,6 @@ from .const import ( BYTES_RECEIVED, BYTES_SENT, CONF_LOCAL_IP, - DISCOVERY_HOSTNAME, - DISCOVERY_LOCATION, - DISCOVERY_NAME, - DISCOVERY_ST, - DISCOVERY_UDN, - DISCOVERY_UNIQUE_ID, - DISCOVERY_USN, DOMAIN, DOMAIN_CONFIG, LOGGER as _LOGGER, @@ -38,20 +30,6 @@ from .const import ( ) -def discovery_info_to_discovery(discovery_info: Mapping) -> Mapping: - """Convert a SSDP-discovery to 'our' discovery.""" - location = discovery_info[ssdp.ATTR_SSDP_LOCATION] - parsed = urlparse(location) - hostname = parsed.hostname - return { - DISCOVERY_UDN: discovery_info[ssdp.ATTR_UPNP_UDN], - DISCOVERY_ST: discovery_info[ssdp.ATTR_SSDP_ST], - DISCOVERY_LOCATION: discovery_info[ssdp.ATTR_SSDP_LOCATION], - DISCOVERY_USN: discovery_info[ssdp.ATTR_SSDP_USN], - DISCOVERY_HOSTNAME: hostname, - } - - def _get_local_ip(hass: HomeAssistant) -> IPv4Address | None: """Get the configured local ip.""" if DOMAIN in hass.data and DOMAIN_CONFIG in hass.data[DOMAIN]: @@ -70,29 +48,6 @@ class Device: self._device_updater = device_updater self.coordinator: DataUpdateCoordinator = None - @classmethod - async def async_discover(cls, hass: HomeAssistant) -> list[Mapping]: - """Discover UPnP/IGD devices.""" - _LOGGER.debug("Discovering UPnP/IGD devices") - discoveries = [] - for ssdp_st in IgdDevice.DEVICE_TYPES: - for discovery_info in ssdp.async_get_discovery_info_by_st(hass, ssdp_st): - discoveries.append(discovery_info_to_discovery(discovery_info)) - return discoveries - - @classmethod - async def async_supplement_discovery( - cls, hass: HomeAssistant, discovery: Mapping - ) -> Mapping: - """Get additional data from device and supplement discovery.""" - location = discovery[DISCOVERY_LOCATION] - device = await Device.async_create_device(hass, location) - discovery[DISCOVERY_NAME] = device.name - discovery[DISCOVERY_HOSTNAME] = device.hostname - discovery[DISCOVERY_UNIQUE_ID] = discovery[DISCOVERY_USN] - - return discovery - @classmethod async def async_create_device( cls, hass: HomeAssistant, ssdp_location: str diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 41d50b4bae8..937518c34ac 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.19.1"], + "requirements": ["async-upnp-client==0.19.2"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman"], "ssdp": [ diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 323b1c86034..4c3aca7f2dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.19.1 +async-upnp-client==0.19.2 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.4.0 diff --git a/requirements_all.txt b/requirements_all.txt index 76a9b4f7543..1045ec26c62 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -311,7 +311,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp -async-upnp-client==0.19.1 +async-upnp-client==0.19.2 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 988c2bfb2c0..aaf5771e559 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -202,7 +202,7 @@ arcam-fmj==0.7.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp -async-upnp-client==0.19.1 +async-upnp-client==0.19.2 # homeassistant.components.aurora auroranoaa==0.0.2 diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 34ca1b7228e..94cf8a58908 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -29,7 +29,13 @@ def _patched_ssdp_listener(info, *args, **kwargs): async def _async_callback(*_): await listener.async_callback(info) + @callback + def _async_search(*_): + # Prevent an actual scan. + pass + listener.async_start = _async_callback + listener.async_search = _async_search return listener @@ -287,7 +293,10 @@ async def test_invalid_characters(hass, aioclient_mock): @patch("homeassistant.components.ssdp.SSDPListener.async_start") @patch("homeassistant.components.ssdp.SSDPListener.async_search") -async def test_start_stop_scanner(async_start_mock, async_search_mock, hass): +@patch("homeassistant.components.ssdp.SSDPListener.async_stop") +async def test_start_stop_scanner( + async_stop_mock, async_search_mock, async_start_mock, hass +): """Test we start and stop the scanner.""" assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) @@ -295,15 +304,18 @@ async def test_start_stop_scanner(async_start_mock, async_search_mock, hass): await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert async_start_mock.call_count == 2 - assert async_search_mock.call_count == 2 + assert async_start_mock.call_count == 1 + # Next is 3, as async_upnp_client triggers 1 SSDPListener._async_on_connect + assert async_search_mock.call_count == 3 + assert async_stop_mock.call_count == 0 hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert async_start_mock.call_count == 2 - assert async_search_mock.call_count == 2 + assert async_start_mock.call_count == 1 + assert async_search_mock.call_count == 3 + assert async_stop_mock.call_count == 1 async def test_unexpected_exception_while_fetching(hass, aioclient_mock, caplog): @@ -787,7 +799,6 @@ async def test_async_detect_interfaces_setting_empty_route(hass): assert argset == { (IPv6Address("2001:db8::"), None), - (IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")), (IPv4Address("192.168.1.5"), None), } @@ -802,12 +813,12 @@ async def test_bind_failure_skips_adapter(hass, caplog): ] } create_args = [] - did_search = 0 + search_args = [] @callback - def _callback(*_): - nonlocal did_search - did_search += 1 + def _callback(*args): + nonlocal search_args + search_args.append(args) pass def _generate_failing_ssdp_listener(*args, **kwargs): @@ -844,11 +855,74 @@ async def test_bind_failure_skips_adapter(hass, caplog): assert argset == { (IPv6Address("2001:db8::"), None), - (IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")), (IPv4Address("192.168.1.5"), None), } assert "Failed to setup listener for" in caplog.text async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert did_search == 2 + assert set(search_args) == { + (), + ( + ( + "255.255.255.255", + 1900, + ), + ), + } + + +async def test_ipv4_does_additional_search_for_sonos(hass, caplog): + """Test that only ipv4 does an additional search for Sonos.""" + mock_get_ssdp = { + "mock-domain": [ + { + ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + } + ] + } + search_args = [] + + def _generate_fake_ssdp_listener(*args, **kwargs): + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + pass + + @callback + def _callback(*args): + nonlocal search_args + search_args.append(args) + pass + + listener.async_start = _async_callback + listener.async_search = _callback + return listener + + with patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value=mock_get_ssdp, + ), patch( + "homeassistant.components.ssdp.SSDPListener", + new=_generate_fake_ssdp_listener, + ), patch( + "homeassistant.components.ssdp.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ): + assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() + + assert set(search_args) == { + (), + ( + ( + "255.255.255.255", + 1900, + ), + ), + } diff --git a/tests/components/upnp/common.py b/tests/components/upnp/common.py new file mode 100644 index 00000000000..4dd0fd4083d --- /dev/null +++ b/tests/components/upnp/common.py @@ -0,0 +1,23 @@ +"""Common for upnp.""" + +from urllib.parse import urlparse + +from homeassistant.components import ssdp + +TEST_UDN = "uuid:device" +TEST_ST = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" +TEST_USN = f"{TEST_UDN}::{TEST_ST}" +TEST_LOCATION = "http://192.168.1.1/desc.xml" +TEST_HOSTNAME = urlparse(TEST_LOCATION).hostname +TEST_FRIENDLY_NAME = "friendly name" +TEST_DISCOVERY = { + ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION, + ssdp.ATTR_SSDP_ST: TEST_ST, + ssdp.ATTR_SSDP_USN: TEST_USN, + ssdp.ATTR_UPNP_UDN: TEST_UDN, + "usn": TEST_USN, + "location": TEST_LOCATION, + "_host": TEST_HOSTNAME, + "_udn": TEST_UDN, + "friendlyName": TEST_FRIENDLY_NAME, +} diff --git a/tests/components/upnp/mock_ssdp_scanner.py b/tests/components/upnp/mock_ssdp_scanner.py new file mode 100644 index 00000000000..39f9a801bb6 --- /dev/null +++ b/tests/components/upnp/mock_ssdp_scanner.py @@ -0,0 +1,49 @@ +"""Mock ssdp.Scanner.""" +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import ssdp +from homeassistant.core import callback + + +class MockSsdpDescriptionManager(ssdp.DescriptionManager): + """Mocked ssdp DescriptionManager.""" + + async def fetch_description( + self, xml_location: str | None + ) -> None | dict[str, str]: + """Fetch the location or get it from the cache.""" + if xml_location is None: + return None + return {} + + +class MockSsdpScanner(ssdp.Scanner): + """Mocked ssdp Scanner.""" + + @callback + def async_stop(self, *_: Any) -> None: + """Stop the scanner.""" + # Do nothing. + + async def async_start(self) -> None: + """Start the scanner.""" + self.description_manager = MockSsdpDescriptionManager(self.hass) + + @callback + def async_scan(self, *_: Any) -> None: + """Scan for new entries.""" + # Do nothing. + + +@pytest.fixture +def mock_ssdp_scanner(): + """Mock ssdp Scanner.""" + with patch( + "homeassistant.components.ssdp.Scanner", new=MockSsdpScanner + ) as mock_ssdp_scanner: + yield mock_ssdp_scanner diff --git a/tests/components/upnp/mock_device.py b/tests/components/upnp/mock_upnp_device.py similarity index 77% rename from tests/components/upnp/mock_device.py rename to tests/components/upnp/mock_upnp_device.py index 7161ae69598..78adbc5e220 100644 --- a/tests/components/upnp/mock_device.py +++ b/tests/components/upnp/mock_upnp_device.py @@ -1,7 +1,9 @@ """Mock device for testing purposes.""" from typing import Any, Mapping -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant.components.upnp.const import ( BYTES_RECEIVED, @@ -13,6 +15,8 @@ from homeassistant.components.upnp.const import ( from homeassistant.components.upnp.device import Device from homeassistant.util import dt +from .common import TEST_UDN + class MockDevice(Device): """Mock device for Device.""" @@ -28,7 +32,7 @@ class MockDevice(Device): @classmethod async def async_create_device(cls, hass, ssdp_location) -> "MockDevice": """Return self.""" - return cls("UDN") + return cls(TEST_UDN) @property def udn(self) -> str: @@ -70,3 +74,18 @@ class MockDevice(Device): PACKETS_RECEIVED: 0, PACKETS_SENT: 0, } + + async def async_start(self) -> None: + """Start the device updater.""" + + async def async_stop(self) -> None: + """Stop the device updater.""" + + +@pytest.fixture +def mock_upnp_device(): + """Mock upnp Device.async_create_device.""" + with patch( + "homeassistant.components.upnp.Device", new=MockDevice + ) as mock_async_create_device: + yield mock_async_create_device diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 6e546be93f3..646bdb143e9 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -1,8 +1,9 @@ """Test UPnP/IGD config flow.""" from datetime import timedelta -from unittest.mock import AsyncMock, Mock, patch -from urllib.parse import urlparse +from unittest.mock import patch + +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import ssdp @@ -12,119 +13,92 @@ from homeassistant.components.upnp.const import ( CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, - DISCOVERY_HOSTNAME, - DISCOVERY_LOCATION, - DISCOVERY_NAME, - DISCOVERY_ST, - DISCOVERY_UDN, - DISCOVERY_UNIQUE_ID, - DISCOVERY_USN, DOMAIN, + DOMAIN_DEVICES, ) -from homeassistant.components.upnp.device import Device -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt -from .mock_device import MockDevice +from .common import ( + TEST_DISCOVERY, + TEST_FRIENDLY_NAME, + TEST_HOSTNAME, + TEST_LOCATION, + TEST_ST, + TEST_UDN, + TEST_USN, +) +from .mock_ssdp_scanner import mock_ssdp_scanner # noqa: F401 +from .mock_upnp_device import mock_upnp_device # noqa: F401 from tests.common import MockConfigEntry, async_fire_time_changed -async def test_flow_ssdp_discovery(hass: HomeAssistant): +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +async def test_flow_ssdp_discovery( + hass: HomeAssistant, +): """Test config flow: discovered + configured through ssdp.""" - udn = "uuid:device_1" - location = "http://dummy" - mock_device = MockDevice(udn) - ssdp_discoveries = [ - { - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - ssdp.ATTR_SSDP_USN: mock_device.usn, - } - ] - discoveries = [ - { - DISCOVERY_LOCATION: location, - DISCOVERY_NAME: mock_device.name, - DISCOVERY_ST: mock_device.device_type, - DISCOVERY_UDN: mock_device.udn, - DISCOVERY_UNIQUE_ID: mock_device.unique_id, - DISCOVERY_USN: mock_device.usn, - DISCOVERY_HOSTNAME: mock_device.hostname, - } - ] - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object( - ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries) - ), patch.object( - Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) - ): - # Discovered via step ssdp. - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_SSDP_USN: mock_device.usn, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "ssdp_confirm" - - # Confirm via step ssdp_confirm. - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == mock_device.name - assert result["data"] == { - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, - } - - -async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): - """Test config flow: incomplete discovery through ssdp.""" - udn = "uuid:device_1" - location = "http://dummy" - mock_device = MockDevice(udn) + # Ensure we have a ssdp Scanner. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] + ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY + # Speed up callback in ssdp.async_register_callback. + hass.state = CoreState.not_running + # Discovered via step ssdp. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=TEST_DISCOVERY, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "ssdp_confirm" + + # Confirm via step ssdp_confirm. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TEST_FRIENDLY_NAME + assert result["data"] == { + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, + } + + +@pytest.mark.usefixtures("mock_ssdp_scanner") +async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): + """Test config flow: incomplete discovery through ssdp.""" # Discovered via step ssdp. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data={ - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_SSDP_USN: mock_device.usn, - # ssdp.ATTR_UPNP_UDN: mock_device.udn, # Not provided. + ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION, + ssdp.ATTR_SSDP_ST: TEST_ST, + ssdp.ATTR_SSDP_USN: TEST_USN, + # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "incomplete_discovery" +@pytest.mark.usefixtures("mock_ssdp_scanner") async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): """Test config flow: discovery through ssdp, but ignored, as hostname is used by existing config entry.""" - udn = "uuid:device_random_1" - location = "http://dummy" - mock_device = MockDevice(udn) - # Existing entry. config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONFIG_ENTRY_UDN: "uuid:device_random_2", - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_HOSTNAME: urlparse(location).hostname, + CONFIG_ENTRY_UDN: TEST_UDN + "2", + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, }, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) @@ -134,129 +108,78 @@ async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_SSDP_USN: mock_device.usn, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - }, + data=TEST_DISCOVERY, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "discovery_ignored" +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") async def test_flow_user(hass: HomeAssistant): """Test config flow: discovered + configured through user.""" - udn = "uuid:device_1" - location = "http://dummy" - mock_device = MockDevice(udn) - ssdp_discoveries = [ - { - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - ssdp.ATTR_SSDP_USN: mock_device.usn, - } - ] - discoveries = [ - { - DISCOVERY_LOCATION: location, - DISCOVERY_NAME: mock_device.name, - DISCOVERY_ST: mock_device.device_type, - DISCOVERY_UDN: mock_device.udn, - DISCOVERY_UNIQUE_ID: mock_device.unique_id, - DISCOVERY_USN: mock_device.usn, - DISCOVERY_HOSTNAME: mock_device.hostname, - } - ] + # Ensure we have a ssdp Scanner. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] + ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY + # Speed up callback in ssdp.async_register_callback. + hass.state = CoreState.not_running - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object( - ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries) - ), patch.object( - Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) - ): - # Discovered via step user. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + # Discovered via step user. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" - # Confirmed via step user. - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"unique_id": mock_device.unique_id}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == mock_device.name - assert result["data"] == { - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, - } + # Confirmed via step user. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"unique_id": TEST_USN}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TEST_FRIENDLY_NAME + assert result["data"] == { + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, + } +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") async def test_flow_import(hass: HomeAssistant): - """Test config flow: discovered + configured through configuration.yaml.""" - udn = "uuid:device_1" - mock_device = MockDevice(udn) - location = "http://dummy" - ssdp_discoveries = [ - { - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - ssdp.ATTR_SSDP_USN: mock_device.usn, - } - ] - discoveries = [ - { - DISCOVERY_LOCATION: location, - DISCOVERY_NAME: mock_device.name, - DISCOVERY_ST: mock_device.device_type, - DISCOVERY_UDN: mock_device.udn, - DISCOVERY_UNIQUE_ID: mock_device.unique_id, - DISCOVERY_USN: mock_device.usn, - DISCOVERY_HOSTNAME: mock_device.hostname, - } - ] + """Test config flow: configured through configuration.yaml.""" + # Ensure we have a ssdp Scanner. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] + ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY + # Speed up callback in ssdp.async_register_callback. + hass.state = CoreState.not_running - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object( - ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries) - ), patch.object( - Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) - ): - # Discovered via step import. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == mock_device.name - assert result["data"] == { - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, - } + # Discovered via step import. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TEST_FRIENDLY_NAME + assert result["data"] == { + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, + } +@pytest.mark.usefixtures("mock_ssdp_scanner") async def test_flow_import_already_configured(hass: HomeAssistant): - """Test config flow: discovered, but already configured.""" - udn = "uuid:device_1" - mock_device = MockDevice(udn) - + """Test config flow: configured through configuration.yaml, but existing config entry.""" # Existing entry. config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, }, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) @@ -271,94 +194,88 @@ async def test_flow_import_already_configured(hass: HomeAssistant): assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_ssdp_scanner") async def test_flow_import_no_devices_found(hass: HomeAssistant): """Test config flow: no devices found, configured through configuration.yaml.""" - ssdp_discoveries = [] - with patch.object( - ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries) + # Ensure we have a ssdp Scanner. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] + ssdp_scanner.cache.clear() + + # Discovered via step import. + with patch( + "homeassistant.components.upnp.config_flow.SSDP_SEARCH_TIMEOUT", new=0.0 ): - # Discovered via step import. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") async def test_options_flow(hass: HomeAssistant): """Test options flow.""" + # Ensure we have a ssdp Scanner. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] + ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY + # Speed up callback in ssdp.async_register_callback. + hass.state = CoreState.not_running + # Set up config entry. - udn = "uuid:device_1" - location = "http://192.168.1.1/desc.xml" - mock_device = MockDevice(udn) - ssdp_discoveries = [ - { - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - ssdp.ATTR_SSDP_USN: mock_device.usn, - } - ] config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, }, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + mock_device = hass.data[DOMAIN][DOMAIN_DEVICES][TEST_UDN] - config = { - # no upnp, ensures no import-flow is started. + # Reset. + mock_device.times_polled = 0 + + # Forward time, ensure single poll after 30 (default) seconds. + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + assert mock_device.times_polled == 1 + + # Options flow with no input results in form. + result = await hass.config_entries.options.async_init( + config_entry.entry_id, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + # Options flow with input results in update to entry. + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONFIG_ENTRY_SCAN_INTERVAL: 60}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONFIG_ENTRY_SCAN_INTERVAL: 60, } - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object( - ssdp, - "async_get_discovery_info_by_udn_st", - Mock(return_value=ssdp_discoveries[0]), - ): - # Initialisation of component. - await async_setup_component(hass, "upnp", config) - await hass.async_block_till_done() - mock_device.times_polled = 0 # Reset. - # Forward time, ensure single poll after 30 (default) seconds. - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31)) - await hass.async_block_till_done() - assert mock_device.times_polled == 1 + # Forward time, ensure single poll after 60 seconds, still from original setting. + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=61)) + await hass.async_block_till_done() + assert mock_device.times_polled == 2 - # Options flow with no input results in form. - result = await hass.config_entries.options.async_init( - config_entry.entry_id, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + # Now the updated interval takes effect. + # Forward time, ensure single poll after 120 seconds. + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=121)) + await hass.async_block_till_done() + assert mock_device.times_polled == 3 - # Options flow with input results in update to entry. - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONFIG_ENTRY_SCAN_INTERVAL: 60}, - ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert config_entry.options == { - CONFIG_ENTRY_SCAN_INTERVAL: 60, - } - - # Forward time, ensure single poll after 60 seconds, still from original setting. - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=61)) - await hass.async_block_till_done() - assert mock_device.times_polled == 2 - - # Now the updated interval takes effect. - # Forward time, ensure single poll after 120 seconds. - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=121)) - await hass.async_block_till_done() - assert mock_device.times_polled == 3 - - # Forward time, ensure single poll after 180 seconds. - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=181)) - await hass.async_block_till_done() - assert mock_device.times_polled == 4 + # Forward time, ensure single poll after 180 seconds. + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=181)) + await hass.async_block_till_done() + assert mock_device.times_polled == 4 diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index 0770906f0da..9ccdbf02f4b 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -1,6 +1,7 @@ """Test UPnP/IGD setup process.""" +from __future__ import annotations -from unittest.mock import AsyncMock, Mock, patch +import pytest from homeassistant.components import ssdp from homeassistant.components.upnp.const import ( @@ -8,51 +9,37 @@ from homeassistant.components.upnp.const import ( CONFIG_ENTRY_UDN, DOMAIN, ) -from homeassistant.components.upnp.device import Device -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component -from .mock_device import MockDevice +from .common import TEST_DISCOVERY, TEST_ST, TEST_UDN +from .mock_ssdp_scanner import mock_ssdp_scanner # noqa: F401 +from .mock_upnp_device import mock_upnp_device # noqa: F401 from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") async def test_async_setup_entry_default(hass: HomeAssistant): """Test async_setup_entry.""" - udn = "uuid:device_1" - location = "http://192.168.1.1/desc.xml" - mock_device = MockDevice(udn) - discovery = { - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - ssdp.ATTR_SSDP_USN: mock_device.usn, - } entry = MockConfigEntry( domain=DOMAIN, data={ - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_ST: mock_device.device_type, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_ST: TEST_ST, }, ) - config = { - # no upnp - } - async_create_device = AsyncMock(return_value=mock_device) - mock_get_discovery = Mock() - with patch.object(Device, "async_create_device", async_create_device), patch.object( - ssdp, "async_get_discovery_info_by_udn_st", mock_get_discovery - ): - # initialisation of component, no device discovered - mock_get_discovery.return_value = None - await async_setup_component(hass, "upnp", config) - await hass.async_block_till_done() + # Initialisation of component, no device discovered. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() - # loading of config_entry, device discovered - mock_get_discovery.return_value = discovery - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) is True + # Device is discovered. + ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] + ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY + # Speed up callback in ssdp.async_register_callback. + hass.state = CoreState.not_running - # ensure device is stored/used - async_create_device.assert_called_with(hass, discovery[ssdp.ATTR_SSDP_LOCATION]) + # Load config_entry. + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) is True From eb278834de6527a395aaa560fcebb5b996f89923 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 13 Aug 2021 19:39:16 +0200 Subject: [PATCH 364/903] Add gas support to energy (#54560) Co-authored-by: Paulus Schoutsen --- homeassistant/components/energy/data.py | 31 ++- homeassistant/components/energy/sensor.py | 225 ++++++++++++++-------- tests/components/energy/test_sensor.py | 47 +++++ 3 files changed, 220 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index 9196694953a..1cea20564b4 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -88,7 +88,25 @@ class BatterySourceType(TypedDict): stat_energy_to: str -SourceType = Union[GridSourceType, SolarSourceType, BatterySourceType] +class GasSourceType(TypedDict): + """Dictionary holding the source of gas storage.""" + + type: Literal["gas"] + + stat_energy_from: str + + # statistic_id of costs ($) incurred from the energy meter + # If set to None and entity_energy_from and entity_energy_price are configured, + # an EnergyCostSensor will be automatically created + stat_cost: str | None + + # Used to generate costs if stat_cost is set to None + entity_energy_from: str | None # entity_id of an gas meter (m³), entity_id of the gas meter for stat_energy_from + entity_energy_price: str | None # entity_id of an entity providing price ($/m³) + number_energy_price: float | None # Price for energy ($/m³) + + +SourceType = Union[GridSourceType, SolarSourceType, BatterySourceType, GasSourceType] class DeviceConsumption(TypedDict): @@ -193,6 +211,16 @@ BATTERY_SOURCE_SCHEMA = vol.Schema( vol.Required("stat_energy_to"): str, } ) +GAS_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "gas", + vol.Required("stat_energy_from"): str, + vol.Optional("stat_cost"): vol.Any(str, None), + vol.Optional("entity_energy_from"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } +) def check_type_limits(value: list[SourceType]) -> list[SourceType]: @@ -214,6 +242,7 @@ ENERGY_SOURCE_SCHEMA = vol.All( "grid": GRID_SOURCE_SCHEMA, "solar": SOLAR_SOURCE_SCHEMA, "battery": BATTERY_SOURCE_SCHEMA, + "gas": GAS_SOURCE_SCHEMA, }, ) ] diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index ccf1a0d7b34..fd36611acaf 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -from functools import partial import logging from typing import Any, Final, Literal, TypeVar, cast @@ -16,6 +15,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, + VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -36,22 +36,19 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the energy sensors.""" - manager = await async_get_manager(hass) - process_now = partial(_process_manager_data, hass, manager, async_add_entities, {}) - manager.async_listen_updates(process_now) - - if manager.data: - await process_now() + sensor_manager = SensorManager(await async_get_manager(hass), async_add_entities) + await sensor_manager.async_start() T = TypeVar("T") @dataclass -class FlowAdapter: - """Adapter to allow flows to be used as sensors.""" +class SourceAdapter: + """Adapter to allow sources and their flows to be used as sensors.""" - flow_type: Literal["flow_from", "flow_to"] + source_type: Literal["grid", "gas"] + flow_type: Literal["flow_from", "flow_to", None] stat_energy_key: Literal["stat_energy_from", "stat_energy_to"] entity_energy_key: Literal["entity_energy_from", "entity_energy_to"] total_money_key: Literal["stat_cost", "stat_compensation"] @@ -59,8 +56,9 @@ class FlowAdapter: entity_id_suffix: str -FLOW_ADAPTERS: Final = ( - FlowAdapter( +SOURCE_ADAPTERS: Final = ( + SourceAdapter( + "grid", "flow_from", "stat_energy_from", "entity_energy_from", @@ -68,7 +66,8 @@ FLOW_ADAPTERS: Final = ( "Cost", "cost", ), - FlowAdapter( + SourceAdapter( + "grid", "flow_to", "stat_energy_to", "entity_energy_to", @@ -76,67 +75,112 @@ FLOW_ADAPTERS: Final = ( "Compensation", "compensation", ), + SourceAdapter( + "gas", + None, + "stat_energy_from", + "entity_energy_from", + "stat_cost", + "Cost", + "cost", + ), ) -async def _process_manager_data( - hass: HomeAssistant, - manager: EnergyManager, - async_add_entities: AddEntitiesCallback, - current_entities: dict[tuple[str, str], EnergyCostSensor], -) -> None: - """Process updated data.""" - to_add: list[SensorEntity] = [] - to_remove = dict(current_entities) +class SensorManager: + """Class to handle creation/removal of sensor data.""" - async def finish() -> None: - if to_add: - async_add_entities(to_add) + def __init__( + self, manager: EnergyManager, async_add_entities: AddEntitiesCallback + ) -> None: + """Initialize sensor manager.""" + self.manager = manager + self.async_add_entities = async_add_entities + self.current_entities: dict[tuple[str, str | None, str], EnergyCostSensor] = {} - for key, entity in to_remove.items(): - current_entities.pop(key) - await entity.async_remove() + async def async_start(self) -> None: + """Start.""" + self.manager.async_listen_updates(self._process_manager_data) + + if self.manager.data: + await self._process_manager_data() + + async def _process_manager_data(self) -> None: + """Process manager data.""" + to_add: list[SensorEntity] = [] + to_remove = dict(self.current_entities) + + async def finish() -> None: + if to_add: + self.async_add_entities(to_add) + + for key, entity in to_remove.items(): + self.current_entities.pop(key) + await entity.async_remove() + + if not self.manager.data: + await finish() + return + + for energy_source in self.manager.data["energy_sources"]: + for adapter in SOURCE_ADAPTERS: + if adapter.source_type != energy_source["type"]: + continue + + if adapter.flow_type is None: + self._process_sensor_data( + adapter, + # Opting out of the type complexity because can't get it to work + energy_source, # type: ignore + to_add, + to_remove, + ) + continue + + for flow in energy_source[adapter.flow_type]: # type: ignore + self._process_sensor_data( + adapter, + # Opting out of the type complexity because can't get it to work + flow, # type: ignore + to_add, + to_remove, + ) - if not manager.data: await finish() - return - for energy_source in manager.data["energy_sources"]: - if energy_source["type"] != "grid": - continue + @callback + def _process_sensor_data( + self, + adapter: SourceAdapter, + config: dict, + to_add: list[SensorEntity], + to_remove: dict[tuple[str, str | None, str], EnergyCostSensor], + ) -> None: + """Process sensor data.""" + # No need to create an entity if we already have a cost stat + if config.get(adapter.total_money_key) is not None: + return - for adapter in FLOW_ADAPTERS: - for flow in energy_source[adapter.flow_type]: - # Opting out of the type complexity because can't get it to work - untyped_flow = cast(dict, flow) + key = (adapter.source_type, adapter.flow_type, config[adapter.stat_energy_key]) - # No need to create an entity if we already have a cost stat - if untyped_flow.get(adapter.total_money_key) is not None: - continue + # Make sure the right data is there + # If the entity existed, we don't pop it from to_remove so it's removed + if config.get(adapter.entity_energy_key) is None or ( + config.get("entity_energy_price") is None + and config.get("number_energy_price") is None + ): + return - # This is unique among all flow_from's - key = (adapter.flow_type, untyped_flow[adapter.stat_energy_key]) + current_entity = to_remove.pop(key, None) + if current_entity: + current_entity.update_config(config) + return - # Make sure the right data is there - # If the entity existed, we don't pop it from to_remove so it's removed - if untyped_flow.get(adapter.entity_energy_key) is None or ( - untyped_flow.get("entity_energy_price") is None - and untyped_flow.get("number_energy_price") is None - ): - continue - - current_entity = to_remove.pop(key, None) - if current_entity: - current_entity.update_config(untyped_flow) - continue - - current_entities[key] = EnergyCostSensor( - adapter, - untyped_flow, - ) - to_add.append(current_entities[key]) - - await finish() + self.current_entities[key] = EnergyCostSensor( + adapter, + config, + ) + to_add.append(self.current_entities[key]) class EnergyCostSensor(SensorEntity): @@ -148,17 +192,19 @@ class EnergyCostSensor(SensorEntity): def __init__( self, - adapter: FlowAdapter, - flow: dict, + adapter: SourceAdapter, + config: dict, ) -> None: """Initialize the sensor.""" super().__init__() self._adapter = adapter - self.entity_id = f"{flow[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" + self.entity_id = ( + f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" + ) self._attr_device_class = DEVICE_CLASS_MONETARY self._attr_state_class = STATE_CLASS_MEASUREMENT - self._flow = flow + self._config = config self._last_energy_sensor_state: State | None = None self._cur_value = 0.0 @@ -174,7 +220,7 @@ class EnergyCostSensor(SensorEntity): def _update_cost(self) -> None: """Update incurred costs.""" energy_state = self.hass.states.get( - cast(str, self._flow[self._adapter.entity_energy_key]) + cast(str, self._config[self._adapter.entity_energy_key]) ) if energy_state is None or ATTR_LAST_RESET not in energy_state.attributes: @@ -186,8 +232,10 @@ class EnergyCostSensor(SensorEntity): return # Determine energy price - if self._flow["entity_energy_price"] is not None: - energy_price_state = self.hass.states.get(self._flow["entity_energy_price"]) + if self._config["entity_energy_price"] is not None: + energy_price_state = self.hass.states.get( + self._config["entity_energy_price"] + ) if energy_price_state is None: return @@ -197,14 +245,17 @@ class EnergyCostSensor(SensorEntity): except ValueError: return - if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith( - f"/{ENERGY_WATT_HOUR}" + if ( + self._adapter.source_type == "grid" + and energy_price_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, "" + ).endswith(f"/{ENERGY_WATT_HOUR}") ): energy_price *= 1000.0 else: energy_price_state = None - energy_price = cast(float, self._flow["number_energy_price"]) + energy_price = cast(float, self._config["number_energy_price"]) if self._last_energy_sensor_state is None: # Initialize as it's the first time all required entities are in place. @@ -213,9 +264,17 @@ class EnergyCostSensor(SensorEntity): energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if energy_unit == ENERGY_WATT_HOUR: - energy_price /= 1000 - elif energy_unit != ENERGY_KILO_WATT_HOUR: + if self._adapter.source_type == "grid": + if energy_unit == ENERGY_WATT_HOUR: + energy_price /= 1000 + elif energy_unit != ENERGY_KILO_WATT_HOUR: + energy_unit = None + + elif self._adapter.source_type == "gas": + if energy_unit != VOLUME_CUBIC_METERS: + energy_unit = None + + if energy_unit is None: _LOGGER.warning( "Found unexpected unit %s for %s", energy_unit, energy_state.entity_id ) @@ -237,11 +296,13 @@ class EnergyCostSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Register callbacks.""" - energy_state = self.hass.states.get(self._flow[self._adapter.entity_energy_key]) + energy_state = self.hass.states.get( + self._config[self._adapter.entity_energy_key] + ) if energy_state: name = energy_state.name else: - name = split_entity_id(self._flow[self._adapter.entity_energy_key])[ + name = split_entity_id(self._config[self._adapter.entity_energy_key])[ 0 ].replace("_", " ") @@ -251,7 +312,7 @@ class EnergyCostSensor(SensorEntity): # Store stat ID in hass.data so frontend can look it up self.hass.data[DOMAIN]["cost_sensors"][ - self._flow[self._adapter.entity_energy_key] + self._config[self._adapter.entity_energy_key] ] = self.entity_id @callback @@ -263,7 +324,7 @@ class EnergyCostSensor(SensorEntity): self.async_on_remove( async_track_state_change_event( self.hass, - cast(str, self._flow[self._adapter.entity_energy_key]), + cast(str, self._config[self._adapter.entity_energy_key]), async_state_changed_listener, ) ) @@ -271,14 +332,14 @@ class EnergyCostSensor(SensorEntity): async def async_will_remove_from_hass(self) -> None: """Handle removing from hass.""" self.hass.data[DOMAIN]["cost_sensors"].pop( - self._flow[self._adapter.entity_energy_key] + self._config[self._adapter.entity_energy_key] ) await super().async_will_remove_from_hass() @callback - def update_config(self, flow: dict) -> None: + def update_config(self, config: dict) -> None: """Update the config.""" - self._flow = flow + self._config = config @property def native_unit_of_measurement(self) -> str | None: diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 978b21e1919..1e89c05fbd6 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( DEVICE_CLASS_MONETARY, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, + VOLUME_CUBIC_METERS, ) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -295,3 +296,49 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: state = hass.states.get("sensor.energy_consumption_cost") assert state.state == "5.0" + + +async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: + """Test gas cost price from sensor entity.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption", + "entity_energy_from": "sensor.gas_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + last_reset = dt_util.utc_from_timestamp(0).isoformat() + + hass.states.async_set( + "sensor.gas_consumption", + 100, + {"last_reset": last_reset, "unit_of_measurement": VOLUME_CUBIC_METERS}, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.gas_consumption_cost") + assert state.state == "0.0" + + # gas use bumped to 10 kWh + hass.states.async_set( + "sensor.gas_consumption", + 200, + {"last_reset": last_reset, "unit_of_measurement": VOLUME_CUBIC_METERS}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.gas_consumption_cost") + assert state.state == "50.0" From 8264fd2eb68c3e6c06f8c173583fb9c355673f82 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 13 Aug 2021 20:48:31 +0200 Subject: [PATCH 365/903] Update frontend to 20210813.0 (#54603) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 135c0ec0244..a4a97914622 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210809.0" + "home-assistant-frontend==20210813.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4c3aca7f2dd..bbfc9a20381 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.46.0 -home-assistant-frontend==20210809.0 +home-assistant-frontend==20210813.0 httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 1045ec26c62..0522b51a14f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -786,7 +786,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210809.0 +home-assistant-frontend==20210813.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aaf5771e559..a1ed1bb69ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -452,7 +452,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210809.0 +home-assistant-frontend==20210813.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From f4fb5f2f5a2f7e3f69ecff295c09b22f688a85da Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 13 Aug 2021 15:42:55 -0500 Subject: [PATCH 366/903] Skip Sonos zeroconf availability check in non-timeout scenarios (#54425) --- homeassistant/components/sonos/speaker.py | 25 ++++++++++++++--------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 6e887dfdc53..9485d5dcff3 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -499,21 +499,26 @@ class SonosSpeaker: self.async_write_entity_states() - async def async_unseen(self, now: datetime.datetime | None = None) -> None: + async def async_unseen( + self, callback_timestamp: datetime.datetime | None = None + ) -> None: """Make this player unavailable when it was not seen recently.""" if self._seen_timer: self._seen_timer() self._seen_timer = None - hostname = uid_to_short_hostname(self.soco.uid) - zcname = f"{hostname}.{MDNS_SERVICE}" - aiozeroconf = await zeroconf.async_get_async_instance(self.hass) - if await aiozeroconf.async_get_service_info(MDNS_SERVICE, zcname): - # We can still see the speaker via zeroconf check again later. - self._seen_timer = self.hass.helpers.event.async_call_later( - SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen - ) - return + if callback_timestamp: + # Called by a _seen_timer timeout, check mDNS one more time + # This should not be checked in an "active" unseen scenario + hostname = uid_to_short_hostname(self.soco.uid) + zcname = f"{hostname}.{MDNS_SERVICE}" + aiozeroconf = await zeroconf.async_get_async_instance(self.hass) + if await aiozeroconf.async_get_service_info(MDNS_SERVICE, zcname): + # We can still see the speaker via zeroconf check again later. + self._seen_timer = self.hass.helpers.event.async_call_later( + SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen + ) + return _LOGGER.debug( "No activity and could not locate %s on the network. Marking unavailable", From 370b7f387da6706348d0c0a121c711e782e47d89 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 14 Aug 2021 00:11:27 +0000 Subject: [PATCH 367/903] [ci skip] Translation update --- .../components/adax/translations/cs.json | 1 + .../airvisual/translations/sensor.cs.json | 20 +++++++++++++++++++ .../alarm_control_panel/translations/fr.json | 1 + .../components/coinbase/translations/cs.json | 7 +++++++ .../forecast_solar/translations/cs.json | 13 ++++++++++++ .../nmap_tracker/translations/cs.json | 10 ++++++++++ .../components/sensor/translations/ca.json | 2 ++ .../uptimerobot/translations/cs.json | 6 +++++- .../components/zwave_js/translations/cs.json | 15 ++++++++++++++ 9 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/airvisual/translations/sensor.cs.json create mode 100644 homeassistant/components/forecast_solar/translations/cs.json diff --git a/homeassistant/components/adax/translations/cs.json b/homeassistant/components/adax/translations/cs.json index ce5fa77543f..1d090f44de2 100644 --- a/homeassistant/components/adax/translations/cs.json +++ b/homeassistant/components/adax/translations/cs.json @@ -10,6 +10,7 @@ "step": { "user": { "data": { + "account_id": "ID \u00fa\u010dtu", "host": "Hostitel", "password": "Heslo" } diff --git a/homeassistant/components/airvisual/translations/sensor.cs.json b/homeassistant/components/airvisual/translations/sensor.cs.json new file mode 100644 index 00000000000..44c834c7df6 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.cs.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Oxid uhelnat\u00fd", + "n2": "Oxid dusi\u010dit\u00fd", + "o3": "Oz\u00f3n", + "p1": "PM10", + "p2": "PM2,5", + "s2": "Oxid si\u0159i\u010dit\u00fd" + }, + "airvisual__pollutant_level": { + "good": "Dobr\u00e9", + "hazardous": "Riskantn\u00ed", + "moderate": "M\u00edrn\u00e9", + "unhealthy": "Nezdrav\u00e9", + "unhealthy_sensitive": "Nezdrav\u00e9 pro citliv\u00e9 skupiny", + "very_unhealthy": "Velmi nezdrav\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/fr.json b/homeassistant/components/alarm_control_panel/translations/fr.json index 6d8ee9c08c3..bbcb26f7184 100644 --- a/homeassistant/components/alarm_control_panel/translations/fr.json +++ b/homeassistant/components/alarm_control_panel/translations/fr.json @@ -12,6 +12,7 @@ "is_armed_away": "{entity_name} est arm\u00e9", "is_armed_home": "{entity_name} est arm\u00e9 \u00e0 la maison", "is_armed_night": "{entity_name} est arm\u00e9 la nuit", + "is_armed_vacation": "{entity_name} est arm\u00e9 en mode vacances", "is_disarmed": "{entity_name} est d\u00e9sarm\u00e9", "is_triggered": "{entity_name} est d\u00e9clench\u00e9" }, diff --git a/homeassistant/components/coinbase/translations/cs.json b/homeassistant/components/coinbase/translations/cs.json index 32a69bfe33d..c6f6a1f36f9 100644 --- a/homeassistant/components/coinbase/translations/cs.json +++ b/homeassistant/components/coinbase/translations/cs.json @@ -19,6 +19,13 @@ "options": { "error": { "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "init": { + "data": { + "exchange_base": "Z\u00e1kladn\u00ed m\u011bna pro senzory sm\u011bnn\u00fdch kurz\u016f." + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/cs.json b/homeassistant/components/forecast_solar/translations/cs.json new file mode 100644 index 00000000000..0b970643bbe --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/cs.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka", + "name": "Jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/cs.json b/homeassistant/components/nmap_tracker/translations/cs.json index 1a0d0ae0b53..ac5f913d8e6 100644 --- a/homeassistant/components/nmap_tracker/translations/cs.json +++ b/homeassistant/components/nmap_tracker/translations/cs.json @@ -3,5 +3,15 @@ "abort": { "already_configured": "Um\u00edst\u011bn\u00ed je ji\u017e nastaveno" } + }, + "options": { + "step": { + "init": { + "data": { + "interval_seconds": "Interval skenov\u00e1n\u00ed", + "track_new_devices": "Sledovat nov\u00e1 za\u0159\u00edzen\u00ed" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/sensor/translations/ca.json b/homeassistant/components/sensor/translations/ca.json index a30748d5c9c..f0df998170d 100644 --- a/homeassistant/components/sensor/translations/ca.json +++ b/homeassistant/components/sensor/translations/ca.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Concentraci\u00f3 actual de mon\u00f2xid de carboni de {entity_name}", "is_current": "Intensitat actual de {entity_name}", "is_energy": "Energia actual de {entity_name}", + "is_gas": "Gas actual de {entity_name}", "is_humidity": "Humitat actual de {entity_name}", "is_illuminance": "Il\u00b7luminaci\u00f3 actual de {entity_name}", "is_power": "Pot\u00e8ncia actual de {entity_name}", @@ -22,6 +23,7 @@ "carbon_monoxide": "Canvia la concentraci\u00f3 de mon\u00f2xid de carboni de {entity_name}", "current": "Canvia la intensitat de {entity_name}", "energy": "Canvia l'energia de {entity_name}", + "gas": "Canvia el gas de {entity_name}", "humidity": "Canvia la humitat de {entity_name}", "illuminance": "Canvia la il\u00b7luminaci\u00f3 de {entity_name}", "power": "Canvia la pot\u00e8ncia de {entity_name}", diff --git a/homeassistant/components/uptimerobot/translations/cs.json b/homeassistant/components/uptimerobot/translations/cs.json index fb6f0bfa70d..09480693834 100644 --- a/homeassistant/components/uptimerobot/translations/cs.json +++ b/homeassistant/components/uptimerobot/translations/cs.json @@ -2,12 +2,14 @@ "config": { "abort": { "already_configured": "\u00da\u010det je ji\u017e nastaven", + "reauth_failed_existing": "Nepoda\u0159ilo se aktualizovat polo\u017eku konfigurace, odstra\u0148te pros\u00edm integraci a nastavte ji znovu.", "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, "error": { "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", "invalid_api_key": "Neplatn\u00fd kl\u00ed\u010d API", + "reauth_failed_matching_account": "Zadan\u00fd kl\u00ed\u010d API neodpov\u00edd\u00e1 ID \u00fa\u010dtu pro st\u00e1vaj\u00edc\u00ed konfiguraci.", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, "step": { @@ -15,12 +17,14 @@ "data": { "api_key": "Kl\u00ed\u010d API" }, + "description": "Je t\u0159eba zadat nov\u00fd kl\u00ed\u010d API \u010dten\u00ed od spole\u010dnosti Uptime Robot.", "title": "Znovu ov\u011b\u0159it integraci" }, "user": { "data": { "api_key": "Kl\u00ed\u010d API" - } + }, + "description": "Mus\u00edte zadat kl\u00ed\u010d API pro \u010dten\u00ed od spole\u010dnosti Uptime Robot." } } } diff --git a/homeassistant/components/zwave_js/translations/cs.json b/homeassistant/components/zwave_js/translations/cs.json index 9f8af44c451..05efdb8e5ff 100644 --- a/homeassistant/components/zwave_js/translations/cs.json +++ b/homeassistant/components/zwave_js/translations/cs.json @@ -21,5 +21,20 @@ } } } + }, + "device_automation": { + "condition_type": { + "config_parameter": "Hodnota konfigura\u010dn\u00edho parametru {subtype}", + "node_status": "Stav uzlu", + "value": "Aktu\u00e1ln\u00ed hodnota Z-Wave hodnoty" + }, + "trigger_type": { + "event.notification.entry_control": "Odeslat ozn\u00e1men\u00ed o \u0159\u00edzen\u00ed vstupu", + "event.notification.notification": "Odeslal ozn\u00e1men\u00ed", + "event.value_notification.basic": "Z\u00e1kladn\u00ed ud\u00e1lost CC na {subtype}", + "event.value_notification.central_scene": "Akce centr\u00e1ln\u00ed sc\u00e9ny na {subtype}", + "event.value_notification.scene_activation": "Aktivace sc\u00e9ny na {subtype}", + "state.node_status": "Stav uzlu zm\u011bn\u011bn" + } } } \ No newline at end of file From c10497d49961a007a9106b9a9039e529a4ba62c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Aug 2021 01:26:57 -0500 Subject: [PATCH 368/903] Bump zeroconf to 0.35.0 (#54604) Fixes https://github.com/home-assistant/core/issues/54531 Fixes https://github.com/home-assistant/core/issues/54434 Fixes https://github.com/home-assistant/core/issues/54487 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 83db312601c..b971ec06179 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.34.3"], + "requirements": ["zeroconf==0.35.0"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bbfc9a20381..3f7eeb65ab5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.17 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.34.3 +zeroconf==0.35.0 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 0522b51a14f..bb2d174bc23 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2439,7 +2439,7 @@ zeep[async]==4.0.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.34.3 +zeroconf==0.35.0 # homeassistant.components.zha zha-quirks==0.0.59 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1ed1bb69ad..ca2b5e79894 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1347,7 +1347,7 @@ youless-api==0.10 zeep[async]==4.0.0 # homeassistant.components.zeroconf -zeroconf==0.34.3 +zeroconf==0.35.0 # homeassistant.components.zha zha-quirks==0.0.59 From 2c181181e15a4a45168d4e185f1309f63043a867 Mon Sep 17 00:00:00 2001 From: Oxan van Leeuwen Date: Sat, 14 Aug 2021 08:27:47 +0200 Subject: [PATCH 369/903] Clamp color temperature to supported range in ESPHome light (#54595) ESPHome devices initially report a color temperature of 0 or 1 until it has been changed by the user. This broke the conversion from RGBWW to an RGB color. Fixes #54293. --- homeassistant/components/esphome/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index b89a75ab76a..aeecc22d9f1 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -230,7 +230,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # Try to reverse white + color temp to cwww min_ct = self._static_info.min_mireds max_ct = self._static_info.max_mireds - color_temp = self._state.color_temperature + color_temp = min(max(self._state.color_temperature, min_ct), max_ct) white = self._state.white ww_frac = (color_temp - min_ct) / (max_ct - min_ct) From 102789672a699f1f579f1669967da5ce03f662de Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 14 Aug 2021 08:38:42 +0200 Subject: [PATCH 370/903] Bump python-miio to 0.5.7 (#54601) --- homeassistant/components/xiaomi_miio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 1f37d624b95..6d3c5e50be8 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi Miio", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", - "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.6"], + "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.7"], "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index bb2d174bc23..09a67edb29c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1860,7 +1860,7 @@ python-juicenet==1.0.2 # python-lirc==1.2.3 # homeassistant.components.xiaomi_miio -python-miio==0.5.6 +python-miio==0.5.7 # homeassistant.components.mpd python-mpd2==3.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca2b5e79894..44ab676e9f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1041,7 +1041,7 @@ python-izone==1.1.6 python-juicenet==1.0.2 # homeassistant.components.xiaomi_miio -python-miio==0.5.6 +python-miio==0.5.7 # homeassistant.components.nest python-nest==4.1.0 From 08d8b026d00cec09fc2b03552710f53d21b3809b Mon Sep 17 00:00:00 2001 From: Colin O'Dell Date: Sat, 14 Aug 2021 02:44:52 -0400 Subject: [PATCH 371/903] Upgrade qnapstats library to 0.4.0 (#54571) --- homeassistant/components/qnap/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/qnap/manifest.json b/homeassistant/components/qnap/manifest.json index abd5d6f5a4a..217d14a6adf 100644 --- a/homeassistant/components/qnap/manifest.json +++ b/homeassistant/components/qnap/manifest.json @@ -2,7 +2,7 @@ "domain": "qnap", "name": "QNAP", "documentation": "https://www.home-assistant.io/integrations/qnap", - "requirements": ["qnapstats==0.3.1"], + "requirements": ["qnapstats==0.4.0"], "codeowners": ["@colinodell"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 09a67edb29c..dce813853da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pyzbar==0.1.7 pyzerproc==0.4.8 # homeassistant.components.qnap -qnapstats==0.3.1 +qnapstats==0.4.0 # homeassistant.components.quantum_gateway quantum-gateway==0.0.5 From 8d7a136fc4e90b4f0136fc410c447598eea290cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jur=C4=8Da?= Date: Sat, 14 Aug 2021 11:05:23 +0200 Subject: [PATCH 372/903] Add MySensors S_MOISTURE type as sensor (#54583) --- homeassistant/components/mysensors/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index bb56770fd0c..396d0e2519b 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -85,6 +85,7 @@ SENSORS: dict[str, list[str | None] | dict[str, list[str | None]]] = { DEVICE_CLASS_ILLUMINANCE, STATE_CLASS_MEASUREMENT, ], + "S_MOISTURE": [PERCENTAGE, "mdi:water-percent", None, None], }, "V_VOLTAGE": [ ELECTRIC_POTENTIAL_VOLT, From a9807e5fa792965d010acf5642a5042bc79cec45 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 15 Aug 2021 00:11:00 +0000 Subject: [PATCH 373/903] [ci skip] Translation update --- .../components/sensor/translations/nl.json | 2 ++ .../components/tractive/translations/nl.json | 19 +++++++++++++++++++ .../uptimerobot/translations/nl.json | 17 +++++++++++++++-- .../components/weather/translations/ru.json | 2 +- 4 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/tractive/translations/nl.json diff --git a/homeassistant/components/sensor/translations/nl.json b/homeassistant/components/sensor/translations/nl.json index 745e097c6ee..933caf15de8 100644 --- a/homeassistant/components/sensor/translations/nl.json +++ b/homeassistant/components/sensor/translations/nl.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Huidig niveau {entity_name} koolmonoxideconcentratie", "is_current": "Huidige {entity_name} stroom", "is_energy": "Huidige {entity_name} energie", + "is_gas": "Huidig {entity_name} gas", "is_humidity": "Huidige {entity_name} vochtigheidsgraad", "is_illuminance": "Huidige {entity_name} verlichtingssterkte", "is_power": "Huidige {entity_name}\nvermogen", @@ -22,6 +23,7 @@ "carbon_monoxide": "{entity_name} koolmonoxideconcentratie gewijzigd", "current": "{entity_name} huidige wijzigingen", "energy": "{entity_name} energieveranderingen", + "gas": "{entity_name} gas verandert", "humidity": "{entity_name} vochtigheidsgraad gewijzigd", "illuminance": "{entity_name} verlichtingssterkte gewijzigd", "power": "{entity_name} vermogen gewijzigd", diff --git a/homeassistant/components/tractive/translations/nl.json b/homeassistant/components/tractive/translations/nl.json new file mode 100644 index 00000000000..2ae14092cde --- /dev/null +++ b/homeassistant/components/tractive/translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/nl.json b/homeassistant/components/uptimerobot/translations/nl.json index 3a77fedf228..7e0ad6a3cd0 100644 --- a/homeassistant/components/uptimerobot/translations/nl.json +++ b/homeassistant/components/uptimerobot/translations/nl.json @@ -1,17 +1,30 @@ { "config": { "abort": { - "already_configured": "Apparaat is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "reauth_failed_existing": "Kon de config entry niet updaten, gelieve de integratie te verwijderen en het opnieuw op te zetten.", + "reauth_successful": "Herauthenticatie was succesvol", + "unknown": "Onverwachte fout" }, "error": { "cannot_connect": "Kan geen verbinding maken", + "invalid_api_key": "Ongeldige API-sleutel", + "reauth_failed_matching_account": "De API sleutel die u heeft opgegeven komt niet overeen met de account ID voor de bestaande configuratie.", "unknown": "Onverwachte fout" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API-sleutel" + }, + "description": "U moet een alleen-lezen API-sleutel van Uptime Robot opgeven", + "title": "Verifieer de integratie opnieuw" + }, "user": { "data": { "api_key": "API-sleutel" - } + }, + "description": "U moet een alleen-lezen API-sleutel van Uptime Robot opgeven" } } } diff --git a/homeassistant/components/weather/translations/ru.json b/homeassistant/components/weather/translations/ru.json index 1f0458b7653..b0f92257631 100644 --- a/homeassistant/components/weather/translations/ru.json +++ b/homeassistant/components/weather/translations/ru.json @@ -1,7 +1,7 @@ { "state": { "_": { - "clear-night": "\u042f\u0441\u043d\u043e, \u043d\u043e\u0447\u044c", + "clear-night": "\u042f\u0441\u043d\u043e", "cloudy": "\u041e\u0431\u043b\u0430\u0447\u043d\u043e", "exceptional": "\u041f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435", "fog": "\u0422\u0443\u043c\u0430\u043d", From 87e7a8fb5f5a0108acf0349f195f1c43fe58adda Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 15 Aug 2021 08:51:43 +0200 Subject: [PATCH 374/903] Move temperature conversions to sensor base class - new integrations (#54623) * Move temperature conversions to sensor base class * Tweaks * Update pvpc_hourly_pricing * Fix flipr sensor * Fix ezviz and youless sensor --- homeassistant/components/acmeda/sensor.py | 2 +- .../components/advantage_air/sensor.py | 2 +- homeassistant/components/canary/sensor.py | 2 +- homeassistant/components/ezviz/sensor.py | 5 ++- homeassistant/components/flipr/sensor.py | 8 ++-- homeassistant/components/fritzbox/sensor.py | 4 +- homeassistant/components/fronius/sensor.py | 6 +-- homeassistant/components/mill/sensor.py | 6 +-- homeassistant/components/powerwall/sensor.py | 4 +- .../components/pvpc_hourly_pricing/sensor.py | 9 ++-- homeassistant/components/renault/sensor.py | 42 +++++++++---------- .../components/smartthings/sensor.py | 4 +- homeassistant/components/spider/sensor.py | 8 ++-- homeassistant/components/tplink/sensor.py | 2 +- homeassistant/components/wemo/sensor.py | 4 +- .../components/xiaomi_miio/sensor.py | 2 +- homeassistant/components/youless/sensor.py | 8 ++-- 17 files changed, 60 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index 7cded0adb30..43f5e32c74f 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -34,7 +34,7 @@ class AcmedaBattery(AcmedaBase, SensorEntity): """Representation of a Acmeda cover device.""" device_class = DEVICE_CLASS_BATTERY - unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE @property def name(self): diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 65b7b35740e..5912101fd65 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -154,6 +154,6 @@ class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the current value of the measured temperature.""" return self._zone["measuredTemp"] diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 3870cb357ef..1e7747039b8 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -120,7 +120,7 @@ class CanarySensor(CoordinatorEntity, SensorEntity): "model": device.device_type["name"], "manufacturer": MANUFACTURER, } - self._attr_unit_of_measurement = sensor_type[1] + self._attr_native_unit_of_measurement = sensor_type[1] self._attr_device_class = sensor_type[3] self._attr_icon = sensor_type[2] diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index 42283b52d35..512491a2548 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -5,9 +5,10 @@ import logging from pyezviz.constants import SensorType +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -39,7 +40,7 @@ async def async_setup_entry( async_add_entities(sensors) -class EzvizSensor(CoordinatorEntity, Entity): +class EzvizSensor(CoordinatorEntity, SensorEntity): """Representation of a Ezviz sensor.""" coordinator: EzvizDataUpdateCoordinator diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 427a668a72b..3a02b7e2f2e 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -1,13 +1,13 @@ """Sensor platform for the Flipr's pool_sensor.""" from datetime import datetime +from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( ATTR_ATTRIBUTION, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, TEMP_CELSIUS, ) -from homeassistant.helpers.entity import Entity from . import FliprEntity from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN @@ -53,7 +53,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(sensors_list, True) -class FliprSensor(FliprEntity, Entity): +class FliprSensor(FliprEntity, SensorEntity): """Sensor representing FliprSensor data.""" @property @@ -62,7 +62,7 @@ class FliprSensor(FliprEntity, Entity): return f"Flipr {self.flipr_id} {SENSORS[self.info_type]['name']}" @property - def state(self): + def native_value(self): """State of the sensor.""" state = self.coordinator.data[self.info_type] if isinstance(state, datetime): @@ -80,7 +80,7 @@ class FliprSensor(FliprEntity, Entity): return SENSORS[self.info_type]["icon"] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return unit of measurement.""" return SENSORS[self.info_type]["unit"] diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 01bea17fb3c..56e025cd605 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -136,7 +136,7 @@ class FritzBoxPowerSensor(FritzBoxSensor): """The entity class for FRITZ!SmartHome power consumption sensors.""" @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" if power := self.device.power: return power / 1000 # type: ignore [no-any-return] @@ -147,7 +147,7 @@ class FritzBoxEnergySensor(FritzBoxSensor): """The entity class for FRITZ!SmartHome total energy sensors.""" @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" if energy := self.device.energy: return energy / 1000 # type: ignore [no-any-return] diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 68430684d85..5141c79f31b 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -306,9 +306,9 @@ class FroniusTemplateSensor(SensorEntity): async def async_update(self): """Update the internal state.""" state = self._parent.data.get(self._key) - self._attr_state = state.get("value") - if isinstance(self._attr_state, float): - self._attr_native_value = round(self._attr_state, 2) + self._attr_native_value = state.get("value") + if isinstance(self._attr_native_value, float): + self._attr_native_value = round(self._attr_native_value, 2) self._attr_native_unit_of_measurement = state.get("unit") @property diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index bdd1a90fb38..a8b4554139f 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -36,7 +36,7 @@ class MillHeaterEnergySensor(SensorEntity): self._attr_device_class = DEVICE_CLASS_ENERGY self._attr_name = f"{heater.name} {sensor_type.replace('_', ' ')}" self._attr_unique_id = f"{heater.device_id}_{sensor_type}" - self._attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR self._attr_state_class = STATE_CLASS_MEASUREMENT self._attr_device_info = { "identifiers": {(DOMAIN, heater.device_id)}, @@ -67,7 +67,7 @@ class MillHeaterEnergySensor(SensorEntity): else: _state = None if _state is None: - self._attr_state = _state + self._attr_native_value = _state return if self.state is not None and _state < self.state: @@ -81,4 +81,4 @@ class MillHeaterEnergySensor(SensorEntity): month=1, day=1, hour=0, minute=0, second=0, microsecond=0 ) ) - self._attr_state = _state + self._attr_native_value = _state diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 96542dc3929..0ffa333181d 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -152,7 +152,7 @@ class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Direction Energy sensor.""" _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_device_class = DEVICE_CLASS_ENERGY _attr_last_reset = dt_util.utc_from_timestamp(0) @@ -180,7 +180,7 @@ class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Get the current value in kWh.""" meter = self.coordinator.data[POWERWALL_API_METERS].get_meter(self._meter) if self._meter_direction == _METER_DIRECTION_EXPORT: diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 157327fbd19..9cc5603e35b 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -7,7 +7,7 @@ from typing import Any from aiopvpc import PVPCData -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CURRENCY_EURO, ENERGY_KILO_WATT_HOUR from homeassistant.core import HomeAssistant, callback @@ -51,9 +51,10 @@ async def async_setup_entry( class ElecPriceSensor(RestoreEntity, SensorEntity): """Class to hold the prices of electricity as a sensor.""" - unit_of_measurement = UNIT - icon = ICON - should_poll = False + _attr_icon = ICON + _attr_native_unit_of_measurement = UNIT + _attr_should_poll = False + _attr_state_class = STATE_CLASS_MEASUREMENT def __init__(self, name, unique_id, pvpc_data_handler): """Initialize the sensor object.""" diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 8403a04d001..51f38d6a4d6 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -88,10 +88,10 @@ class RenaultBatteryAutonomySensor(RenaultBatteryDataEntity, SensorEntity): """Battery autonomy sensor.""" _attr_icon = "mdi:ev-station" - _attr_unit_of_measurement = LENGTH_KILOMETERS + _attr_native_unit_of_measurement = LENGTH_KILOMETERS @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" return self.data.batteryAutonomy if self.data else None @@ -100,10 +100,10 @@ class RenaultBatteryLevelSensor(RenaultBatteryDataEntity, SensorEntity): """Battery Level sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" return self.data.batteryLevel if self.data else None @@ -128,10 +128,10 @@ class RenaultBatteryTemperatureSensor(RenaultBatteryDataEntity, SensorEntity): """Battery Temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" return self.data.batteryTemperature if self.data else None @@ -142,7 +142,7 @@ class RenaultChargeModeSensor(RenaultChargeModeDataEntity, SensorEntity): _attr_device_class = DEVICE_CLASS_CHARGE_MODE @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of this entity.""" return self.data.chargeMode if self.data else None @@ -160,7 +160,7 @@ class RenaultChargeStateSensor(RenaultBatteryDataEntity, SensorEntity): _attr_device_class = DEVICE_CLASS_CHARGE_STATE @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of this entity.""" charging_status = self.data.get_charging_status() if self.data else None return slugify(charging_status.name) if charging_status is not None else None @@ -175,10 +175,10 @@ class RenaultChargingRemainingTimeSensor(RenaultBatteryDataEntity, SensorEntity) """Charging Remaining Time sensor.""" _attr_icon = "mdi:timer" - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" return self.data.chargingRemainingTime if self.data else None @@ -187,10 +187,10 @@ class RenaultChargingPowerSensor(RenaultBatteryDataEntity, SensorEntity): """Charging Power sensor.""" _attr_device_class = DEVICE_CLASS_ENERGY - _attr_unit_of_measurement = POWER_KILO_WATT + _attr_native_unit_of_measurement = POWER_KILO_WATT @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of this entity.""" if not self.data or self.data.chargingInstantaneousPower is None: return None @@ -204,10 +204,10 @@ class RenaultFuelAutonomySensor(RenaultCockpitDataEntity, SensorEntity): """Fuel autonomy sensor.""" _attr_icon = "mdi:gas-station" - _attr_unit_of_measurement = LENGTH_KILOMETERS + _attr_native_unit_of_measurement = LENGTH_KILOMETERS @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" return ( round(self.data.fuelAutonomy) @@ -220,10 +220,10 @@ class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): """Fuel quantity sensor.""" _attr_icon = "mdi:fuel" - _attr_unit_of_measurement = VOLUME_LITERS + _attr_native_unit_of_measurement = VOLUME_LITERS @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" return ( round(self.data.fuelQuantity) @@ -236,10 +236,10 @@ class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): """Mileage sensor.""" _attr_icon = "mdi:sign-direction" - _attr_unit_of_measurement = LENGTH_KILOMETERS + _attr_native_unit_of_measurement = LENGTH_KILOMETERS @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" return ( round(self.data.totalMileage) @@ -252,10 +252,10 @@ class RenaultOutsideTemperatureSensor(RenaultHVACDataEntity, SensorEntity): """HVAC Outside Temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of this entity.""" return self.data.externalTemperature if self.data else None @@ -266,7 +266,7 @@ class RenaultPlugStateSensor(RenaultBatteryDataEntity, SensorEntity): _attr_device_class = DEVICE_CLASS_PLUG_STATE @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of this entity.""" plug_status = self.data.get_plug_status() if self.data else None return slugify(plug_status.name) if plug_status is not None else None diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c4d84ec5f69..a8e6c0472e9 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -568,7 +568,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): return f"{self._device.device_id}.{self.report_name}" @property - def state(self): + 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: @@ -585,7 +585,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): return DEVICE_CLASS_ENERGY @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" if self.report_name == "power": return POWER_WATT diff --git a/homeassistant/components/spider/sensor.py b/homeassistant/components/spider/sensor.py index 998a9ff8eee..bf1ab0b18be 100644 --- a/homeassistant/components/spider/sensor.py +++ b/homeassistant/components/spider/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry(hass, config, async_add_entities): class SpiderPowerPlugEnergy(SensorEntity): """Representation of a Spider Power Plug (energy).""" - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_device_class = DEVICE_CLASS_ENERGY _attr_state_class = STATE_CLASS_MEASUREMENT @@ -59,7 +59,7 @@ class SpiderPowerPlugEnergy(SensorEntity): return f"{self.power_plug.name} Total Energy Today" @property - def state(self) -> float: + def native_value(self) -> float: """Return todays energy usage in Kwh.""" return round(self.power_plug.today_energy_consumption / 1000, 2) @@ -80,7 +80,7 @@ class SpiderPowerPlugPower(SensorEntity): _attr_device_class = DEVICE_CLASS_POWER _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT def __init__(self, api, power_plug) -> None: """Initialize the Spider Power Plug.""" @@ -108,7 +108,7 @@ class SpiderPowerPlugPower(SensorEntity): return f"{self.power_plug.name} Power Consumption" @property - def state(self) -> float: + def native_value(self) -> float: """Return the current power usage in W.""" return round(self.power_plug.current_energy_consumption) diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 697641915f7..fae7939cd65 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -137,7 +137,7 @@ class SmartPlugSensor(CoordinatorEntity, SensorEntity): return self.coordinator.data @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the sensors state.""" return self.data[CONF_EMETER_PARAMS][self.entity_description.key] diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index ebd68231e0c..b9d22e6995a 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -98,7 +98,7 @@ class InsightCurrentPower(InsightSensor): ) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the current power consumption.""" return ( convert(self.wemo.insight_params[self.entity_description.key], float, 0.0) @@ -123,7 +123,7 @@ class InsightTodayEnergy(InsightSensor): return dt.start_of_local_day() @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the current energy use today.""" miliwatts = convert( self.wemo.insight_params[self.entity_description.key], float, 0.0 diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index bbf83825dca..aff0b5212f1 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -280,7 +280,7 @@ class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): self.entity_description = description @property - def state(self): + def native_value(self): """Return the state of the device.""" self._state = self._extract_value_from_attribute( self.coordinator.data, self.entity_description.key diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 54155034919..bc0f1ee873b 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -3,11 +3,11 @@ from __future__ import annotations from youless_api.youless_sensor import YoulessSensor +from homeassistant.components.sensor import SensorEntity from homeassistant.components.youless import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, DEVICE_CLASS_POWER from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( @@ -40,7 +40,7 @@ async def async_setup_entry( ) -class YoulessBaseSensor(CoordinatorEntity, Entity): +class YoulessBaseSensor(CoordinatorEntity, SensorEntity): """The base sensor for Youless.""" def __init__( @@ -71,7 +71,7 @@ class YoulessBaseSensor(CoordinatorEntity, Entity): return None @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement for the sensor.""" if self.get_sensor is None: return None @@ -79,7 +79,7 @@ class YoulessBaseSensor(CoordinatorEntity, Entity): return self.get_sensor.unit_of_measurement @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Determine the state value, only if a sensor is initialized.""" if self.get_sensor is None: return None From 675441142d1fe51b0216365810b298c15704f9ca Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Sun, 15 Aug 2021 12:50:40 +0200 Subject: [PATCH 375/903] Update pyhomematic to 0.1.74 (#54613) --- homeassistant/components/homematic/const.py | 2 ++ homeassistant/components/homematic/manifest.json | 2 +- homeassistant/components/homematic/sensor.py | 4 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py index 4f1c1d12f81..0880d168375 100644 --- a/homeassistant/components/homematic/const.py +++ b/homeassistant/components/homematic/const.py @@ -62,6 +62,7 @@ HM_DEVICE_TYPES = { "IPWIODevice", "IPSwitchBattery", "IPMultiIOPCB", + "IPGarageSwitch", ], DISCOVER_LIGHTS: [ "Dimmer", @@ -125,6 +126,7 @@ HM_DEVICE_TYPES = { "TempModuleSTE2", "IPMultiIOPCB", "ValveBoxW", + "CO2SensorIP", ], DISCOVER_CLIMATE: [ "Thermostat", diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index 8b1ee62a09e..f500ef54b56 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -2,7 +2,7 @@ "domain": "homematic", "name": "Homematic", "documentation": "https://www.home-assistant.io/integrations/homematic", - "requirements": ["pyhomematic==0.1.73"], + "requirements": ["pyhomematic==0.1.74"], "codeowners": ["@pvizeli", "@danielperna84"], "iot_class": "local_push" } diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 7cfe0ffc944..18690ac3553 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -3,7 +3,9 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, DEGREE, + DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, @@ -72,6 +74,7 @@ HM_UNIT_HA_CAST = { "VALVE_STATE": PERCENTAGE, "CARRIER_SENSE_LEVEL": PERCENTAGE, "DUTY_CYCLE_LEVEL": PERCENTAGE, + "CONCENTRATION": CONCENTRATION_PARTS_PER_MILLION, } HM_DEVICE_CLASS_HA_CAST = { @@ -85,6 +88,7 @@ HM_DEVICE_CLASS_HA_CAST = { "HIGHEST_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, "POWER": DEVICE_CLASS_POWER, "CURRENT": DEVICE_CLASS_POWER, + "CONCENTRATION": DEVICE_CLASS_CO2, } HM_ICON_HA_CAST = {"WIND_SPEED": "mdi:weather-windy", "BRIGHTNESS": "mdi:invert-colors"} diff --git a/requirements_all.txt b/requirements_all.txt index dce813853da..c6b8d084c8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1487,7 +1487,7 @@ pyhik==0.2.8 pyhiveapi==0.4.2 # homeassistant.components.homematic -pyhomematic==0.1.73 +pyhomematic==0.1.74 # homeassistant.components.homeworks pyhomeworks==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44ab676e9f4..cf73f4538cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -839,7 +839,7 @@ pyheos==0.7.2 pyhiveapi==0.4.2 # homeassistant.components.homematic -pyhomematic==0.1.73 +pyhomematic==0.1.74 # homeassistant.components.ialarm pyialarm==1.9.0 From 5ed9cd7153ebb8bd96dd26a8d40fed0a8e992b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sun, 15 Aug 2021 13:16:10 +0200 Subject: [PATCH 376/903] Adax, update requirements (#54587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/adax/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index 36106290ed6..3d2c9273d05 100644 --- a/homeassistant/components/adax/manifest.json +++ b/homeassistant/components/adax/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/adax", "requirements": [ - "adax==0.0.1" + "adax==0.1.1" ], "codeowners": [ "@danielhiversen" diff --git a/requirements_all.txt b/requirements_all.txt index c6b8d084c8e..32f92439be8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -106,7 +106,7 @@ adafruit-circuitpython-dht==3.6.0 adafruit-circuitpython-mcp230xx==2.2.2 # homeassistant.components.adax -adax==0.0.1 +adax==0.1.1 # homeassistant.components.androidtv adb-shell[async]==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf73f4538cd..315ee031f03 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -48,7 +48,7 @@ abodepy==1.2.0 accuweather==0.2.0 # homeassistant.components.adax -adax==0.0.1 +adax==0.1.1 # homeassistant.components.androidtv adb-shell[async]==0.3.4 From ebaae8d2bf9982ab78eb92cb9699338587214738 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 15 Aug 2021 13:49:29 +0200 Subject: [PATCH 377/903] Add sensor platform for Xiaomi Miio fans (#54564) --- .../components/xiaomi_miio/__init__.py | 9 +- homeassistant/components/xiaomi_miio/const.py | 4 +- homeassistant/components/xiaomi_miio/fan.py | 70 ------- .../components/xiaomi_miio/sensor.py | 193 +++++++++++++++--- 4 files changed, 174 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index faff2194948..122c42c6589 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -31,7 +31,6 @@ from .const import ( KEY_DEVICE, MODELS_AIR_MONITOR, MODELS_FAN, - MODELS_FAN_MIIO, MODELS_HUMIDIFIER, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, @@ -47,7 +46,7 @@ _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] SWITCH_PLATFORMS = ["switch"] -FAN_PLATFORMS = ["fan"] +FAN_PLATFORMS = ["fan", "sensor"] HUMIDIFIER_PLATFORMS = [ "binary_sensor", "humidifier", @@ -120,11 +119,7 @@ async def async_create_miio_device_and_coordinator( device = None migrate = False - if ( - model not in MODELS_HUMIDIFIER - and model not in MODELS_PURIFIER_MIOT - and model not in MODELS_FAN_MIIO - ): + if model not in MODELS_HUMIDIFIER and model not in MODELS_FAN: return _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index c407e92a6ae..de1c0bcf007 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -63,7 +63,7 @@ MODELS_PURIFIER_MIOT = [ MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_PROH, ] -MODELS_FAN_MIIO = [ +MODELS_PURIFIER_MIIO = [ MODEL_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V2, MODEL_AIRPURIFIER_V3, @@ -124,7 +124,7 @@ MODELS_SWITCH = [ "chuangmi.plug.hmi205", "chuangmi.plug.hmi206", ] -MODELS_FAN = MODELS_FAN_MIIO + MODELS_PURIFIER_MIOT +MODELS_FAN = MODELS_PURIFIER_MIIO + MODELS_PURIFIER_MIOT MODELS_HUMIDIFIER = ( MODELS_HUMIDIFIER_MIOT + MODELS_HUMIDIFIER_MIIO + MODELS_HUMIDIFIER_MJJSQ ) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index fe4df2cd6d3..5b3418c83f5 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -28,7 +28,6 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, - ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, CONF_TOKEN, @@ -103,26 +102,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ATTR_MODEL = "model" # Air Purifier -ATTR_HUMIDITY = "humidity" -ATTR_AIR_QUALITY_INDEX = "aqi" -ATTR_FILTER_HOURS_USED = "filter_hours_used" ATTR_FILTER_LIFE = "filter_life_remaining" ATTR_FAVORITE_LEVEL = "favorite_level" ATTR_BUZZER = "buzzer" ATTR_CHILD_LOCK = "child_lock" ATTR_LED = "led" ATTR_LED_BRIGHTNESS = "led_brightness" -ATTR_MOTOR_SPEED = "motor_speed" -ATTR_AVERAGE_AIR_QUALITY_INDEX = "average_aqi" -ATTR_PURIFY_VOLUME = "purify_volume" ATTR_BRIGHTNESS = "brightness" ATTR_LEVEL = "level" ATTR_FAN_LEVEL = "fan_level" -ATTR_MOTOR2_SPEED = "motor2_speed" -ATTR_ILLUMINANCE = "illuminance" -ATTR_FILTER_RFID_PRODUCT_ID = "filter_rfid_product_id" -ATTR_FILTER_RFID_TAG = "filter_rfid_tag" -ATTR_FILTER_TYPE = "filter_type" ATTR_LEARN_MODE = "learn_mode" ATTR_SLEEP_TIME = "sleep_time" ATTR_SLEEP_LEARN_COUNT = "sleep_mode_learn_count" @@ -135,22 +123,12 @@ ATTR_VOLUME = "volume" ATTR_USE_TIME = "use_time" ATTR_BUTTON_PRESSED = "button_pressed" -# Air Fresh -ATTR_CO2 = "co2" - # Map attributes to properties of the state object AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { - ATTR_TEMPERATURE: "temperature", - ATTR_HUMIDITY: "humidity", - ATTR_AIR_QUALITY_INDEX: "aqi", ATTR_MODE: "mode", - ATTR_FILTER_HOURS_USED: "filter_hours_used", - ATTR_FILTER_LIFE: "filter_life_remaining", ATTR_FAVORITE_LEVEL: "favorite_level", ATTR_CHILD_LOCK: "child_lock", ATTR_LED: "led", - ATTR_MOTOR_SPEED: "motor_speed", - ATTR_AVERAGE_AIR_QUALITY_INDEX: "average_aqi", ATTR_LEARN_MODE: "learn_mode", ATTR_EXTRA_FEATURES: "extra_features", ATTR_TURBO_MODE_SUPPORTED: "turbo_mode_supported", @@ -159,7 +137,6 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { AVAILABLE_ATTRIBUTES_AIRPURIFIER = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, - ATTR_PURIFY_VOLUME: "purify_volume", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", ATTR_AUTO_DETECT: "auto_detect", @@ -171,15 +148,8 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER = { AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, - ATTR_PURIFY_VOLUME: "purify_volume", ATTR_USE_TIME: "use_time", - ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id", - ATTR_FILTER_RFID_TAG: "filter_rfid_tag", - ATTR_FILTER_TYPE: "filter_type", - ATTR_ILLUMINANCE: "illuminance", - ATTR_MOTOR2_SPEED: "motor2_speed", ATTR_VOLUME: "volume", - # perhaps supported but unconfirmed ATTR_AUTO_DETECT: "auto_detect", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", @@ -187,64 +157,32 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO = { AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, - ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id", - ATTR_FILTER_RFID_TAG: "filter_rfid_tag", - ATTR_FILTER_TYPE: "filter_type", - ATTR_ILLUMINANCE: "illuminance", - ATTR_MOTOR2_SPEED: "motor2_speed", ATTR_VOLUME: "volume", } AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, ATTR_BUZZER: "buzzer", - ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id", - ATTR_FILTER_RFID_TAG: "filter_rfid_tag", - ATTR_FILTER_TYPE: "filter_type", - ATTR_ILLUMINANCE: "illuminance", } AVAILABLE_ATTRIBUTES_AIRPURIFIER_3 = { - ATTR_TEMPERATURE: "temperature", - ATTR_HUMIDITY: "humidity", - ATTR_AIR_QUALITY_INDEX: "aqi", ATTR_MODE: "mode", - ATTR_FILTER_HOURS_USED: "filter_hours_used", - ATTR_FILTER_LIFE: "filter_life_remaining", ATTR_FAVORITE_LEVEL: "favorite_level", ATTR_CHILD_LOCK: "child_lock", ATTR_LED: "led", - ATTR_MOTOR_SPEED: "motor_speed", - ATTR_AVERAGE_AIR_QUALITY_INDEX: "average_aqi", - ATTR_PURIFY_VOLUME: "purify_volume", ATTR_USE_TIME: "use_time", ATTR_BUZZER: "buzzer", ATTR_LED_BRIGHTNESS: "led_brightness", - ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id", - ATTR_FILTER_RFID_TAG: "filter_rfid_tag", - ATTR_FILTER_TYPE: "filter_type", ATTR_FAN_LEVEL: "fan_level", } AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { # Common set isn't used here. It's a very basic version of the device. - ATTR_AIR_QUALITY_INDEX: "aqi", ATTR_MODE: "mode", ATTR_LED: "led", ATTR_BUZZER: "buzzer", ATTR_CHILD_LOCK: "child_lock", - ATTR_ILLUMINANCE: "illuminance", - ATTR_FILTER_HOURS_USED: "filter_hours_used", - ATTR_FILTER_LIFE: "filter_life_remaining", - ATTR_MOTOR_SPEED: "motor_speed", - # perhaps supported but unconfirmed - ATTR_AVERAGE_AIR_QUALITY_INDEX: "average_aqi", ATTR_VOLUME: "volume", - ATTR_MOTOR2_SPEED: "motor2_speed", - ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id", - ATTR_FILTER_RFID_TAG: "filter_rfid_tag", - ATTR_FILTER_TYPE: "filter_type", - ATTR_PURIFY_VOLUME: "purify_volume", ATTR_LEARN_MODE: "learn_mode", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", @@ -255,20 +193,12 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { } AVAILABLE_ATTRIBUTES_AIRFRESH = { - ATTR_TEMPERATURE: "temperature", - ATTR_AIR_QUALITY_INDEX: "aqi", - ATTR_AVERAGE_AIR_QUALITY_INDEX: "average_aqi", - ATTR_CO2: "co2", - ATTR_HUMIDITY: "humidity", ATTR_MODE: "mode", ATTR_LED: "led", ATTR_LED_BRIGHTNESS: "led_brightness", ATTR_BUZZER: "buzzer", ATTR_CHILD_LOCK: "child_lock", - ATTR_FILTER_LIFE: "filter_life_remaining", - ATTR_FILTER_HOURS_USED: "filter_hours_used", ATTR_USE_TIME: "use_time", - ATTR_MOTOR_SPEED: "motor_speed", ATTR_EXTRA_FEATURES: "extra_features", } diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index aff0b5212f1..26914e1dff8 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -19,6 +19,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -26,18 +27,25 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, CONF_HOST, CONF_NAME, CONF_TOKEN, + DEVICE_CLASS_CO2, + DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, + LIGHT_LUX, PERCENTAGE, POWER_WATT, PRESSURE_HPA, TEMP_CELSIUS, + TIME_HOURS, + VOLUME_CUBIC_METERS, ) import homeassistant.helpers.config_validation as cv @@ -49,11 +57,18 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRFRESH_VA2, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1, + MODEL_AIRPURIFIER_PRO, + MODEL_AIRPURIFIER_PRO_V7, + MODEL_AIRPURIFIER_V2, + MODEL_AIRPURIFIER_V3, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, + MODELS_PURIFIER_MIIO, + MODELS_PURIFIER_MIOT, ) from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity from .gateway import XiaomiGatewayDevice @@ -73,17 +88,26 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ATTR_ACTUAL_SPEED = "actual_speed" ATTR_AIR_QUALITY = "air_quality" +ATTR_AQI = "aqi" +ATTR_CARBON_DIOXIDE = "co2" ATTR_CHARGING = "charging" ATTR_DISPLAY_CLOCK = "display_clock" +ATTR_FILTER_LIFE_REMAINING = "filter_life_remaining" +ATTR_FILTER_HOURS_USED = "filter_hours_used" +ATTR_FILTER_USE = "filter_use" ATTR_HUMIDITY = "humidity" ATTR_ILLUMINANCE = "illuminance" +ATTR_ILLUMINANCE_LUX = "illuminance_lux" ATTR_LOAD_POWER = "load_power" +ATTR_MOTOR2_SPEED = "motor2_speed" ATTR_MOTOR_SPEED = "motor_speed" ATTR_NIGHT_MODE = "night_mode" ATTR_NIGHT_TIME_BEGIN = "night_time_begin" ATTR_NIGHT_TIME_END = "night_time_end" +ATTR_PM25 = "pm25" ATTR_POWER = "power" ATTR_PRESSURE = "pressure" +ATTR_PURIFY_VOLUME = "purify_volume" ATTR_SENSOR_STATE = "sensor_state" ATTR_WATER_LEVEL = "water_level" @@ -92,8 +116,7 @@ ATTR_WATER_LEVEL = "water_level" class XiaomiMiioSensorDescription(SensorEntityDescription): """Class that holds device specific info for a xiaomi aqara or humidifier sensor.""" - valid_min_value: float | None = None - valid_max_value: float | None = None + attributes: tuple = () SENSOR_TYPES = { @@ -130,8 +153,6 @@ SENSOR_TYPES = { native_unit_of_measurement=PERCENTAGE, icon="mdi:water-check", state_class=STATE_CLASS_MEASUREMENT, - valid_min_value=0.0, - valid_max_value=100.0, ), ATTR_ACTUAL_SPEED: XiaomiMiioSensorDescription( key=ATTR_ACTUAL_SPEED, @@ -147,6 +168,13 @@ SENSOR_TYPES = { icon="mdi:fast-forward", state_class=STATE_CLASS_MEASUREMENT, ), + ATTR_MOTOR2_SPEED: XiaomiMiioSensorDescription( + key=ATTR_MOTOR2_SPEED, + name="Second Motor Speed", + native_unit_of_measurement="rpm", + icon="mdi:fast-forward", + state_class=STATE_CLASS_MEASUREMENT, + ), ATTR_ILLUMINANCE: XiaomiMiioSensorDescription( key=ATTR_ILLUMINANCE, name="Illuminance", @@ -154,24 +182,144 @@ SENSOR_TYPES = { device_class=DEVICE_CLASS_ILLUMINANCE, state_class=STATE_CLASS_MEASUREMENT, ), + ATTR_ILLUMINANCE_LUX: XiaomiMiioSensorDescription( + key=ATTR_ILLUMINANCE, + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), ATTR_AIR_QUALITY: XiaomiMiioSensorDescription( key=ATTR_AIR_QUALITY, native_unit_of_measurement="AQI", icon="mdi:cloud", state_class=STATE_CLASS_MEASUREMENT, ), + ATTR_PM25: XiaomiMiioSensorDescription( + key=ATTR_AQI, + name="PM2.5", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:blur", + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( + key=ATTR_FILTER_LIFE_REMAINING, + name="Filter Life Remaining", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:air-filter", + state_class=STATE_CLASS_MEASUREMENT, + attributes=("filter_type",), + ), + ATTR_FILTER_USE: XiaomiMiioSensorDescription( + key=ATTR_FILTER_HOURS_USED, + name="Filter Use", + native_unit_of_measurement=TIME_HOURS, + icon="mdi:clock-outline", + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_CARBON_DIOXIDE: XiaomiMiioSensorDescription( + key=ATTR_CARBON_DIOXIDE, + name="Carbon Dioxide", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_PURIFY_VOLUME: XiaomiMiioSensorDescription( + key=ATTR_PURIFY_VOLUME, + name="Purify Volume", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), } HUMIDIFIER_MIIO_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE) HUMIDIFIER_CA1_CB1_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE, ATTR_MOTOR_SPEED) HUMIDIFIER_MIOT_SENSORS = ( + ATTR_ACTUAL_SPEED, ATTR_HUMIDITY, ATTR_TEMPERATURE, ATTR_WATER_LEVEL, - ATTR_ACTUAL_SPEED, ) HUMIDIFIER_MJJSQ_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE) +PURIFIER_MIIO_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_TEMPERATURE, +) +PURIFIER_MIOT_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_PURIFY_VOLUME, + ATTR_TEMPERATURE, +) +PURIFIER_V2_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_PURIFY_VOLUME, + ATTR_TEMPERATURE, +) +PURIFIER_V3_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_ILLUMINANCE_LUX, + ATTR_MOTOR2_SPEED, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_PURIFY_VOLUME, +) +PURIFIER_PRO_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_ILLUMINANCE_LUX, + ATTR_MOTOR2_SPEED, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_PURIFY_VOLUME, + ATTR_TEMPERATURE, +) +PURIFIER_PRO_V7_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_ILLUMINANCE_LUX, + ATTR_MOTOR2_SPEED, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_TEMPERATURE, +) +AIRFRESH_SENSORS = ( + ATTR_CARBON_DIOXIDE, + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_ILLUMINANCE_LUX, + ATTR_PM25, + ATTR_TEMPERATURE, +) + +MODEL_TO_SENSORS_MAP = { + MODEL_AIRHUMIDIFIER_CA1: HUMIDIFIER_CA1_CB1_SENSORS, + MODEL_AIRHUMIDIFIER_CB1: HUMIDIFIER_CA1_CB1_SENSORS, + MODEL_AIRPURIFIER_V2: PURIFIER_V2_SENSORS, + MODEL_AIRPURIFIER_V3: PURIFIER_V3_SENSORS, + MODEL_AIRPURIFIER_PRO_V7: PURIFIER_PRO_V7_SENSORS, + MODEL_AIRPURIFIER_PRO: PURIFIER_PRO_SENSORS, + MODEL_AIRFRESH_VA2: AIRFRESH_SENSORS, +} + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Import Miio configuration from YAML.""" @@ -224,20 +372,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): host = config_entry.data[CONF_HOST] token = config_entry.data[CONF_TOKEN] model = config_entry.data[CONF_MODEL] - device = None + device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE) sensors = [] - if model in (MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1): - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - sensors = HUMIDIFIER_CA1_CB1_SENSORS + if model in MODEL_TO_SENSORS_MAP: + sensors = MODEL_TO_SENSORS_MAP[model] elif model in MODELS_HUMIDIFIER_MIOT: - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] sensors = HUMIDIFIER_MIOT_SENSORS elif model in MODELS_HUMIDIFIER_MJJSQ: - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] sensors = HUMIDIFIER_MJJSQ_SENSORS elif model in MODELS_HUMIDIFIER_MIIO: - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] sensors = HUMIDIFIER_MIIO_SENSORS + elif model in MODELS_PURIFIER_MIIO: + sensors = PURIFIER_MIIO_SENSORS + elif model in MODELS_PURIFIER_MIOT: + sensors = PURIFIER_MIOT_SENSORS else: unique_id = config_entry.unique_id name = config_entry.title @@ -276,24 +424,23 @@ class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): self._attr_name = name self._attr_unique_id = unique_id - self._state = None self.entity_description = description @property def native_value(self): """Return the state of the device.""" - self._state = self._extract_value_from_attribute( + return self._extract_value_from_attribute( self.coordinator.data, self.entity_description.key ) - if ( - self.entity_description.valid_min_value - and self._state < self.entity_description.valid_min_value - ) or ( - self.entity_description.valid_max_value - and self._state > self.entity_description.valid_max_value - ): - return None - return self._state + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return { + attr: self._extract_value_from_attribute(self.coordinator.data, attr) + for attr in self.entity_description.attributes + if hasattr(self.coordinator.data, attr) + } @staticmethod def _extract_value_from_attribute(state, attribute): From 54da4245079bae53301726a3412d3de9ac38a5a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Aug 2021 08:04:47 -0500 Subject: [PATCH 378/903] Add new OUIs to august for yale branded connect bridges (#54637) --- homeassistant/components/august/manifest.json | 4 ++++ homeassistant/generated/dhcp.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 74caa4b4a78..fc365102926 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -13,6 +13,10 @@ "hostname": "connect", "macaddress": "B8B7F1*" }, + { + "hostname": "connect", + "macaddress": "2C9FFB*" + }, { "hostname": "august*", "macaddress": "E076D0*" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index dbdaaf6da5e..d6b4fc4e457 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -16,6 +16,11 @@ DHCP = [ "hostname": "connect", "macaddress": "B8B7F1*" }, + { + "domain": "august", + "hostname": "connect", + "macaddress": "2C9FFB*" + }, { "domain": "august", "hostname": "august*", From ee7116d0e85ab24548607d6b970d9915f3e3ae0b Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 15 Aug 2021 06:09:06 -0700 Subject: [PATCH 379/903] Treat temporary errors as warnings for Tesla (#54515) * Treat temporary errors as warnings for Tesla closes #53391 * Apply suggestions from code review Co-authored-by: J. Nick Koston * Black Co-authored-by: J. Nick Koston --- homeassistant/components/tesla/__init__.py | 9 +++++++++ homeassistant/components/tesla/config_flow.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index d945d87243e..798e769dc47 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -177,6 +177,15 @@ async def async_setup_entry(hass, config_entry): await async_client.aclose() if ex.code == HTTP_UNAUTHORIZED: raise ConfigEntryAuthFailed from ex + if ex.message in [ + "VEHICLE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "SERVICE_MAINTENANCE", + "UPSTREAM_TIMEOUT", + ]: + raise ConfigEntryNotReady( + f"Temporarily unable to communicate with Tesla API: {ex.message}" + ) from ex _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) return False diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py index 46bc49b126b..5a88999a7e3 100644 --- a/homeassistant/components/tesla/config_flow.py +++ b/homeassistant/components/tesla/config_flow.py @@ -175,7 +175,7 @@ async def validate_input(hass: core.HomeAssistant, data): if ex.code == HTTP_UNAUTHORIZED: _LOGGER.error("Invalid credentials: %s", ex) raise InvalidAuth() from ex - _LOGGER.error("Unable to communicate with Tesla API: %s", ex) + _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) raise CannotConnect() from ex finally: await async_client.aclose() From aa590415d34891898c5c49b20a7b808ceff93b82 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 15 Aug 2021 21:33:48 +0200 Subject: [PATCH 380/903] Bump py-synologydsm-api to 1.0.4 (#54610) --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 04d7f43bb75..8d8d30c2cf8 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -2,7 +2,7 @@ "domain": "synology_dsm", "name": "Synology DSM", "documentation": "https://www.home-assistant.io/integrations/synology_dsm", - "requirements": ["py-synologydsm-api==1.0.3"], + "requirements": ["py-synologydsm-api==1.0.4"], "codeowners": ["@hacf-fr", "@Quentame", "@mib1185"], "config_flow": true, "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 32f92439be8..ec0335cc819 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1257,7 +1257,7 @@ py-nightscout==1.2.2 py-schluter==0.1.7 # homeassistant.components.synology_dsm -py-synologydsm-api==1.0.3 +py-synologydsm-api==1.0.4 # homeassistant.components.zabbix py-zabbix==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 315ee031f03..e290fa4d4ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -705,7 +705,7 @@ py-melissa-climate==2.1.4 py-nightscout==1.2.2 # homeassistant.components.synology_dsm -py-synologydsm-api==1.0.3 +py-synologydsm-api==1.0.4 # homeassistant.components.seventeentrack py17track==3.2.1 From d0cebe911c1b13143ee041aec179902c50d82675 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 15 Aug 2021 16:06:05 -0400 Subject: [PATCH 381/903] Add siren, number, and weather to base platform list (#54665) --- homeassistant/setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 07bbaa22954..9575a4331b8 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -39,14 +39,17 @@ BASE_PLATFORMS = { "lock", "media_player", "notify", + "number", "remote", "scene", "select", "sensor", + "siren", "switch", "tts", "vacuum", "water_heater", + "weather", } DATA_SETUP_DONE = "setup_done" From 61412db119de805dc267a797c1f36b2c6ac17089 Mon Sep 17 00:00:00 2001 From: Nikolaos Stamatopoulos Date: Sun, 15 Aug 2021 22:56:30 +0200 Subject: [PATCH 382/903] Fix typo in xiaomi_miio cloud_login_error string (#54661) * fix(xiaomi_miio): Fix typo in cloud_login_error string * fixup! fix(xiaomi_miio): Fix typo in cloud_login_error string Restore translation files --- homeassistant/components/xiaomi_miio/gateway.py | 2 +- homeassistant/components/xiaomi_miio/strings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/gateway.py b/homeassistant/components/xiaomi_miio/gateway.py index 17f42f4bffa..8b7a5c77a17 100644 --- a/homeassistant/components/xiaomi_miio/gateway.py +++ b/homeassistant/components/xiaomi_miio/gateway.py @@ -117,7 +117,7 @@ class ConnectXiaomiGateway: miio_cloud = MiCloud(self._cloud_username, self._cloud_password) if not miio_cloud.login(): raise ConfigEntryAuthFailed( - "Could not login to Xioami Miio Cloud, check the credentials" + "Could not login to Xiaomi Miio Cloud, check the credentials" ) devices_raw = miio_cloud.get_devices(self._cloud_country) self._gateway_device.get_devices_from_dict(devices_raw) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 69a1621c973..129f6f1ecbf 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -12,7 +12,7 @@ "unknown_device": "The device model is not known, not able to setup the device using config flow.", "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", - "cloud_login_error": "Could not login to Xioami Miio Cloud, check the credentials." + "cloud_login_error": "Could not login to Xiaomi Miio Cloud, check the credentials." }, "flow_title": "{name}", "step": { From d01addbd249a7b1d494ecb66487967aab826e9d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Aug 2021 18:27:10 -0500 Subject: [PATCH 383/903] Bump zeroconf to 0.35.1 (#54666) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b971ec06179..05576accb78 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.35.0"], + "requirements": ["zeroconf==0.35.1"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3f7eeb65ab5..9409432e13f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.17 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.35.0 +zeroconf==0.35.1 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index ec0335cc819..3483f26245b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2439,7 +2439,7 @@ zeep[async]==4.0.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.35.0 +zeroconf==0.35.1 # homeassistant.components.zha zha-quirks==0.0.59 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e290fa4d4ee..bc0d8482421 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1347,7 +1347,7 @@ youless-api==0.10 zeep[async]==4.0.0 # homeassistant.components.zeroconf -zeroconf==0.35.0 +zeroconf==0.35.1 # homeassistant.components.zha zha-quirks==0.0.59 From 9e2945680efc4421a01942053920caf234ae7804 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 16 Aug 2021 01:32:01 +0200 Subject: [PATCH 384/903] Address late review of nut integration (#54606) * remove defaults from SensorEntityDescription * use _attr_unique_id instead of unique_id() * check if unique_id is not None --- homeassistant/components/nut/const.py | 147 ------------------------- homeassistant/components/nut/sensor.py | 9 +- 2 files changed, 2 insertions(+), 154 deletions(-) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 5bdd9049456..a180c2224f7 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -51,32 +51,22 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status.display": SensorEntityDescription( key="ups.status.display", name="Status", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.status": SensorEntityDescription( key="ups.status", name="Status Data", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.alarm": SensorEntityDescription( key="ups.alarm", name="Alarms", - native_unit_of_measurement=None, icon="mdi:alarm", - device_class=None, - state_class=None, ), "ups.temperature": SensorEntityDescription( key="ups.temperature", name="UPS Temperature", native_unit_of_measurement=TEMP_CELSIUS, - icon=None, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), @@ -85,7 +75,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { name="Load", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - device_class=None, state_class=STATE_CLASS_MEASUREMENT, ), "ups.load.high": SensorEntityDescription( @@ -93,111 +82,79 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { name="Overload Setting", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - device_class=None, - state_class=None, ), "ups.id": SensorEntityDescription( key="ups.id", name="System identifier", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.delay.start": SensorEntityDescription( key="ups.delay.start", name="Load Restart Delay", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - device_class=None, - state_class=None, ), "ups.delay.reboot": SensorEntityDescription( key="ups.delay.reboot", name="UPS Reboot Delay", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - device_class=None, - state_class=None, ), "ups.delay.shutdown": SensorEntityDescription( key="ups.delay.shutdown", name="UPS Shutdown Delay", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - device_class=None, - state_class=None, ), "ups.timer.start": SensorEntityDescription( key="ups.timer.start", name="Load Start Timer", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - device_class=None, - state_class=None, ), "ups.timer.reboot": SensorEntityDescription( key="ups.timer.reboot", name="Load Reboot Timer", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - device_class=None, - state_class=None, ), "ups.timer.shutdown": SensorEntityDescription( key="ups.timer.shutdown", name="Load Shutdown Timer", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - device_class=None, - state_class=None, ), "ups.test.interval": SensorEntityDescription( key="ups.test.interval", name="Self-Test Interval", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - device_class=None, - state_class=None, ), "ups.test.result": SensorEntityDescription( key="ups.test.result", name="Self-Test Result", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.test.date": SensorEntityDescription( key="ups.test.date", name="Self-Test Date", - native_unit_of_measurement=None, icon="mdi:calendar", - device_class=None, - state_class=None, ), "ups.display.language": SensorEntityDescription( key="ups.display.language", name="Language", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.contacts": SensorEntityDescription( key="ups.contacts", name="External Contacts", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.efficiency": SensorEntityDescription( key="ups.efficiency", name="Efficiency", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - device_class=None, state_class=STATE_CLASS_MEASUREMENT, ), "ups.power": SensorEntityDescription( @@ -205,7 +162,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { name="Current Apparent Power", native_unit_of_measurement=POWER_VOLT_AMPERE, icon="mdi:flash", - device_class=None, state_class=STATE_CLASS_MEASUREMENT, ), "ups.power.nominal": SensorEntityDescription( @@ -213,14 +169,11 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { name="Nominal Power", native_unit_of_measurement=POWER_VOLT_AMPERE, icon="mdi:flash", - device_class=None, - state_class=None, ), "ups.realpower": SensorEntityDescription( key="ups.realpower", name="Current Real Power", native_unit_of_measurement=POWER_WATT, - icon=None, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), @@ -228,71 +181,47 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.realpower.nominal", name="Nominal Real Power", native_unit_of_measurement=POWER_WATT, - icon=None, device_class=DEVICE_CLASS_POWER, - state_class=None, ), "ups.beeper.status": SensorEntityDescription( key="ups.beeper.status", name="Beeper Status", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.type": SensorEntityDescription( key="ups.type", name="UPS Type", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.watchdog.status": SensorEntityDescription( key="ups.watchdog.status", name="Watchdog Status", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.start.auto": SensorEntityDescription( key="ups.start.auto", name="Start on AC", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.start.battery": SensorEntityDescription( key="ups.start.battery", name="Start on Battery", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.start.reboot": SensorEntityDescription( key="ups.start.reboot", name="Reboot on Battery", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "ups.shutdown": SensorEntityDescription( key="ups.shutdown", name="Shutdown Ability", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "battery.charge": SensorEntityDescription( key="battery.charge", name="Battery Charge", native_unit_of_measurement=PERCENTAGE, - icon=None, device_class=DEVICE_CLASS_BATTERY, state_class=STATE_CLASS_MEASUREMENT, ), @@ -301,38 +230,28 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { name="Low Battery Setpoint", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - device_class=None, - state_class=None, ), "battery.charge.restart": SensorEntityDescription( key="battery.charge.restart", name="Minimum Battery to Start", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - device_class=None, - state_class=None, ), "battery.charge.warning": SensorEntityDescription( key="battery.charge.warning", name="Warning Battery Setpoint", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - device_class=None, - state_class=None, ), "battery.charger.status": SensorEntityDescription( key="battery.charger.status", name="Charging Status", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "battery.voltage": SensorEntityDescription( key="battery.voltage", name="Battery Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), @@ -340,40 +259,31 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.voltage.nominal", name="Nominal Battery Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon=None, device_class=DEVICE_CLASS_VOLTAGE, - state_class=None, ), "battery.voltage.low": SensorEntityDescription( key="battery.voltage.low", name="Low Battery Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon=None, device_class=DEVICE_CLASS_VOLTAGE, - state_class=None, ), "battery.voltage.high": SensorEntityDescription( key="battery.voltage.high", name="High Battery Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon=None, device_class=DEVICE_CLASS_VOLTAGE, - state_class=None, ), "battery.capacity": SensorEntityDescription( key="battery.capacity", name="Battery Capacity", native_unit_of_measurement="Ah", icon="mdi:flash", - device_class=None, - state_class=None, ), "battery.current": SensorEntityDescription( key="battery.current", name="Battery Current", native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", - device_class=None, state_class=STATE_CLASS_MEASUREMENT, ), "battery.current.total": SensorEntityDescription( @@ -381,14 +291,11 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { name="Total Battery Current", native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", - device_class=None, - state_class=None, ), "battery.temperature": SensorEntityDescription( key="battery.temperature", name="Battery Temperature", native_unit_of_measurement=TEMP_CELSIUS, - icon=None, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), @@ -397,110 +304,75 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { name="Battery Runtime", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - device_class=None, - state_class=None, ), "battery.runtime.low": SensorEntityDescription( key="battery.runtime.low", name="Low Battery Runtime", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - device_class=None, - state_class=None, ), "battery.runtime.restart": SensorEntityDescription( key="battery.runtime.restart", name="Minimum Battery Runtime to Start", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - device_class=None, - state_class=None, ), "battery.alarm.threshold": SensorEntityDescription( key="battery.alarm.threshold", name="Battery Alarm Threshold", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "battery.date": SensorEntityDescription( key="battery.date", name="Battery Date", - native_unit_of_measurement=None, icon="mdi:calendar", - device_class=None, - state_class=None, ), "battery.mfr.date": SensorEntityDescription( key="battery.mfr.date", name="Battery Manuf. Date", - native_unit_of_measurement=None, icon="mdi:calendar", - device_class=None, - state_class=None, ), "battery.packs": SensorEntityDescription( key="battery.packs", name="Number of Batteries", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "battery.packs.bad": SensorEntityDescription( key="battery.packs.bad", name="Number of Bad Batteries", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "battery.type": SensorEntityDescription( key="battery.type", name="Battery Chemistry", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "input.sensitivity": SensorEntityDescription( key="input.sensitivity", name="Input Power Sensitivity", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "input.transfer.low": SensorEntityDescription( key="input.transfer.low", name="Low Voltage Transfer", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon=None, device_class=DEVICE_CLASS_VOLTAGE, - state_class=None, ), "input.transfer.high": SensorEntityDescription( key="input.transfer.high", name="High Voltage Transfer", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon=None, device_class=DEVICE_CLASS_VOLTAGE, - state_class=None, ), "input.transfer.reason": SensorEntityDescription( key="input.transfer.reason", name="Voltage Transfer Reason", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "input.voltage": SensorEntityDescription( key="input.voltage", name="Input Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), @@ -508,16 +380,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="input.voltage.nominal", name="Nominal Input Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon=None, device_class=DEVICE_CLASS_VOLTAGE, - state_class=None, ), "input.frequency": SensorEntityDescription( key="input.frequency", name="Input Line Frequency", native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", - device_class=None, state_class=STATE_CLASS_MEASUREMENT, ), "input.frequency.nominal": SensorEntityDescription( @@ -525,23 +394,17 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { name="Nominal Input Line Frequency", native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", - device_class=None, - state_class=None, ), "input.frequency.status": SensorEntityDescription( key="input.frequency.status", name="Input Frequency Status", - native_unit_of_measurement=None, icon="mdi:information-outline", - device_class=None, - state_class=None, ), "output.current": SensorEntityDescription( key="output.current", name="Output Current", native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", - device_class=None, state_class=STATE_CLASS_MEASUREMENT, ), "output.current.nominal": SensorEntityDescription( @@ -549,14 +412,11 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { name="Nominal Output Current", native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", - device_class=None, - state_class=None, ), "output.voltage": SensorEntityDescription( key="output.voltage", name="Output Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon=None, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), @@ -564,16 +424,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="output.voltage.nominal", name="Nominal Output Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - icon=None, device_class=DEVICE_CLASS_VOLTAGE, - state_class=None, ), "output.frequency": SensorEntityDescription( key="output.frequency", name="Output Frequency", native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", - device_class=None, state_class=STATE_CLASS_MEASUREMENT, ), "output.frequency.nominal": SensorEntityDescription( @@ -581,14 +438,11 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { name="Nominal Output Frequency", native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", - device_class=None, - state_class=None, ), "ambient.humidity": SensorEntityDescription( key="ambient.humidity", name="Ambient Humidity", native_unit_of_measurement=PERCENTAGE, - icon=None, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), @@ -596,7 +450,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ambient.temperature", name="Ambient Temperature", native_unit_of_measurement=TEMP_CELSIUS, - icon=None, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 971c194c1c9..995032eb0fd 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -104,6 +104,8 @@ class NUTSensor(CoordinatorEntity, SensorEntity): self._unique_id = unique_id self._attr_name = f"{name} {sensor_description.name}" + if unique_id is not None: + self._attr_unique_id = f"{unique_id}_{sensor_description.key}" @property def device_info(self): @@ -122,13 +124,6 @@ class NUTSensor(CoordinatorEntity, SensorEntity): device_info["sw_version"] = self._firmware return device_info - @property - def unique_id(self): - """Sensor Unique id.""" - if not self._unique_id: - return None - return f"{self._unique_id}_{self.entity_description.key}" - @property def native_value(self): """Return entity state from ups.""" From d091068092c80332c8c15e61e3f7699d0e1efd3a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 16 Aug 2021 00:11:52 +0000 Subject: [PATCH 385/903] [ci skip] Translation update --- homeassistant/components/xiaomi_miio/translations/en.json | 2 +- .../components/xiaomi_miio/translations/select.ru.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/translations/en.json b/homeassistant/components/xiaomi_miio/translations/en.json index cbe10230093..84593a3edc1 100644 --- a/homeassistant/components/xiaomi_miio/translations/en.json +++ b/homeassistant/components/xiaomi_miio/translations/en.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "Failed to connect", "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", - "cloud_login_error": "Could not login to Xioami Miio Cloud, check the credentials.", + "cloud_login_error": "Could not login to Xiaomi Miio Cloud, check the credentials.", "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", "no_device_selected": "No device selected, please select one device.", "unknown_device": "The device model is not known, not able to setup the device using config flow." diff --git a/homeassistant/components/xiaomi_miio/translations/select.ru.json b/homeassistant/components/xiaomi_miio/translations/select.ru.json index 4dac3002d1b..138d2b4fdce 100644 --- a/homeassistant/components/xiaomi_miio/translations/select.ru.json +++ b/homeassistant/components/xiaomi_miio/translations/select.ru.json @@ -3,7 +3,7 @@ "xiaomi_miio__led_brightness": { "bright": "\u042f\u0440\u043a\u043e", "dim": "\u0422\u0443\u0441\u043a\u043b\u043e", - "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + "off": "\u041e\u0442\u043a\u043b." } } } \ No newline at end of file From 41d932fcf8d1ba34a2064bacb1f1f78822d9286d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 16 Aug 2021 14:56:21 +1200 Subject: [PATCH 386/903] Send color_brightness to ESPHome devices on 1.20 (pre-color_mode) (#54670) --- homeassistant/components/esphome/light.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index aeecc22d9f1..c6cf9742082 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -105,8 +105,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): color_bri = max(rgb) # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) + data["color_brightness"] = color_bri if self._supports_color_mode: - data["color_brightness"] = color_bri data["color_mode"] = LightColorMode.RGB if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: @@ -116,8 +116,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["white"] = w + data["color_brightness"] = color_bri if self._supports_color_mode: - data["color_brightness"] = color_bri data["color_mode"] = LightColorMode.RGB_WHITE if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: @@ -144,8 +144,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) target_mode = LightColorMode.RGB_COLOR_TEMPERATURE + data["color_brightness"] = color_bri if self._supports_color_mode: - data["color_brightness"] = color_bri data["color_mode"] = target_mode if (flash := kwargs.get(ATTR_FLASH)) is not None: From bab7d46c9bf4b46afe9af138f11d933cbedbc3f8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 15 Aug 2021 19:56:56 -0700 Subject: [PATCH 387/903] Guard partial upgrade (#54617) --- homeassistant/components/http/forwarded.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/http/forwarded.py b/homeassistant/components/http/forwarded.py index 9a76866ba21..6dd2d9adb8a 100644 --- a/homeassistant/components/http/forwarded.py +++ b/homeassistant/components/http/forwarded.py @@ -65,6 +65,11 @@ def async_setup_forwarded( try: from hass_nabucasa import remote # pylint: disable=import-outside-toplevel + + # venv users might have already loaded it before it got upgraded so guard for this + # This can only happen when people upgrade from before 2021.8.5. + if not hasattr(remote, "is_cloud_request"): + remote = None except ImportError: remote = None From b2f73b3c69f834f3b10537fce42b87bfb054dcf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 16 Aug 2021 04:57:18 +0200 Subject: [PATCH 388/903] Fix Tibber last reset (#54582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/tibber/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 6674f6829f9..15bf2a2017e 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -137,6 +137,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.NEVER, ), "lastMeterProduction": TibberSensorEntityDescription( key="lastMeterProduction", @@ -144,6 +145,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.NEVER, ), "voltagePhase1": TibberSensorEntityDescription( key="voltagePhase1", From bec42b74feddf5a38171ca1333d8c64de6bd6f90 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 16 Aug 2021 04:57:37 +0200 Subject: [PATCH 389/903] Solve switch/verify register type convert problem in modbus (#54645) --- homeassistant/components/modbus/base_platform.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index c580e6167ca..468e61aefa8 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -187,9 +187,9 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): self._verify_address = config[CONF_VERIFY].get( CONF_ADDRESS, config[CONF_ADDRESS] ) - self._verify_type = config[CONF_VERIFY].get( - CONF_INPUT_TYPE, convert[config[CONF_WRITE_TYPE]][0] - ) + self._verify_type = convert[ + config[CONF_VERIFY].get(CONF_INPUT_TYPE, config[CONF_WRITE_TYPE]) + ][0] self._state_on = config[CONF_VERIFY].get(CONF_STATE_ON, self.command_on) self._state_off = config[CONF_VERIFY].get(CONF_STATE_OFF, self._command_off) else: From 094f7d38ad5b30f0dbedeea4492b7a85f3c0c228 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 15 Aug 2021 21:02:37 -0700 Subject: [PATCH 390/903] Use buffer at stream start with unsupported audio (#54672) Add a test that reproduces the issue where resetting the iterator drops the already read packets. Fix a bug in replace_underlying_iterator because checking the self._next function turns out not to work since it points to a bound method so the "is not" check fails. --- homeassistant/components/stream/worker.py | 2 +- tests/components/stream/test_worker.py | 52 ++++++++++++++--------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 69def43b2a2..039163c6cf5 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -225,7 +225,7 @@ class PeekIterator(Iterator): def replace_underlying_iterator(self, new_iterator: Iterator) -> None: """Replace the underlying iterator while preserving the buffer.""" self._iterator = new_iterator - if self._next is not self._pop_buffer: + if not self._buffer: self._next = self._iterator.__next__ def _pop_buffer(self) -> av.Packet: diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index ffbeb44d79e..e62a190d7be 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -15,6 +15,7 @@ failure modes or corner cases like how out of order packets are handled. import fractions import io +import logging import math import threading from unittest.mock import patch @@ -52,7 +53,7 @@ SEGMENTS_PER_PACKET = PACKET_DURATION / SEGMENT_DURATION TIMEOUT = 15 -class FakePyAvStream: +class FakeAvInputStream: """A fake pyav Stream.""" def __init__(self, name, rate): @@ -66,9 +67,13 @@ class FakePyAvStream: self.codec = FakeCodec() + def __str__(self) -> str: + """Return a stream name for debugging.""" + return f"FakePyAvStream<{self.name}, {self.time_base}>" -VIDEO_STREAM = FakePyAvStream(VIDEO_STREAM_FORMAT, VIDEO_FRAME_RATE) -AUDIO_STREAM = FakePyAvStream(AUDIO_STREAM_FORMAT, AUDIO_SAMPLE_RATE) + +VIDEO_STREAM = FakeAvInputStream(VIDEO_STREAM_FORMAT, VIDEO_FRAME_RATE) +AUDIO_STREAM = FakeAvInputStream(AUDIO_STREAM_FORMAT, AUDIO_SAMPLE_RATE) class PacketSequence: @@ -110,6 +115,9 @@ class PacketSequence: is_keyframe = not (self.packet - 1) % (VIDEO_FRAME_RATE * KEYFRAME_INTERVAL) size = 3 + def __str__(self) -> str: + return f"FakePacket" + return FakePacket() @@ -154,7 +162,7 @@ class FakePyAvBuffer: def add_stream(self, template=None): """Create an output buffer that captures packets for test to examine.""" - class FakeStream: + class FakeAvOutputStream: def __init__(self, capture_packets): self.capture_packets = capture_packets @@ -162,11 +170,15 @@ class FakePyAvBuffer: return def mux(self, packet): + logging.debug("Muxed packet: %s", packet) self.capture_packets.append(packet) + def __str__(self) -> str: + return f"FakeAvOutputStream<{template.name}>" + if template.name == AUDIO_STREAM_FORMAT: - return FakeStream(self.audio_packets) - return FakeStream(self.video_packets) + return FakeAvOutputStream(self.audio_packets) + return FakeAvOutputStream(self.video_packets) def mux(self, packet): """Capture a packet for tests to examine.""" @@ -217,7 +229,7 @@ async def async_decode_stream(hass, packets, py_av=None): if not py_av: py_av = MockPyAv() - py_av.container.packets = packets + py_av.container.packets = iter(packets) # Can't be rewound with patch("av.open", new=py_av.open), patch( "homeassistant.components.stream.core.StreamOutput.put", @@ -273,7 +285,7 @@ async def test_skip_out_of_order_packet(hass): assert not packets[out_of_order_index].is_keyframe packets[out_of_order_index].dts = -9090 - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check sequence numbers @@ -309,7 +321,7 @@ async def test_discard_old_packets(hass): # Packets after this one are considered out of order packets[OUT_OF_ORDER_PACKET_INDEX - 1].dts = 9090 - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check number of segments @@ -331,7 +343,7 @@ async def test_packet_overflow(hass): # Packet is so far out of order, exceeds max gap and looks like overflow packets[OUT_OF_ORDER_PACKET_INDEX].dts = -9000000 - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check number of segments @@ -355,7 +367,7 @@ async def test_skip_initial_bad_packets(hass): for i in range(0, num_bad_packets): packets[i].dts = None - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check sequence numbers @@ -385,7 +397,7 @@ async def test_too_many_initial_bad_packets_fails(hass): for i in range(0, num_bad_packets): packets[i].dts = None - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments assert len(segments) == 0 assert len(decoded_stream.video_packets) == 0 @@ -405,7 +417,7 @@ async def test_skip_missing_dts(hass): continue packets[i].dts = None - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check sequence numbers @@ -426,7 +438,7 @@ async def test_too_many_bad_packets(hass): for i in range(bad_packet_start, bad_packet_start + num_bad_packets): packets[i].dts = None - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) complete_segments = decoded_stream.complete_segments assert len(complete_segments) == int((bad_packet_start - 1) * SEGMENTS_PER_PACKET) assert len(decoded_stream.video_packets) == bad_packet_start @@ -454,7 +466,7 @@ async def test_audio_packets_not_found(hass): num_packets = PACKETS_TO_WAIT_FOR_AUDIO + 1 packets = PacketSequence(num_packets) # Contains only video packets - decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + decoded_stream = await async_decode_stream(hass, packets, py_av=py_av) complete_segments = decoded_stream.complete_segments assert len(complete_segments) == int((num_packets - 1) * SEGMENTS_PER_PACKET) assert len(decoded_stream.video_packets) == num_packets @@ -474,8 +486,10 @@ async def test_adts_aac_audio(hass): packets[1][0] = 255 packets[1][1] = 241 - decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + decoded_stream = await async_decode_stream(hass, packets, py_av=py_av) assert len(decoded_stream.audio_packets) == 0 + # All decoded video packets are still preserved + assert len(decoded_stream.video_packets) == num_packets - 1 async def test_audio_is_first_packet(hass): @@ -493,7 +507,7 @@ async def test_audio_is_first_packet(hass): packets[2].dts = int(packets[3].dts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) packets[2].pts = int(packets[3].pts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) - decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + decoded_stream = await async_decode_stream(hass, packets, py_av=py_av) complete_segments = decoded_stream.complete_segments # The audio packets are segmented with the video packets assert len(complete_segments) == int((num_packets - 2 - 1) * SEGMENTS_PER_PACKET) @@ -511,7 +525,7 @@ async def test_audio_packets_found(hass): packets[1].dts = int(packets[0].dts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) packets[1].pts = int(packets[0].pts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) - decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + decoded_stream = await async_decode_stream(hass, packets, py_av=py_av) complete_segments = decoded_stream.complete_segments # The audio packet above is buffered with the video packet assert len(complete_segments) == int((num_packets - 1 - 1) * SEGMENTS_PER_PACKET) @@ -529,7 +543,7 @@ async def test_pts_out_of_order(hass): packets[i].pts = packets[i - 1].pts - 1 packets[i].is_keyframe = False - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check number of segments From 416668c2890fb60691b9e19b373f2ec2c6eacbd6 Mon Sep 17 00:00:00 2001 From: cnico Date: Mon, 16 Aug 2021 07:12:39 +0200 Subject: [PATCH 391/903] Address late review of Flipr (#54668) * Code format correction * Other code review remarks of MartinHjelmare * Simplification of flipr instantiation * Formatting correction --- homeassistant/components/flipr/__init__.py | 4 +--- homeassistant/components/flipr/config_flow.py | 7 +++---- homeassistant/components/flipr/sensor.py | 5 +++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 05bbd0d5449..66ea93484f7 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -54,8 +54,6 @@ class FliprDataUpdateCoordinator(DataUpdateCoordinator): password = entry.data[CONF_PASSWORD] self.flipr_id = entry.data[CONF_FLIPR_ID] - _LOGGER.debug("Config entry values : %s, %s", username, self.flipr_id) - # Establishes the connection. self.client = FliprAPIRestClient(username, password) self.entry = entry diff --git a/homeassistant/components/flipr/config_flow.py b/homeassistant/components/flipr/config_flow.py index b503281fed4..b1e4f31d044 100644 --- a/homeassistant/components/flipr/config_flow.py +++ b/homeassistant/components/flipr/config_flow.py @@ -45,7 +45,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" _LOGGER.exception(exception) - if not errors and len(flipr_ids) == 0: + if not errors and not flipr_ids: # No flipr_id found. Tell the user with an error message. errors["base"] = "no_flipr_id_found" @@ -85,9 +85,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _authenticate_and_search_flipr(self) -> list[str]: """Validate the username and password provided and searches for a flipr id.""" - client = await self.hass.async_add_executor_job( - FliprAPIRestClient, self._username, self._password - ) + # Instantiates the flipr API that does not require async since it is has no network access. + client = FliprAPIRestClient(self._username, self._password) flipr_ids = await self.hass.async_add_executor_job(client.search_flipr_ids) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 3a02b7e2f2e..f9fd4e9633e 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -6,6 +6,7 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + ELECTRIC_POTENTIAL_MILLIVOLT, TEMP_CELSIUS, ) @@ -14,7 +15,7 @@ from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN SENSORS = { "chlorine": { - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "icon": "mdi:pool", "name": "Chlorine", "device_class": None, @@ -33,7 +34,7 @@ SENSORS = { "device_class": DEVICE_CLASS_TIMESTAMP, }, "red_ox": { - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "icon": "mdi:pool", "name": "Red OX", "device_class": None, From 8c4a2dc6d2f3dc047eacba34eb2de66a4e5d43a9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 16 Aug 2021 09:13:45 +0200 Subject: [PATCH 392/903] Add `water level` and `water tank detached` sensors for Xiaomi Miio humidifiers (#54625) * Add water level and water tank detached sensors * Use elif * Use DEVICE_CLASS_CONNECTIVITY for water tank sensor * Improve docstring * Change the water tank sensor icon * Fix typo --- .../components/xiaomi_miio/binary_sensor.py | 39 ++++++++++++++++--- .../components/xiaomi_miio/sensor.py | 9 ++++- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index c2f14b17d22..6254c00916e 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -1,7 +1,12 @@ """Support for Xiaomi Miio binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass from enum import Enum +from typing import Callable from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -13,6 +18,8 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODELS_HUMIDIFIER_MIIO, + MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, ) from .device import XiaomiCoordinatedMiioEntity @@ -20,19 +27,31 @@ from .device import XiaomiCoordinatedMiioEntity ATTR_NO_WATER = "no_water" ATTR_WATER_TANK_DETACHED = "water_tank_detached" + +@dataclass +class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): + """A class that describes binary sensor entities.""" + + value: Callable | None = None + + BINARY_SENSOR_TYPES = ( - BinarySensorEntityDescription( + XiaomiMiioBinarySensorDescription( key=ATTR_NO_WATER, name="Water Tank Empty", icon="mdi:water-off-outline", ), - BinarySensorEntityDescription( + XiaomiMiioBinarySensorDescription( key=ATTR_WATER_TANK_DETACHED, - name="Water Tank Detached", - icon="mdi:flask-empty-off-outline", + name="Water Tank", + icon="mdi:car-coolant-level", + device_class=DEVICE_CLASS_CONNECTIVITY, + value=lambda value: not value, ), ) +HUMIDIFIER_MIIO_BINARY_SENSORS = (ATTR_WATER_TANK_DETACHED,) +HUMIDIFIER_MIOT_BINARY_SENSORS = (ATTR_WATER_TANK_DETACHED,) HUMIDIFIER_MJJSQ_BINARY_SENSORS = (ATTR_NO_WATER, ATTR_WATER_TANK_DETACHED) @@ -43,7 +62,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: model = config_entry.data[CONF_MODEL] sensors = [] - if model in MODELS_HUMIDIFIER_MJJSQ: + if model in MODELS_HUMIDIFIER_MIIO: + sensors = HUMIDIFIER_MIIO_BINARY_SENSORS + elif model in MODELS_HUMIDIFIER_MIOT: + sensors = HUMIDIFIER_MIOT_BINARY_SENSORS + elif model in MODELS_HUMIDIFIER_MJJSQ: sensors = HUMIDIFIER_MJJSQ_BINARY_SENSORS for description in BINARY_SENSOR_TYPES: if description.key not in sensors: @@ -74,9 +97,13 @@ class XiaomiGenericBinarySensor(XiaomiCoordinatedMiioEntity, BinarySensorEntity) @property def is_on(self): """Return true if the binary sensor is on.""" - return self._extract_value_from_attribute( + state = self._extract_value_from_attribute( self.coordinator.data, self.entity_description.key ) + if self.entity_description.value is not None and state is not None: + return self.entity_description.value(state) + + return state @staticmethod def _extract_value_from_attribute(state, attribute): diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 26914e1dff8..a8a2787aaed 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -234,8 +234,13 @@ SENSOR_TYPES = { ), } -HUMIDIFIER_MIIO_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE) -HUMIDIFIER_CA1_CB1_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE, ATTR_MOTOR_SPEED) +HUMIDIFIER_MIIO_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE, ATTR_WATER_LEVEL) +HUMIDIFIER_CA1_CB1_SENSORS = ( + ATTR_HUMIDITY, + ATTR_TEMPERATURE, + ATTR_MOTOR_SPEED, + ATTR_WATER_LEVEL, +) HUMIDIFIER_MIOT_SENSORS = ( ATTR_ACTUAL_SPEED, ATTR_HUMIDITY, From f684e4df349f7b95da473a1003f708ac67284848 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 16 Aug 2021 10:36:31 +0100 Subject: [PATCH 393/903] Add code owner to GitHub integration (#54689) --- CODEOWNERS | 1 + homeassistant/components/github/manifest.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index a7aea24c4f0..4b7cb8520b0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -187,6 +187,7 @@ homeassistant/components/geo_rss_events/* @exxamalte homeassistant/components/geonetnz_quakes/* @exxamalte homeassistant/components/geonetnz_volcano/* @exxamalte homeassistant/components/gios/* @bieniu +homeassistant/components/github/* @timmo001 homeassistant/components/gitter/* @fabaff homeassistant/components/glances/* @fabaff @engrbm87 homeassistant/components/goalzero/* @tkdrob diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json index d4405196b7a..40693244b91 100644 --- a/homeassistant/components/github/manifest.json +++ b/homeassistant/components/github/manifest.json @@ -3,6 +3,6 @@ "name": "GitHub", "documentation": "https://www.home-assistant.io/integrations/github", "requirements": ["PyGithub==1.43.8"], - "codeowners": [], + "codeowners": ["@timmo001"], "iot_class": "cloud_polling" } From 045b1ca6ae5366060727131ce3fa791a5b966d85 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 16 Aug 2021 12:41:35 +0200 Subject: [PATCH 394/903] Activate mypy in lifx (#54540) --- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 2 files changed, 4 deletions(-) diff --git a/mypy.ini b/mypy.ini index 0cd2a419fe0..91b40b63cc1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1466,9 +1466,6 @@ ignore_errors = true [mypy-homeassistant.components.kulersky.*] ignore_errors = true -[mypy-homeassistant.components.lifx.*] -ignore_errors = true - [mypy-homeassistant.components.litejet.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 188f2a0a41b..1b24a935084 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -81,7 +81,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.konnected.*", "homeassistant.components.kostal_plenticore.*", "homeassistant.components.kulersky.*", - "homeassistant.components.lifx.*", "homeassistant.components.litejet.*", "homeassistant.components.litterrobot.*", "homeassistant.components.lovelace.*", From 75275254f99a4f52492f09a608e26edf3277384d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Aug 2021 12:52:58 +0200 Subject: [PATCH 395/903] Renault test optimisation (#53705) * Cleanup tests * Use a MockConfigEntry * Don't set up the integration for duplicate config entry testing --- tests/components/renault/__init__.py | 176 ++++++------ tests/components/renault/test_config_flow.py | 268 ++++++++++++------- tests/components/renault/test_init.py | 84 +++--- tests/components/renault/test_sensor.py | 79 ++---- 4 files changed, 314 insertions(+), 293 deletions(-) diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index e4edc3b8539..fcd190fe98d 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -1,52 +1,32 @@ """Tests for the Renault integration.""" from __future__ import annotations -from datetime import timedelta from typing import Any from unittest.mock import patch -from renault_api.kamereon import models, schemas -from renault_api.renault_vehicle import RenaultVehicle +from renault_api.kamereon import schemas +from renault_api.renault_account import RenaultAccount -from homeassistant.components.renault.const import ( - CONF_KAMEREON_ACCOUNT_ID, - CONF_LOCALE, - DOMAIN, -) -from homeassistant.components.renault.renault_vehicle import RenaultVehicleProxy -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.renault.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import MOCK_VEHICLES +from .const import MOCK_CONFIG, MOCK_VEHICLES from tests.common import MockConfigEntry, load_fixture -async def setup_renault_integration(hass: HomeAssistant): +def get_mock_config_entry(): """Create the Renault integration.""" - config_entry = MockConfigEntry( + return MockConfigEntry( domain=DOMAIN, - source="user", - data={ - CONF_LOCALE: "fr_FR", - CONF_USERNAME: "email@test.com", - CONF_PASSWORD: "test", - CONF_KAMEREON_ACCOUNT_ID: "account_id_2", - }, - unique_id="account_id_2", + source=SOURCE_USER, + data=MOCK_CONFIG, + unique_id="account_id_1", options={}, - entry_id="1", + entry_id="123456", ) - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.renault.RenaultHub.attempt_login", return_value=True - ), patch("homeassistant.components.renault.RenaultHub.async_initialise"): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - return config_entry def get_fixtures(vehicle_type: str) -> dict[str, Any]: @@ -76,84 +56,116 @@ def get_fixtures(vehicle_type: str) -> dict[str, Any]: } -async def create_vehicle_proxy( - hass: HomeAssistant, vehicle_type: str -) -> RenaultVehicleProxy: - """Create a vehicle proxy for testing.""" +async def setup_renault_integration_simple(hass: HomeAssistant): + """Create the Renault integration.""" + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) + + renault_account = RenaultAccount( + config_entry.unique_id, + websession=aiohttp_client.async_get_clientsession(hass), + ) + + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ), patch("renault_api.renault_account.RenaultAccount.get_vehicles"): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +async def setup_renault_integration_vehicle(hass: HomeAssistant, vehicle_type: str): + """Create the Renault integration.""" + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) + + renault_account = RenaultAccount( + config_entry.unique_id, + websession=aiohttp_client.async_get_clientsession(hass), + ) mock_vehicle = MOCK_VEHICLES[vehicle_type] mock_fixtures = get_fixtures(vehicle_type) - vehicles_response: models.KamereonVehiclesResponse = ( - schemas.KamereonVehiclesResponseSchema.loads( - load_fixture(f"renault/vehicle_{vehicle_type}.json") - ) - ) - vehicle_details = vehicles_response.vehicleLinks[0].vehicleDetails - vehicle = RenaultVehicle( - vehicles_response.accountId, - vehicle_details.vin, - websession=aiohttp_client.async_get_clientsession(hass), - ) - - vehicle_proxy = RenaultVehicleProxy( - hass, vehicle, vehicle_details, timedelta(seconds=300) - ) - with patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.endpoint_available", + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ), patch( + "renault_api.renault_account.RenaultAccount.get_vehicles", + return_value=( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.supports_endpoint", side_effect=mock_vehicle["endpoints_available"], ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_battery_status", + "renault_api.renault_vehicle.RenaultVehicle.has_contract_for_endpoint", + return_value=True, + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", return_value=mock_fixtures["battery_status"], ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_charge_mode", + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", return_value=mock_fixtures["charge_mode"], ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_cockpit", + "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", return_value=mock_fixtures["cockpit"], ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_hvac_status", + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", return_value=mock_fixtures["hvac_status"], ): - await vehicle_proxy.async_initialise() - return vehicle_proxy + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry -async def create_vehicle_proxy_with_side_effect( +async def setup_renault_integration_vehicle_with_side_effect( hass: HomeAssistant, vehicle_type: str, side_effect: Any -) -> RenaultVehicleProxy: - """Create a vehicle proxy for testing unavailable entities.""" - mock_vehicle = MOCK_VEHICLES[vehicle_type] +): + """Create the Renault integration.""" + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) - vehicles_response: models.KamereonVehiclesResponse = ( - schemas.KamereonVehiclesResponseSchema.loads( - load_fixture(f"renault/vehicle_{vehicle_type}.json") - ) - ) - vehicle_details = vehicles_response.vehicleLinks[0].vehicleDetails - vehicle = RenaultVehicle( - vehicles_response.accountId, - vehicle_details.vin, + renault_account = RenaultAccount( + config_entry.unique_id, websession=aiohttp_client.async_get_clientsession(hass), ) + mock_vehicle = MOCK_VEHICLES[vehicle_type] - vehicle_proxy = RenaultVehicleProxy( - hass, vehicle, vehicle_details, timedelta(seconds=300) - ) - with patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.endpoint_available", + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ), patch( + "renault_api.renault_account.RenaultAccount.get_vehicles", + return_value=( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.supports_endpoint", side_effect=mock_vehicle["endpoints_available"], ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_battery_status", + "renault_api.renault_vehicle.RenaultVehicle.has_contract_for_endpoint", + return_value=True, + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", side_effect=side_effect, ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_charge_mode", + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", side_effect=side_effect, ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_cockpit", + "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", side_effect=side_effect, ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_hvac_status", + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", side_effect=side_effect, ): - await vehicle_proxy.async_initialise() - return vehicle_proxy + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index c8b9c8c3e12..684e17a0101 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, PropertyMock, patch from renault_api.gigya.exceptions import InvalidCredentialsException from renault_api.kamereon import schemas +from renault_api.renault_account import RenaultAccount from homeassistant import config_entries, data_entry_flow from homeassistant.components.renault.const import ( @@ -12,126 +13,197 @@ from homeassistant.components.renault.const import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from . import get_mock_config_entry from tests.common import load_fixture async def test_config_flow_single_account(hass: HomeAssistant): """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {} - - # Failed credentials with patch( - "renault_api.renault_session.RenaultSession.login", - side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_LOCALE: "fr_FR", - CONF_USERNAME: "email@test.com", - CONF_PASSWORD: "test", - }, + "homeassistant.components.renault.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Failed credentials + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=InvalidCredentialsException( + 403042, "invalid loginID or password" + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_credentials"} + + renault_account = AsyncMock() + type(renault_account).account_id = PropertyMock(return_value="account_id_1") + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "invalid_credentials"} + # Account list single + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_account.RenaultAccount.account_id", return_value="123" + ), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) - renault_account = AsyncMock() - type(renault_account).account_id = PropertyMock(return_value="account_id_1") - renault_account.get_vehicles.return_value = ( - schemas.KamereonVehiclesResponseSchema.loads( - load_fixture("renault/vehicle_zoe_40.json") - ) - ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "account_id_1" + assert result["data"][CONF_USERNAME] == "email@test.com" + assert result["data"][CONF_PASSWORD] == "test" + assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" + assert result["data"][CONF_LOCALE] == "fr_FR" - # Account list single - with patch("renault_api.renault_session.RenaultSession.login"), patch( - "renault_api.renault_account.RenaultAccount.account_id", return_value="123" - ), patch( - "renault_api.renault_client.RenaultClient.get_api_accounts", - return_value=[renault_account], - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_LOCALE: "fr_FR", - CONF_USERNAME: "email@test.com", - CONF_PASSWORD: "test", - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "account_id_1" - assert result["data"][CONF_USERNAME] == "email@test.com" - assert result["data"][CONF_PASSWORD] == "test" - assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" - assert result["data"][CONF_LOCALE] == "fr_FR" + assert len(mock_setup_entry.mock_calls) == 1 async def test_config_flow_no_account(hass: HomeAssistant): """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {} - - # Account list empty - with patch("renault_api.renault_session.RenaultSession.login"), patch( - "homeassistant.components.renault.config_flow.RenaultHub.get_account_ids", - return_value=[], - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_LOCALE: "fr_FR", - CONF_USERNAME: "email@test.com", - CONF_PASSWORD: "test", - }, + with patch( + "homeassistant.components.renault.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "kamereon_no_account" + # Account list empty + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "kamereon_no_account" + + assert len(mock_setup_entry.mock_calls) == 0 async def test_config_flow_multiple_accounts(hass: HomeAssistant): """Test what happens if multiple Kamereon accounts are available.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {} + with patch( + "homeassistant.components.renault.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} - # Multiple accounts - with patch("renault_api.renault_session.RenaultSession.login"), patch( - "homeassistant.components.renault.config_flow.RenaultHub.get_account_ids", - return_value=["account_id_1", "account_id_2"], - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_LOCALE: "fr_FR", - CONF_USERNAME: "email@test.com", - CONF_PASSWORD: "test", - }, + renault_account_1 = RenaultAccount( + "account_id_1", + websession=aiohttp_client.async_get_clientsession(hass), + ) + renault_account_2 = RenaultAccount( + "account_id_2", + websession=aiohttp_client.async_get_clientsession(hass), ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "kamereon" + # Multiple accounts + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account_1, renault_account_2], + ), patch("renault_api.renault_account.RenaultAccount.get_vehicles"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) - # Account selected - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_KAMEREON_ACCOUNT_ID: "account_id_2"}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "account_id_2" - assert result["data"][CONF_USERNAME] == "email@test.com" - assert result["data"][CONF_PASSWORD] == "test" - assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2" - assert result["data"][CONF_LOCALE] == "fr_FR" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "kamereon" + + # Account selected + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_KAMEREON_ACCOUNT_ID: "account_id_2"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "account_id_2" + assert result["data"][CONF_USERNAME] == "email@test.com" + assert result["data"][CONF_PASSWORD] == "test" + assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2" + assert result["data"][CONF_LOCALE] == "fr_FR" + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_duplicate(hass: HomeAssistant): + """Test abort if unique_id configured.""" + with patch( + "homeassistant.components.renault.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + get_mock_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} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + renault_account = RenaultAccount( + "account_id_1", + websession=aiohttp_client.async_get_clientsession(hass), + ) + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ), patch("renault_api.renault_account.RenaultAccount.get_vehicles"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index 974155c3df9..fab5eff8f0c 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -1,85 +1,63 @@ """Tests for Renault setup process.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import aiohttp -import pytest from renault_api.gigya.exceptions import InvalidCredentialsException -from renault_api.kamereon import schemas -from homeassistant.components.renault import ( - RenaultHub, - async_setup_entry, - async_unload_entry, -) from homeassistant.components.renault.const import DOMAIN -from homeassistant.components.renault.renault_vehicle import RenaultVehicleProxy -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.config_entries import ConfigEntryState -from .const import MOCK_CONFIG - -from tests.common import MockConfigEntry, load_fixture +from . import get_mock_config_entry, setup_renault_integration_simple -async def test_setup_unload_and_reload_entry(hass): +async def test_setup_unload_entry(hass): """Test entry setup and unload.""" - # Create a mock entry so we don't have to go through config flow - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 - ) - renault_account = AsyncMock() - renault_account.get_vehicles.return_value = ( - schemas.KamereonVehiclesResponseSchema.loads( - load_fixture("renault/vehicle_zoe_40.json") - ) - ) + with patch("homeassistant.components.renault.PLATFORMS", []): + config_entry = await setup_renault_integration_simple(hass) - with patch("renault_api.renault_session.RenaultSession.login"), patch( - "renault_api.renault_client.RenaultClient.get_api_account", - return_value=renault_account, - ): - # Set up the entry and assert that the values set during setup are where we expect - # them to be. - assert await async_setup_entry(hass, config_entry) - assert DOMAIN in hass.data and config_entry.unique_id in hass.data[DOMAIN] - assert isinstance(hass.data[DOMAIN][config_entry.unique_id], RenaultHub) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id in hass.data[DOMAIN] - renault_hub: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] - assert len(renault_hub.vehicles) == 1 - assert isinstance( - renault_hub.vehicles["VF1AAAAA555777999"], RenaultVehicleProxy - ) - - # Unload the entry and verify that the data has been removed - assert await async_unload_entry(hass, config_entry) - assert config_entry.unique_id not in hass.data[DOMAIN] + # Unload the entry and verify that the data has been removed + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert config_entry.unique_id not in hass.data[DOMAIN] async def test_setup_entry_bad_password(hass): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 - ) + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) with patch( "renault_api.renault_session.RenaultSession.login", side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), ): - # Set up the entry and assert that the values set during setup are where we expect - # them to be. - assert not await async_setup_entry(hass, config_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + 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_setup_entry_exception(hass): """Test ConfigEntryNotReady when API raises an exception during entry setup.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 - ) + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) # In this case we are testing the condition where async_setup_entry raises # ConfigEntryNotReady. with patch( "renault_api.renault_session.RenaultSession.login", side_effect=aiohttp.ClientConnectionError, - ), pytest.raises(ConfigEntryNotReady): - assert await async_setup_entry(hass, config_entry) + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 8956fa7e7e6..01db9ac8bba 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -1,5 +1,5 @@ """Tests for Renault sensors.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import patch import pytest from renault_api.kamereon import exceptions @@ -9,9 +9,8 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import async_setup_component from . import ( - create_vehicle_proxy, - create_vehicle_proxy_with_side_effect, - setup_renault_integration, + setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_side_effect, ) from .const import MOCK_VEHICLES @@ -25,16 +24,8 @@ async def test_sensors(hass, vehicle_type): entity_registry = mock_registry(hass) device_registry = mock_device_registry(hass) - vehicle_proxy = await create_vehicle_proxy(hass, vehicle_type) - - with patch( - "homeassistant.components.renault.RenaultHub.vehicles", - new_callable=PropertyMock, - return_value={ - vehicle_proxy.details.vin: vehicle_proxy, - }, - ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration(hass) + with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration_vehicle(hass, vehicle_type) await hass.async_block_till_done() mock_vehicle = MOCK_VEHICLES[vehicle_type] @@ -68,16 +59,8 @@ async def test_sensor_empty(hass, vehicle_type): entity_registry = mock_registry(hass) device_registry = mock_device_registry(hass) - vehicle_proxy = await create_vehicle_proxy_with_side_effect(hass, vehicle_type, {}) - - with patch( - "homeassistant.components.renault.RenaultHub.vehicles", - new_callable=PropertyMock, - return_value={ - vehicle_proxy.details.vin: vehicle_proxy, - }, - ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration(hass) + with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect(hass, vehicle_type, {}) await hass.async_block_till_done() mock_vehicle = MOCK_VEHICLES[vehicle_type] @@ -116,18 +99,10 @@ async def test_sensor_errors(hass, vehicle_type): "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", ) - vehicle_proxy = await create_vehicle_proxy_with_side_effect( - hass, vehicle_type, invalid_upstream_exception - ) - - with patch( - "homeassistant.components.renault.RenaultHub.vehicles", - new_callable=PropertyMock, - return_value={ - vehicle_proxy.details.vin: vehicle_proxy, - }, - ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration(hass) + with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) await hass.async_block_till_done() mock_vehicle = MOCK_VEHICLES[vehicle_type] @@ -165,18 +140,10 @@ async def test_sensor_access_denied(hass): "Access is denied for this resource", ) - vehicle_proxy = await create_vehicle_proxy_with_side_effect( - hass, "zoe_40", access_denied_exception - ) - - with patch( - "homeassistant.components.renault.RenaultHub.vehicles", - new_callable=PropertyMock, - return_value={ - vehicle_proxy.details.vin: vehicle_proxy, - }, - ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration(hass) + with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, "zoe_40", access_denied_exception + ) await hass.async_block_till_done() assert len(device_registry.devices) == 0 @@ -194,18 +161,10 @@ async def test_sensor_not_supported(hass): "This feature is not technically supported by this gateway", ) - vehicle_proxy = await create_vehicle_proxy_with_side_effect( - hass, "zoe_40", not_supported_exception - ) - - with patch( - "homeassistant.components.renault.RenaultHub.vehicles", - new_callable=PropertyMock, - return_value={ - vehicle_proxy.details.vin: vehicle_proxy, - }, - ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration(hass) + with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, "zoe_40", not_supported_exception + ) await hass.async_block_till_done() assert len(device_registry.devices) == 0 From d11c58dac83c375a4907ebc7f7c0e8be44a83fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 16 Aug 2021 12:56:10 +0200 Subject: [PATCH 396/903] Improve Tractive (#54532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tractive, code improve Signed-off-by: Daniel Hjelseth Høyer * Tractive, code improve Signed-off-by: Daniel Hjelseth Høyer * Tractive, code improve Signed-off-by: Daniel Hjelseth Høyer * Update homeassistant/components/tractive/config_flow.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/tractive/const.py Co-authored-by: Martin Hjelmare * Tractive, comments Signed-off-by: Daniel Hjelseth Høyer * Update homeassistant/components/tractive/config_flow.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/tractive/config_flow.py Co-authored-by: Martin Hjelmare * Tractive Signed-off-by: Daniel Hjelseth Høyer * Reauth Signed-off-by: Daniel Hjelseth Høyer * Reauth Signed-off-by: Daniel Hjelseth Høyer * add tests Signed-off-by: Daniel Hjelseth Høyer * add tests Signed-off-by: Daniel Hjelseth Høyer Co-authored-by: Martin Hjelmare --- homeassistant/components/tractive/__init__.py | 5 +- .../components/tractive/config_flow.py | 42 ++++- homeassistant/components/tractive/const.py | 6 +- .../components/tractive/device_tracker.py | 52 +++--- .../components/tractive/strings.json | 4 +- tests/components/tractive/test_config_flow.py | 164 ++++++++++++++++++ 6 files changed, 237 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index cb8eff1c8bb..78ee4c7ed97 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -9,7 +9,7 @@ import aiotractive from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -38,6 +38,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: creds = await client.authenticate() + except aiotractive.exceptions.UnauthorizedError as error: + await client.close() + raise ConfigEntryAuthFailed from error except aiotractive.exceptions.TractiveError as error: await client.close() raise ConfigEntryNotReady from error diff --git a/homeassistant/components/tractive/config_flow.py b/homeassistant/components/tractive/config_flow.py index 70ed9071c7b..4b1fc241110 100644 --- a/homeassistant/components/tractive/config_flow.py +++ b/homeassistant/components/tractive/config_flow.py @@ -17,7 +17,9 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema({CONF_EMAIL: str, CONF_PASSWORD: str}) +USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} +) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: @@ -47,9 +49,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) + return self.async_show_form(step_id="user", data_schema=USER_DATA_SCHEMA) errors = {} @@ -66,7 +66,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, _: dict[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + + errors = {} + + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + existing_entry = await self.async_set_unique_id(info["user_id"]) + if existing_entry: + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reauth_failed_existing") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=USER_DATA_SCHEMA, + errors=errors, ) diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index 5d265c489ff..7587fedfc4c 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -6,7 +6,7 @@ DOMAIN = "tractive" RECONNECT_INTERVAL = timedelta(seconds=10) -TRACKER_HARDWARE_STATUS_UPDATED = "tracker_hardware_status_updated" -TRACKER_POSITION_UPDATED = "tracker_position_updated" +TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated" +TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated" -SERVER_UNAVAILABLE = "tractive_server_unavailable" +SERVER_UNAVAILABLE = f"{DOMAIN}_server_unavailable" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 1365faa6419..82e22139f04 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -94,52 +94,52 @@ class TractiveDeviceTracker(TrackerEntity): """Return the battery level of the device.""" return self._battery_level + @callback + def _handle_hardware_status_update(self, event): + self._battery_level = event["battery_level"] + self._attr_available = True + self.async_write_ha_state() + + @callback + def _handle_position_update(self, event): + self._latitude = event["latitude"] + self._longitude = event["longitude"] + self._accuracy = event["accuracy"] + self._attr_available = True + self.async_write_ha_state() + + @callback + def _handle_server_unavailable(self): + self._latitude = None + self._longitude = None + self._accuracy = None + self._battery_level = None + self._attr_available = False + self.async_write_ha_state() + async def async_added_to_hass(self): """Handle entity which will be added.""" - @callback - def handle_hardware_status_update(event): - self._battery_level = event["battery_level"] - self._attr_available = True - self.async_write_ha_state() - self.async_on_remove( async_dispatcher_connect( self.hass, f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", - handle_hardware_status_update, + self._handle_hardware_status_update, ) ) - @callback - def handle_position_update(event): - self._latitude = event["latitude"] - self._longitude = event["longitude"] - self._accuracy = event["accuracy"] - self._attr_available = True - self.async_write_ha_state() - self.async_on_remove( async_dispatcher_connect( self.hass, f"{TRACKER_POSITION_UPDATED}-{self._tracker_id}", - handle_position_update, + self._handle_position_update, ) ) - @callback - def handle_server_unavailable(): - self._latitude = None - self._longitude = None - self._accuracy = None - self._battery_level = None - self._attr_available = False - self.async_write_ha_state() - self.async_on_remove( async_dispatcher_connect( self.hass, f"{SERVER_UNAVAILABLE}-{self._user_id}", - handle_server_unavailable, + self._handle_server_unavailable, ) ) diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json index 510b5697e56..9711eb41489 100644 --- a/homeassistant/components/tractive/strings.json +++ b/homeassistant/components/tractive/strings.json @@ -13,7 +13,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again." } } } \ No newline at end of file diff --git a/tests/components/tractive/test_config_flow.py b/tests/components/tractive/test_config_flow.py index 080aadb2bc7..7ccfdc63a34 100644 --- a/tests/components/tractive/test_config_flow.py +++ b/tests/components/tractive/test_config_flow.py @@ -7,6 +7,8 @@ from homeassistant import config_entries, setup from homeassistant.components.tractive.const import DOMAIN from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry + USER_INPUT = { "email": "test-email@example.com", "password": "test-password", @@ -76,3 +78,165 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} + + +async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + first_entry = MockConfigEntry( + domain="tractive", + data=USER_INPUT, + unique_id="USERID", + ) + first_entry.add_to_hass(hass) + + with patch("aiotractive.api.API.user_id", return_value="USERID"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_reauthentication(hass): + """Test Tractive reauthentication.""" + old_entry = MockConfigEntry( + domain="tractive", + data=USER_INPUT, + unique_id="USERID", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch("aiotractive.api.API.user_id", return_value="USERID"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + +async def test_reauthentication_failure(hass): + """Test Tractive reauthentication failure.""" + old_entry = MockConfigEntry( + domain="tractive", + data=USER_INPUT, + unique_id="USERID", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch( + "aiotractive.api.API.user_id", + side_effect=aiotractive.exceptions.UnauthorizedError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result["type"] == "form" + assert result2["errors"]["base"] == "invalid_auth" + + +async def test_reauthentication_unknown_failure(hass): + """Test Tractive reauthentication failure.""" + old_entry = MockConfigEntry( + domain="tractive", + data=USER_INPUT, + unique_id="USERID", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch( + "aiotractive.api.API.user_id", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result["type"] == "form" + assert result2["errors"]["base"] == "unknown" + + +async def test_reauthentication_failure_no_existing_entry(hass): + """Test Tractive reauthentication with no existing entry.""" + old_entry = MockConfigEntry( + domain="tractive", + data=USER_INPUT, + unique_id="USERID", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch("aiotractive.api.API.user_id", return_value="USERID_DIFFERENT"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_failed_existing" From 2e56f66518d7e63cf3e4847bcf5a6ce95679c698 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Mon, 16 Aug 2021 04:18:19 -0700 Subject: [PATCH 397/903] Bump adb-shell to 0.4.0 (#54575) --- homeassistant/components/androidtv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index d1e379435a0..00be4fa50c4 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -3,7 +3,7 @@ "name": "Android TV", "documentation": "https://www.home-assistant.io/integrations/androidtv", "requirements": [ - "adb-shell[async]==0.3.4", + "adb-shell[async]==0.4.0", "androidtv[async]==0.0.60", "pure-python-adb[async]==0.3.0.dev0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 3483f26245b..d6b4d721240 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -109,7 +109,7 @@ adafruit-circuitpython-mcp230xx==2.2.2 adax==0.1.1 # homeassistant.components.androidtv -adb-shell[async]==0.3.4 +adb-shell[async]==0.4.0 # homeassistant.components.alarmdecoder adext==0.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc0d8482421..aee9c071350 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -51,7 +51,7 @@ accuweather==0.2.0 adax==0.1.1 # homeassistant.components.androidtv -adb-shell[async]==0.3.4 +adb-shell[async]==0.4.0 # homeassistant.components.alarmdecoder adext==0.4.2 From a204d7f807a9fe80ca300ba37af4d1065f69026f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Aug 2021 13:49:04 +0200 Subject: [PATCH 398/903] Renault code quality improvements (#53680) --- homeassistant/components/renault/__init__.py | 4 +- .../components/renault/renault_hub.py | 39 +++++++--- .../components/renault/renault_vehicle.py | 4 +- homeassistant/components/renault/sensor.py | 77 ++++++++----------- tests/components/renault/__init__.py | 59 ++++++++++++-- tests/components/renault/const.py | 29 ++++++- tests/components/renault/test_init.py | 4 +- tests/components/renault/test_sensor.py | 7 +- tests/fixtures/renault/no_data.json | 7 ++ 9 files changed, 159 insertions(+), 71 deletions(-) create mode 100644 tests/fixtures/renault/no_data.json diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index 80433b2106e..d4c065e52ca 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {}) await renault_hub.async_initialise(config_entry) - hass.data[DOMAIN][config_entry.unique_id] = renault_hub + hass.data[DOMAIN][config_entry.entry_id] = renault_hub hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) @@ -40,6 +40,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) if unload_ok: - hass.data[DOMAIN].pop(config_entry.unique_id) + hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 51e356934bb..07770ad3769 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -1,10 +1,12 @@ """Proxy to handle account communication with Renault servers.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging 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 @@ -23,7 +25,6 @@ class RenaultHub: def __init__(self, hass: HomeAssistant, locale: str) -> None: """Initialise proxy.""" - LOGGER.debug("Creating RenaultHub") self._hass = hass self._client = RenaultClient( websession=async_get_clientsession(self._hass), locale=locale @@ -49,17 +50,33 @@ class RenaultHub: self._account = await self._client.get_api_account(account_id) vehicles = await self._account.get_vehicles() if vehicles.vehicleLinks: - for vehicle_link in vehicles.vehicleLinks: - if vehicle_link.vin and vehicle_link.vehicleDetails: - # Generate vehicle proxy - vehicle = RenaultVehicleProxy( - hass=self._hass, - vehicle=await self._account.get_api_vehicle(vehicle_link.vin), - details=vehicle_link.vehicleDetails, - scan_interval=scan_interval, + await asyncio.gather( + *( + self.async_initialise_vehicle( + vehicle_link, self._account, scan_interval ) - await vehicle.async_initialise() - self._vehicles[vehicle_link.vin] = vehicle + for vehicle_link in vehicles.vehicleLinks + ) + ) + + async def async_initialise_vehicle( + self, + vehicle_link: KamereonVehiclesLink, + renault_account: RenaultAccount, + scan_interval: timedelta, + ) -> None: + """Set up proxy.""" + assert vehicle_link.vin is not None + assert vehicle_link.vehicleDetails is not None + # Generate vehicle proxy + vehicle = RenaultVehicleProxy( + hass=self._hass, + vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), + details=vehicle_link.vehicleDetails, + scan_interval=scan_interval, + ) + await vehicle.async_initialise() + self._vehicles[vehicle_link.vin] = vehicle async def get_account_ids(self) -> list[str]: """Get Kamereon account ids.""" diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index d3f6b6e48be..8d4cfea53ee 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -115,7 +115,7 @@ class RenaultVehicleProxy: coordinator = self.coordinators[key] if coordinator.not_supported: # Remove endpoint as it is not supported for this vehicle. - LOGGER.error( + LOGGER.warning( "Ignoring endpoint %s as it is not supported for this vehicle: %s", coordinator.name, coordinator.last_exception, @@ -123,7 +123,7 @@ class RenaultVehicleProxy: del self.coordinators[key] elif coordinator.access_denied: # Remove endpoint as it is denied for this vehicle. - LOGGER.error( + LOGGER.warning( "Ignoring endpoint %s as it is denied for this vehicle: %s", coordinator.name, coordinator.last_exception, diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 51f38d6a4d6..7ef11fb2afc 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -1,14 +1,14 @@ """Support for Renault sensors.""" from __future__ import annotations -from typing import Any - from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, POWER_KILO_WATT, @@ -18,8 +18,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.util import slugify from .const import ( DEVICE_CLASS_CHARGE_MODE, @@ -46,20 +44,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" - proxy: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] - entities = await get_entities(proxy) + proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] + entities = get_entities(proxy) async_add_entities(entities) -async def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: +def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: """Create Renault entities for all vehicles.""" entities = [] for vehicle in proxy.vehicles.values(): - entities.extend(await get_vehicle_entities(vehicle)) + entities.extend(get_vehicle_entities(vehicle)) return entities -async def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultDataEntity]: +def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultDataEntity]: """Create Renault entities for single vehicle.""" entities: list[RenaultDataEntity] = [] if "cockpit" in vehicle.coordinators: @@ -78,6 +76,9 @@ async def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultData entities.append(RenaultChargingPowerSensor(vehicle, "Charging Power")) entities.append(RenaultPlugStateSensor(vehicle, "Plug State")) entities.append(RenaultBatteryAutonomySensor(vehicle, "Battery Autonomy")) + entities.append( + RenaultBatteryAvailableEnergySensor(vehicle, "Battery Available Energy") + ) entities.append(RenaultBatteryTemperatureSensor(vehicle, "Battery Temperature")) if "charge_mode" in vehicle.coordinators: entities.append(RenaultChargeModeSensor(vehicle, "Charge Mode")) @@ -96,6 +97,18 @@ class RenaultBatteryAutonomySensor(RenaultBatteryDataEntity, SensorEntity): return self.data.batteryAutonomy if self.data else None +class RenaultBatteryAvailableEnergySensor(RenaultBatteryDataEntity, SensorEntity): + """Battery available energy sensor.""" + + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + + @property + def native_value(self) -> float | None: + """Return the state of this entity.""" + return self.data.batteryAvailableEnergy if self.data else None + + class RenaultBatteryLevelSensor(RenaultBatteryDataEntity, SensorEntity): """Battery Level sensor.""" @@ -107,22 +120,6 @@ class RenaultBatteryLevelSensor(RenaultBatteryDataEntity, SensorEntity): """Return the state of this entity.""" return self.data.batteryLevel if self.data else None - @property - def icon(self) -> str: - """Icon handling.""" - return icon_for_battery_level( - battery_level=self.state, charging=self.is_charging - ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of this entity.""" - attrs = super().extra_state_attributes - attrs[ATTR_BATTERY_AVAILABLE_ENERGY] = ( - self.data.batteryAvailableEnergy if self.data else None - ) - return attrs - class RenaultBatteryTemperatureSensor(RenaultBatteryDataEntity, SensorEntity): """Battery Temperature sensor.""" @@ -163,7 +160,7 @@ class RenaultChargeStateSensor(RenaultBatteryDataEntity, SensorEntity): def native_value(self) -> str | None: """Return the state of this entity.""" charging_status = self.data.get_charging_status() if self.data else None - return slugify(charging_status.name) if charging_status is not None else None + return charging_status.name.lower() if charging_status is not None else None @property def icon(self) -> str: @@ -186,7 +183,7 @@ class RenaultChargingRemainingTimeSensor(RenaultBatteryDataEntity, SensorEntity) class RenaultChargingPowerSensor(RenaultBatteryDataEntity, SensorEntity): """Charging Power sensor.""" - _attr_device_class = DEVICE_CLASS_ENERGY + _attr_device_class = DEVICE_CLASS_POWER _attr_native_unit_of_measurement = POWER_KILO_WATT @property @@ -209,11 +206,9 @@ class RenaultFuelAutonomySensor(RenaultCockpitDataEntity, SensorEntity): @property def native_value(self) -> int | None: """Return the state of this entity.""" - return ( - round(self.data.fuelAutonomy) - if self.data and self.data.fuelAutonomy is not None - else None - ) + if not self.data or self.data.fuelAutonomy is None: + return None + return round(self.data.fuelAutonomy) class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): @@ -225,11 +220,9 @@ class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): @property def native_value(self) -> int | None: """Return the state of this entity.""" - return ( - round(self.data.fuelQuantity) - if self.data and self.data.fuelQuantity is not None - else None - ) + if not self.data or self.data.fuelQuantity is None: + return None + return round(self.data.fuelQuantity) class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): @@ -241,11 +234,9 @@ class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): @property def native_value(self) -> int | None: """Return the state of this entity.""" - return ( - round(self.data.totalMileage) - if self.data and self.data.totalMileage is not None - else None - ) + if not self.data or self.data.totalMileage is None: + return None + return round(self.data.totalMileage) class RenaultOutsideTemperatureSensor(RenaultHVACDataEntity, SensorEntity): @@ -269,7 +260,7 @@ class RenaultPlugStateSensor(RenaultBatteryDataEntity, SensorEntity): def native_value(self) -> str | None: """Return the state of this entity.""" plug_status = self.data.get_plug_status() if self.data else None - return slugify(plug_status.name) if plug_status is not None else None + return plug_status.name.lower() if plug_status is not None else None @property def icon(self) -> str: diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index fcd190fe98d..da72da05d5d 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -31,27 +31,27 @@ def get_mock_config_entry(): def get_fixtures(vehicle_type: str) -> dict[str, Any]: """Create a vehicle proxy for testing.""" - mock_vehicle = MOCK_VEHICLES[vehicle_type] + mock_vehicle = MOCK_VEHICLES.get(vehicle_type, {"endpoints": {}}) return { "battery_status": schemas.KamereonVehicleDataResponseSchema.loads( load_fixture(f"renault/{mock_vehicle['endpoints']['battery_status']}") if "battery_status" in mock_vehicle["endpoints"] - else "{}" + else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleBatteryStatusDataSchema), "charge_mode": schemas.KamereonVehicleDataResponseSchema.loads( load_fixture(f"renault/{mock_vehicle['endpoints']['charge_mode']}") if "charge_mode" in mock_vehicle["endpoints"] - else "{}" + else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleChargeModeDataSchema), "cockpit": schemas.KamereonVehicleDataResponseSchema.loads( load_fixture(f"renault/{mock_vehicle['endpoints']['cockpit']}") if "cockpit" in mock_vehicle["endpoints"] - else "{}" + else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleCockpitDataSchema), "hvac_status": schemas.KamereonVehicleDataResponseSchema.loads( load_fixture(f"renault/{mock_vehicle['endpoints']['hvac_status']}") if "hvac_status" in mock_vehicle["endpoints"] - else "{}" + else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleHvacStatusDataSchema), } @@ -123,6 +123,55 @@ async def setup_renault_integration_vehicle(hass: HomeAssistant, vehicle_type: s return config_entry +async def setup_renault_integration_vehicle_with_no_data( + hass: HomeAssistant, vehicle_type: str +): + """Create the Renault integration.""" + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) + + renault_account = RenaultAccount( + config_entry.unique_id, + websession=aiohttp_client.async_get_clientsession(hass), + ) + mock_vehicle = MOCK_VEHICLES[vehicle_type] + mock_fixtures = get_fixtures("") + + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ), patch( + "renault_api.renault_account.RenaultAccount.get_vehicles", + return_value=( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.supports_endpoint", + side_effect=mock_vehicle["endpoints_available"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.has_contract_for_endpoint", + return_value=True, + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", + return_value=mock_fixtures["battery_status"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", + return_value=mock_fixtures["charge_mode"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", + return_value=mock_fixtures["cockpit"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", + return_value=mock_fixtures["hvac_status"], + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + async def setup_renault_integration_vehicle_with_side_effect( hass: HomeAssistant, vehicle_type: str, side_effect: Any ): diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index be2adafd7be..8c3d6e9f98f 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -13,7 +13,9 @@ from homeassistant.const import ( CONF_USERNAME, DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, POWER_KILO_WATT, @@ -59,6 +61,13 @@ MOCK_VEHICLES = { "result": "141", "unit": LENGTH_KILOMETERS, }, + { + "entity_id": "sensor.battery_available_energy", + "unique_id": "vf1aaaaa555777999_battery_available_energy", + "result": "31", + "unit": ENERGY_KILO_WATT_HOUR, + "class": DEVICE_CLASS_ENERGY, + }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777999_battery_level", @@ -90,7 +99,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_charging_power", "result": "0.027", "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_ENERGY, + "class": DEVICE_CLASS_POWER, }, { "entity_id": "sensor.charging_remaining_time", @@ -145,6 +154,13 @@ MOCK_VEHICLES = { "result": "128", "unit": LENGTH_KILOMETERS, }, + { + "entity_id": "sensor.battery_available_energy", + "unique_id": "vf1aaaaa555777999_battery_available_energy", + "result": "0", + "unit": ENERGY_KILO_WATT_HOUR, + "class": DEVICE_CLASS_ENERGY, + }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777999_battery_level", @@ -176,7 +192,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_charging_power", "result": STATE_UNKNOWN, "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_ENERGY, + "class": DEVICE_CLASS_POWER, }, { "entity_id": "sensor.charging_remaining_time", @@ -224,6 +240,13 @@ MOCK_VEHICLES = { "result": "141", "unit": LENGTH_KILOMETERS, }, + { + "entity_id": "sensor.battery_available_energy", + "unique_id": "vf1aaaaa555777123_battery_available_energy", + "result": "31", + "unit": ENERGY_KILO_WATT_HOUR, + "class": DEVICE_CLASS_ENERGY, + }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777123_battery_level", @@ -255,7 +278,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777123_charging_power", "result": "27.0", "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_ENERGY, + "class": DEVICE_CLASS_POWER, }, { "entity_id": "sensor.charging_remaining_time", diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index fab5eff8f0c..37a67151972 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -17,13 +17,13 @@ async def test_setup_unload_entry(hass): assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.unique_id in hass.data[DOMAIN] + assert config_entry.entry_id in hass.data[DOMAIN] # Unload the entry and verify that the data has been removed await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED - assert config_entry.unique_id not in hass.data[DOMAIN] + assert config_entry.entry_id not in hass.data[DOMAIN] async def test_setup_entry_bad_password(hass): diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 01db9ac8bba..42a75012b38 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -5,11 +5,12 @@ import pytest from renault_api.kamereon import exceptions from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.setup import async_setup_component from . import ( setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, setup_renault_integration_vehicle_with_side_effect, ) from .const import MOCK_VEHICLES @@ -60,7 +61,7 @@ async def test_sensor_empty(hass, vehicle_type): device_registry = mock_device_registry(hass) with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration_vehicle_with_side_effect(hass, vehicle_type, {}) + await setup_renault_integration_vehicle_with_no_data(hass, vehicle_type) await hass.async_block_till_done() mock_vehicle = MOCK_VEHICLES[vehicle_type] @@ -84,7 +85,7 @@ async def test_sensor_empty(hass, vehicle_type): assert registry_entry.unit_of_measurement == expected_entity.get("unit") assert registry_entry.device_class == expected_entity.get("class") state = hass.states.get(entity_id) - assert state.state == STATE_UNAVAILABLE + assert state.state == STATE_UNKNOWN @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) diff --git a/tests/fixtures/renault/no_data.json b/tests/fixtures/renault/no_data.json new file mode 100644 index 00000000000..7b78844ca99 --- /dev/null +++ b/tests/fixtures/renault/no_data.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": {} + } +} From a7918e90ab6a913a6cb2e868b4614d0fe0358f9c Mon Sep 17 00:00:00 2001 From: Dylan Gore Date: Mon, 16 Aug 2021 12:52:40 +0100 Subject: [PATCH 399/903] Update PyMetEireann to 2021.8.0 (#54693) --- homeassistant/components/met_eireann/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/met_eireann/manifest.json b/homeassistant/components/met_eireann/manifest.json index 9d2e1857689..36cc905eabf 100644 --- a/homeassistant/components/met_eireann/manifest.json +++ b/homeassistant/components/met_eireann/manifest.json @@ -3,7 +3,7 @@ "name": "Met Éireann", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/met_eireann", - "requirements": ["pyMetEireann==0.2"], + "requirements": ["pyMetEireann==2021.8.0"], "codeowners": ["@DylanGore"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index d6b4d721240..db0885255bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1275,7 +1275,7 @@ pyControl4==0.0.6 pyHS100==0.3.5.2 # homeassistant.components.met_eireann -pyMetEireann==0.2 +pyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aee9c071350..e2e8f737214 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -717,7 +717,7 @@ pyControl4==0.0.6 pyHS100==0.3.5.2 # homeassistant.components.met_eireann -pyMetEireann==0.2 +pyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air From 2bcfae6998c972b2648cc843eb3865927a032ccd Mon Sep 17 00:00:00 2001 From: serenewaffles Date: Mon, 16 Aug 2021 09:23:48 -0400 Subject: [PATCH 400/903] Fix typo in Todoist service description (#54662) --- homeassistant/components/todoist/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/services.yaml b/homeassistant/components/todoist/services.yaml index 85e975e94ff..d0b680375f9 100644 --- a/homeassistant/components/todoist/services.yaml +++ b/homeassistant/components/todoist/services.yaml @@ -30,7 +30,7 @@ new_task: min: 1 max: 4 due_date_string: - name: Dure date string + name: Due date string description: The day this task is due, in natural language. example: Tomorrow selector: From 3e93215a1fd819ffb6620de57de92a7ad7b2cb9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Bogda=C5=82?= Date: Mon, 16 Aug 2021 15:49:12 +0200 Subject: [PATCH 401/903] Fix event type names for non-specified Traccar events (#54561) * Fix event type name * Extend list of types only when all_events is specified * Remove flake8 warnings --- .../components/traccar/device_tracker.py | 44 ++++++------- .../components/traccar/test_device_tracker.py | 62 +++++++++++++++++++ 2 files changed, 85 insertions(+), 21 deletions(-) create mode 100644 tests/components/traccar/test_device_tracker.py diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 5ad5879f31b..16cd9ba94e5 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -74,6 +74,26 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL +EVENTS = [ + EVENT_DEVICE_MOVING, + EVENT_COMMAND_RESULT, + EVENT_DEVICE_FUEL_DROP, + EVENT_GEOFENCE_ENTER, + EVENT_DEVICE_OFFLINE, + EVENT_DRIVER_CHANGED, + EVENT_GEOFENCE_EXIT, + EVENT_DEVICE_OVERSPEED, + EVENT_DEVICE_ONLINE, + EVENT_DEVICE_STOPPED, + EVENT_MAINTENANCE, + EVENT_ALARM, + EVENT_TEXT_MESSAGE, + EVENT_DEVICE_UNKNOWN, + EVENT_IGNITION_OFF, + EVENT_IGNITION_ON, + EVENT_ALL_EVENTS, +] + PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_PASSWORD): cv.string, @@ -91,27 +111,7 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ), vol.Optional(CONF_EVENT, default=[]): vol.All( cv.ensure_list, - [ - vol.Any( - EVENT_DEVICE_MOVING, - EVENT_COMMAND_RESULT, - EVENT_DEVICE_FUEL_DROP, - EVENT_GEOFENCE_ENTER, - EVENT_DEVICE_OFFLINE, - EVENT_DRIVER_CHANGED, - EVENT_GEOFENCE_EXIT, - EVENT_DEVICE_OVERSPEED, - EVENT_DEVICE_ONLINE, - EVENT_DEVICE_STOPPED, - EVENT_MAINTENANCE, - EVENT_ALARM, - EVENT_TEXT_MESSAGE, - EVENT_DEVICE_UNKNOWN, - EVENT_IGNITION_OFF, - EVENT_IGNITION_ON, - EVENT_ALL_EVENTS, - ) - ], + [vol.In(EVENTS)], ), } ) @@ -203,6 +203,8 @@ class TraccarScanner: ): """Initialize.""" + if EVENT_ALL_EVENTS in event_types: + event_types = EVENTS self._event_types = {camelcase(evt): evt for evt in event_types} self._custom_attributes = custom_attributes self._scan_interval = scan_interval diff --git a/tests/components/traccar/test_device_tracker.py b/tests/components/traccar/test_device_tracker.py new file mode 100644 index 00000000000..4e2f5e0ff09 --- /dev/null +++ b/tests/components/traccar/test_device_tracker.py @@ -0,0 +1,62 @@ +"""The tests for the Traccar device tracker platform.""" +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from homeassistant.components.device_tracker.const import DOMAIN +from homeassistant.components.traccar.device_tracker import ( + PLATFORM_SCHEMA as TRACCAR_PLATFORM_SCHEMA, +) +from homeassistant.const import ( + CONF_EVENT, + CONF_HOST, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_USERNAME, +) +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + + +async def test_import_events_catch_all(hass): + """Test importing all events and firing them in HA using their event types.""" + conf_dict = { + DOMAIN: TRACCAR_PLATFORM_SCHEMA( + { + CONF_PLATFORM: "traccar", + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_user", + CONF_PASSWORD: "fake_pass", + CONF_EVENT: ["all_events"], + } + ) + } + + device = {"id": 1, "name": "abc123"} + api_mock = AsyncMock() + api_mock.devices = [device] + api_mock.get_events.return_value = [ + { + "deviceId": device["id"], + "type": "ignitionOn", + "serverTime": datetime.utcnow(), + "attributes": {}, + }, + { + "deviceId": device["id"], + "type": "ignitionOff", + "serverTime": datetime.utcnow(), + "attributes": {}, + }, + ] + + events_ignition_on = async_capture_events(hass, "traccar_ignition_on") + events_ignition_off = async_capture_events(hass, "traccar_ignition_off") + + with patch( + "homeassistant.components.traccar.device_tracker.API", return_value=api_mock + ): + assert await async_setup_component(hass, DOMAIN, conf_dict) + + assert len(events_ignition_on) == 1 + assert len(events_ignition_off) == 1 From 979165669c017423535ba9ede1d5d6d943cb43f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 16 Aug 2021 15:57:23 +0200 Subject: [PATCH 402/903] Update mill to use state class total (#54581) --- homeassistant/components/mill/sensor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index a8b4554139f..5241f95abdb 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -2,7 +2,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, SensorEntity, ) from homeassistant.const import ENERGY_KILO_WATT_HOUR @@ -27,17 +27,18 @@ async def async_setup_entry(hass, entry, async_add_entities): class MillHeaterEnergySensor(SensorEntity): """Representation of a Mill Sensor device.""" + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_state_class = STATE_CLASS_TOTAL + def __init__(self, heater, mill_data_connection, sensor_type): """Initialize the sensor.""" self._id = heater.device_id self._conn = mill_data_connection self._sensor_type = sensor_type - self._attr_device_class = DEVICE_CLASS_ENERGY self._attr_name = f"{heater.name} {sensor_type.replace('_', ' ')}" self._attr_unique_id = f"{heater.device_id}_{sensor_type}" - self._attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - self._attr_state_class = STATE_CLASS_MEASUREMENT self._attr_device_info = { "identifiers": {(DOMAIN, heater.device_id)}, "name": self.name, From 99a62799ae68cd71f77cb36c3b4c2a5cac678286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 16 Aug 2021 16:07:11 +0200 Subject: [PATCH 403/903] Allow non-admin users to call history/list_statistic_ids (#54698) --- homeassistant/components/history/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index a1e0fd45167..518e555c280 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -167,7 +167,6 @@ async def ws_get_statistics_during_period( vol.Optional("statistic_type"): vol.Any("sum", "mean"), } ) -@websocket_api.require_admin @websocket_api.async_response async def ws_get_list_statistic_ids( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict From 75a2ac08080378ad1ab8495ba5f13df2495a0e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 16 Aug 2021 16:10:07 +0200 Subject: [PATCH 404/903] Update melcloud to use state class total increasing (#54607) --- homeassistant/components/melcloud/sensor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 12029127b84..608c3547724 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -11,11 +11,11 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) from homeassistant.const import ENERGY_KILO_WATT_HOUR, TEMP_CELSIUS -from homeassistant.util import dt as dt_util from . import MelCloudDevice from .const import DOMAIN @@ -150,10 +150,11 @@ class MelDeviceSensor(SensorEntity): self._attr_name = f"{api.name} {description.name}" self._attr_unique_id = f"{api.device.serial}-{api.device.mac}-{description.key}" - self._attr_state_class = STATE_CLASS_MEASUREMENT if description.device_class == DEVICE_CLASS_ENERGY: - self._attr_last_reset = dt_util.utc_from_timestamp(0) + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + else: + self._attr_state_class = STATE_CLASS_MEASUREMENT @property def native_value(self): From 2eba63338252f49d059ac7f70df779f908fa9b22 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 16 Aug 2021 16:13:07 +0200 Subject: [PATCH 405/903] Fix 'in' comparisons vesync light (#54614) --- homeassistant/components/vesync/light.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index b747c10ee4e..bd187f2f590 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -46,7 +46,7 @@ def _async_setup_entities(devices, async_add_entities): for dev in devices: if DEV_TYPE_TO_HA.get(dev.device_type) in ("walldimmer", "bulb-dimmable"): entities.append(VeSyncDimmableLightHA(dev)) - elif DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white"): + elif DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white",): entities.append(VeSyncTunableWhiteLightHA(dev)) else: _LOGGER.debug( @@ -82,7 +82,7 @@ class VeSyncBaseLight(VeSyncDevice, LightEntity): """Turn the device on.""" attribute_adjustment_only = False # set white temperature - if self.color_mode in (COLOR_MODE_COLOR_TEMP) and ATTR_COLOR_TEMP in kwargs: + if self.color_mode in (COLOR_MODE_COLOR_TEMP,) and ATTR_COLOR_TEMP in kwargs: # get white temperature from HA data color_temp = int(kwargs[ATTR_COLOR_TEMP]) # ensure value between min-max supported Mireds From a892605a90395c1789ea2da361b20b3fcef5365a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 16 Aug 2021 16:15:42 +0200 Subject: [PATCH 406/903] Bump pytautulli (#54594) --- .../components/tautulli/manifest.json | 2 +- homeassistant/components/tautulli/sensor.py | 97 +++++++++++++------ requirements_all.txt | 2 +- 3 files changed, 69 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/tautulli/manifest.json b/homeassistant/components/tautulli/manifest.json index cb2e38ebd6d..d413e477397 100644 --- a/homeassistant/components/tautulli/manifest.json +++ b/homeassistant/components/tautulli/manifest.json @@ -2,7 +2,7 @@ "domain": "tautulli", "name": "Tautulli", "documentation": "https://www.home-assistant.io/integrations/tautulli", - "requirements": ["pytautulli==0.5.0"], + "requirements": ["pytautulli==21.8.1"], "codeowners": ["@ludeeus"], "iot_class": "local_polling" } diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index 67df02cb15d..16b58b206aa 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -1,7 +1,7 @@ """A platform which allows you to get information from Tautulli.""" from datetime import timedelta -from pytautulli import Tautulli +from pytautulli import PyTautulli import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -60,10 +60,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= session = async_get_clientsession(hass, verify_ssl) tautulli = TautulliData( - Tautulli(host, port, api_key, hass.loop, session, use_ssl, path) + PyTautulli( + api_token=api_key, + hostname=host, + session=session, + verify_ssl=verify_ssl, + port=port, + ssl=use_ssl, + base_api_path=path, + ) ) - if not await tautulli.test_connection(): + await tautulli.async_update() + if not tautulli.activity or not tautulli.home_stats or not tautulli.users: raise PlatformNotReady sensor = [TautulliSensor(tautulli, name, monitored_conditions, user)] @@ -88,25 +97,52 @@ class TautulliSensor(SensorEntity): async def async_update(self): """Get the latest data from the Tautulli API.""" await self.tautulli.async_update() - self.home = self.tautulli.api.home_data - self.sessions = self.tautulli.api.session_data - self._attributes["Top Movie"] = self.home.get("movie") - self._attributes["Top TV Show"] = self.home.get("tv") - self._attributes["Top User"] = self.home.get("user") - for key in self.sessions: - if "sessions" not in key: - self._attributes[key] = self.sessions[key] - for user in self.tautulli.api.users: - if self.usernames is None or user in self.usernames: - userdata = self.tautulli.api.user_data - self._attributes[user] = {} - self._attributes[user]["Activity"] = userdata[user]["Activity"] - if self.monitored_conditions: - for key in self.monitored_conditions: - try: - self._attributes[user][key] = userdata[user][key] - except (KeyError, TypeError): - self._attributes[user][key] = "" + if ( + not self.tautulli.activity + or not self.tautulli.home_stats + or not self.tautulli.users + ): + return + + self._attributes = { + "stream_count": self.tautulli.activity.stream_count, + "stream_count_direct_play": self.tautulli.activity.stream_count_direct_play, + "stream_count_direct_stream": self.tautulli.activity.stream_count_direct_stream, + "stream_count_transcode": self.tautulli.activity.stream_count_transcode, + "total_bandwidth": self.tautulli.activity.total_bandwidth, + "lan_bandwidth": self.tautulli.activity.lan_bandwidth, + "wan_bandwidth": self.tautulli.activity.wan_bandwidth, + } + + for stat in self.tautulli.home_stats: + if stat.stat_id == "top_movies": + self._attributes["Top Movie"] = ( + stat.rows[0].title if stat.rows else None + ) + elif stat.stat_id == "top_tv": + self._attributes["Top TV Show"] = ( + stat.rows[0].title if stat.rows else None + ) + elif stat.stat_id == "top_users": + self._attributes["Top User"] = stat.rows[0].user if stat.rows else None + + for user in self.tautulli.users: + if ( + self.usernames + and user.username not in self.usernames + or user.username == "Local" + ): + continue + self._attributes.setdefault(user.username, {})["Activity"] = None + + for session in self.tautulli.activity.sessions: + if not self._attributes.get(session.username): + continue + + self._attributes[session.username]["Activity"] = session.state + if self.monitored_conditions: + for key in self.monitored_conditions: + self._attributes[session.username][key] = getattr(session, key) @property def name(self): @@ -116,7 +152,9 @@ class TautulliSensor(SensorEntity): @property def native_value(self): """Return the state of the sensor.""" - return self.sessions.get("stream_count") + if not self.tautulli.activity: + return 0 + return self.tautulli.activity.stream_count @property def icon(self): @@ -140,14 +178,13 @@ class TautulliData: def __init__(self, api): """Initialize the data object.""" self.api = api + self.activity = None + self.home_stats = None + self.users = None @Throttle(TIME_BETWEEN_UPDATES) async def async_update(self): """Get the latest data from Tautulli.""" - await self.api.get_data() - - async def test_connection(self): - """Test connection to Tautulli.""" - await self.api.test_connection() - connection_status = self.api.connection - return connection_status + self.activity = await self.api.async_get_activity() + self.home_stats = await self.api.async_get_home_stats() + self.users = await self.api.async_get_users() diff --git a/requirements_all.txt b/requirements_all.txt index db0885255bc..9203b216eb7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1803,7 +1803,7 @@ pysyncthru==0.7.3 pytankerkoenig==0.0.6 # homeassistant.components.tautulli -pytautulli==0.5.0 +pytautulli==21.8.1 # homeassistant.components.tfiac pytfiac==0.4 From 844000556f1493e3c6d82001e8883d9109a0ffa2 Mon Sep 17 00:00:00 2001 From: Oxan van Leeuwen Date: Mon, 16 Aug 2021 16:16:36 +0200 Subject: [PATCH 407/903] Set correct ESPHome color mode when setting color temperature (#54596) --- homeassistant/components/esphome/light.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index c6cf9742082..73339769121 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -157,7 +157,11 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: data["color_temperature"] = color_temp if self._supports_color_mode: - data["color_mode"] = LightColorMode.COLOR_TEMPERATURE + supported_modes = self._native_supported_color_modes + if LightColorMode.COLOR_TEMPERATURE in supported_modes: + data["color_mode"] = LightColorMode.COLOR_TEMPERATURE + elif LightColorMode.COLD_WARM_WHITE in supported_modes: + data["color_mode"] = LightColorMode.COLD_WARM_WHITE if (effect := kwargs.get(ATTR_EFFECT)) is not None: data["effect"] = effect From c5d88d3e2f36fc677e589d802035c16e54f5bddd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 16 Aug 2021 16:19:41 +0200 Subject: [PATCH 408/903] Remove last_reset attribute from dsmr_reader sensors (#54700) --- .../components/dsmr_reader/definitions.py | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index a5fc2b8147a..6edf2972aa4 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -6,6 +6,7 @@ from typing import Callable from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntityDescription, ) from homeassistant.const import ( @@ -21,7 +22,6 @@ from homeassistant.const import ( POWER_KILO_WATT, VOLUME_CUBIC_METERS, ) -from homeassistant.util import dt as dt_util def dsmr_transform(value): @@ -51,32 +51,28 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Low tariff usage", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_returned_1", name="Low tariff returned", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_delivered_2", name="High tariff usage", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_returned_2", name="High tariff returned", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_currently_delivered", @@ -146,8 +142,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, icon="mdi:fire", native_unit_of_measurement=VOLUME_CUBIC_METERS, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_voltage_l1", @@ -208,8 +203,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Gas usage", icon="mdi:fire", native_unit_of_measurement=VOLUME_CUBIC_METERS, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", @@ -229,48 +223,42 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Low tariff usage", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2", name="High tariff usage", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1_returned", name="Low tariff return", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2_returned", name="High tariff return", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_merged", name="Power usage total", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_returned_merged", name="Power return total", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1_cost", From 512a474e934798c0bd2bdf5860bf5a56956045e5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 Aug 2021 07:28:26 -0700 Subject: [PATCH 409/903] Allow specifying discovery without a config flow (#54677) --- .../components/rainforest_eagle/manifest.json | 7 +++- script/hassfest/config_flow.py | 37 +------------------ script/hassfest/dhcp.py | 2 +- script/hassfest/model.py | 5 +++ script/hassfest/mqtt.py | 2 +- script/hassfest/ssdp.py | 2 +- script/hassfest/zeroconf.py | 2 +- 7 files changed, 16 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/manifest.json b/homeassistant/components/rainforest_eagle/manifest.json index fd28e5b0994..4b6268fd59a 100644 --- a/homeassistant/components/rainforest_eagle/manifest.json +++ b/homeassistant/components/rainforest_eagle/manifest.json @@ -4,5 +4,10 @@ "documentation": "https://www.home-assistant.io/integrations/rainforest_eagle", "requirements": ["eagle200_reader==0.2.4", "uEagle==0.0.2"], "codeowners": ["@gtdiehl", "@jcalbert"], - "iot_class": "local_polling" + "iot_class": "local_polling", + "dhcp": [ + { + "macaddress": "D8D5B9*" + } + ] } diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index e4d1be7bc46..8e0f53fd736 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -29,31 +29,6 @@ def validate_integration(config: Config, integration: Integration): "config_flow", "Config flows need to be defined in the file config_flow.py", ) - if integration.manifest.get("homekit"): - integration.add_error( - "config_flow", - "HomeKit information in a manifest requires a config flow to exist", - ) - if integration.manifest.get("mqtt"): - integration.add_error( - "config_flow", - "MQTT information in a manifest requires a config flow to exist", - ) - if integration.manifest.get("ssdp"): - integration.add_error( - "config_flow", - "SSDP information in a manifest requires a config flow to exist", - ) - if integration.manifest.get("zeroconf"): - integration.add_error( - "config_flow", - "Zeroconf information in a manifest requires a config flow to exist", - ) - if integration.manifest.get("dhcp"): - integration.add_error( - "config_flow", - "DHCP information in a manifest requires a config flow to exist", - ) return config_flow = config_flow_file.read_text() @@ -98,17 +73,7 @@ def generate_and_validate(integrations: dict[str, Integration], config: Config): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: - continue - - if not ( - integration.manifest.get("config_flow") - or integration.manifest.get("homekit") - or integration.manifest.get("mqtt") - or integration.manifest.get("ssdp") - or integration.manifest.get("zeroconf") - or integration.manifest.get("dhcp") - ): + if not integration.manifest or not integration.config_flow: continue validate_integration(config, integration) diff --git a/script/hassfest/dhcp.py b/script/hassfest/dhcp.py index a3abe80063e..c746c64e46f 100644 --- a/script/hassfest/dhcp.py +++ b/script/hassfest/dhcp.py @@ -24,7 +24,7 @@ def generate_and_validate(integrations: list[dict[str, str]]): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: + if not integration.manifest or not integration.config_flow: continue match_types = integration.manifest.get("dhcp", []) diff --git a/script/hassfest/model.py b/script/hassfest/model.py index b20df6ea42f..69810686cc1 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -96,6 +96,11 @@ class Integration: """Return quality scale of the integration.""" return self.manifest.get("quality_scale") + @property + def config_flow(self) -> str: + """Return if the integration has a config flow.""" + return self.manifest.get("config_flow") + @property def requirements(self) -> list[str]: """List of requirements.""" diff --git a/script/hassfest/mqtt.py b/script/hassfest/mqtt.py index 718df4ac827..f325518d7b9 100644 --- a/script/hassfest/mqtt.py +++ b/script/hassfest/mqtt.py @@ -26,7 +26,7 @@ def generate_and_validate(integrations: dict[str, Integration]): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: + if not integration.manifest or not integration.config_flow: continue mqtt = integration.manifest.get("mqtt") diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index c71d5432adf..0611f9a2225 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -31,7 +31,7 @@ def generate_and_validate(integrations: dict[str, Integration]): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: + if not integration.manifest or not integration.config_flow: continue ssdp = integration.manifest.get("ssdp") diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 907c6aaceff..4ce4896952e 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -28,7 +28,7 @@ def generate_and_validate(integrations: dict[str, Integration]): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: + if not integration.manifest or not integration.config_flow: continue service_types = integration.manifest.get("zeroconf", []) From 494fd21351e468203529222fce665efe27c4d1e7 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 16 Aug 2021 16:34:22 +0200 Subject: [PATCH 410/903] Refactor mysensors sensor description (#54522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- homeassistant/components/mysensors/sensor.py | 270 +++++++++++-------- 1 file changed, 165 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 396d0e2519b..68fdf2a21b2 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -1,7 +1,7 @@ """Support for MySensors sensors.""" from __future__ import annotations -from datetime import datetime +from typing import Any from awesomeversion import AwesomeVersion @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( DOMAIN, STATE_CLASS_MEASUREMENT, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -46,64 +47,150 @@ from homeassistant.util.dt import utc_from_timestamp from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .helpers import on_unload -SENSORS: dict[str, list[str | None] | dict[str, list[str | None]]] = { - "V_TEMP": [None, None, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT], - "V_HUM": [ - PERCENTAGE, - "mdi:water-percent", - DEVICE_CLASS_HUMIDITY, - STATE_CLASS_MEASUREMENT, - ], - "V_DIMMER": [PERCENTAGE, "mdi:percent", None, None], - "V_PERCENTAGE": [PERCENTAGE, "mdi:percent", None, None], - "V_PRESSURE": [None, "mdi:gauge", None, None], - "V_FORECAST": [None, "mdi:weather-partly-cloudy", None, None], - "V_RAIN": [None, "mdi:weather-rainy", None, None], - "V_RAINRATE": [None, "mdi:weather-rainy", None, None], - "V_WIND": [None, "mdi:weather-windy", None, None], - "V_GUST": [None, "mdi:weather-windy", None, None], - "V_DIRECTION": [DEGREE, "mdi:compass", None, None], - "V_WEIGHT": [MASS_KILOGRAMS, "mdi:weight-kilogram", None, None], - "V_DISTANCE": [LENGTH_METERS, "mdi:ruler", None, None], - "V_IMPEDANCE": ["ohm", None, None, None], - "V_WATT": [POWER_WATT, None, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT], - "V_KWH": [ - ENERGY_KILO_WATT_HOUR, - None, - DEVICE_CLASS_ENERGY, - STATE_CLASS_MEASUREMENT, - ], - "V_LIGHT_LEVEL": [PERCENTAGE, "mdi:white-balance-sunny", None, None], - "V_FLOW": [LENGTH_METERS, "mdi:gauge", None, None], - "V_VOLUME": [VOLUME_CUBIC_METERS, None, None, None], - "V_LEVEL": { - "S_SOUND": [SOUND_PRESSURE_DB, "mdi:volume-high", None, None], - "S_VIBRATION": [FREQUENCY_HERTZ, None, None, None], - "S_LIGHT_LEVEL": [ - LIGHT_LUX, - "mdi:white-balance-sunny", - DEVICE_CLASS_ILLUMINANCE, - STATE_CLASS_MEASUREMENT, - ], - "S_MOISTURE": [PERCENTAGE, "mdi:water-percent", None, None], - }, - "V_VOLTAGE": [ - ELECTRIC_POTENTIAL_VOLT, - "mdi:flash", - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, - ], - "V_CURRENT": [ - ELECTRIC_CURRENT_AMPERE, - "mdi:flash-auto", - DEVICE_CLASS_CURRENT, - STATE_CLASS_MEASUREMENT, - ], - "V_PH": ["pH", None, None, None], - "V_ORP": [ELECTRIC_POTENTIAL_MILLIVOLT, None, None, None], - "V_EC": [CONDUCTIVITY, None, None, None], - "V_VAR": ["var", None, None, None], - "V_VA": [POWER_VOLT_AMPERE, None, None, None], +SENSORS: dict[str, SensorEntityDescription] = { + "V_TEMP": SensorEntityDescription( + key="V_TEMP", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_HUM": SensorEntityDescription( + key="V_HUM", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_DIMMER": SensorEntityDescription( + key="V_DIMMER", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), + "V_PERCENTAGE": SensorEntityDescription( + key="V_PERCENTAGE", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), + "V_PRESSURE": SensorEntityDescription( + key="V_PRESSURE", + icon="mdi:gauge", + ), + "V_FORECAST": SensorEntityDescription( + key="V_FORECAST", + icon="mdi:weather-partly-cloudy", + ), + "V_RAIN": SensorEntityDescription( + key="V_RAIN", + icon="mdi:weather-rainy", + ), + "V_RAINRATE": SensorEntityDescription( + key="V_RAINRATE", + icon="mdi:weather-rainy", + ), + "V_WIND": SensorEntityDescription( + key="V_WIND", + icon="mdi:weather-windy", + ), + "V_GUST": SensorEntityDescription( + key="V_GUST", + icon="mdi:weather-windy", + ), + "V_DIRECTION": SensorEntityDescription( + key="V_DIRECTION", + native_unit_of_measurement=DEGREE, + icon="mdi:compass", + ), + "V_WEIGHT": SensorEntityDescription( + key="V_WEIGHT", + native_unit_of_measurement=MASS_KILOGRAMS, + icon="mdi:weight-kilogram", + ), + "V_DISTANCE": SensorEntityDescription( + key="V_DISTANCE", + native_unit_of_measurement=LENGTH_METERS, + icon="mdi:ruler", + ), + "V_IMPEDANCE": SensorEntityDescription( + key="V_IMPEDANCE", + native_unit_of_measurement="ohm", + ), + "V_WATT": SensorEntityDescription( + key="V_WATT", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_KWH": SensorEntityDescription( + key="V_KWH", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=utc_from_timestamp(0), + ), + "V_LIGHT_LEVEL": SensorEntityDescription( + key="V_LIGHT_LEVEL", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:white-balance-sunny", + ), + "V_FLOW": SensorEntityDescription( + key="V_FLOW", + native_unit_of_measurement=LENGTH_METERS, + icon="mdi:gauge", + ), + "V_VOLUME": SensorEntityDescription( + key="V_VOLUME", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + ), + "V_LEVEL_S_SOUND": SensorEntityDescription( + key="V_LEVEL_S_SOUND", + native_unit_of_measurement=SOUND_PRESSURE_DB, + icon="mdi:volume-high", + ), + "V_LEVEL_S_VIBRATION": SensorEntityDescription( + key="V_LEVEL_S_VIBRATION", + native_unit_of_measurement=FREQUENCY_HERTZ, + ), + "V_LEVEL_S_LIGHT_LEVEL": SensorEntityDescription( + key="V_LEVEL_S_LIGHT_LEVEL", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_LEVEL_S_MOISTURE": SensorEntityDescription( + key="V_LEVEL_S_MOISTURE", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + "V_VOLTAGE": SensorEntityDescription( + key="V_VOLTAGE", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_CURRENT": SensorEntityDescription( + key="V_CURRENT", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_PH": SensorEntityDescription( + key="V_PH", + native_unit_of_measurement="pH", + ), + "V_ORP": SensorEntityDescription( + key="V_ORP", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + ), + "V_EC": SensorEntityDescription( + key="V_EC", + native_unit_of_measurement=CONDUCTIVITY, + ), + "V_VAR": SensorEntityDescription( + key="V_VAR", + native_unit_of_measurement="var", + ), + "V_VA": SensorEntityDescription( + key="V_VA", + native_unit_of_measurement=POWER_VOLT_AMPERE, + ), } @@ -138,44 +225,19 @@ async def async_setup_entry( class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): """Representation of a MySensors Sensor child node.""" - @property - def force_update(self) -> bool: - """Return True if state updates should be forced. + _attr_force_update = True - If True, a state change will be triggered anytime the state property is - updated, not just when the value changes. - """ - return True + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Set up the instance.""" + super().__init__(*args, **kwargs) + if entity_description := self._get_entity_description(): + self.entity_description = entity_description @property def native_value(self) -> str | None: - """Return the state of the device.""" + """Return the state of the sensor.""" return self._values.get(self.value_type) - @property - def device_class(self) -> str | None: - """Return the device class of this entity.""" - return self._get_sensor_type()[2] - - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - return self._get_sensor_type()[1] - - @property - def last_reset(self) -> datetime | None: - """Return the time when the sensor was last reset, if any.""" - set_req = self.gateway.const.SetReq - - if set_req(self.value_type).name == "V_KWH": - return utc_from_timestamp(0) - return None - - @property - def state_class(self) -> str | None: - """Return the state class of this entity.""" - return self._get_sensor_type()[3] - @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity.""" @@ -192,21 +254,19 @@ class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): return TEMP_CELSIUS return TEMP_FAHRENHEIT - unit = self._get_sensor_type()[0] - return unit + if hasattr(self, "entity_description"): + return self.entity_description.native_unit_of_measurement + return None - def _get_sensor_type(self) -> list[str | None]: - """Return list with unit and icon of sensor type.""" - pres = self.gateway.const.Presentation + def _get_entity_description(self) -> SensorEntityDescription | None: + """Return the sensor entity description.""" set_req = self.gateway.const.SetReq + entity_description = SENSORS.get(set_req(self.value_type).name) - _sensor_type = SENSORS.get( - set_req(self.value_type).name, [None, None, None, None] - ) - if isinstance(_sensor_type, dict): - sensor_type = _sensor_type.get( - pres(self.child_type).name, [None, None, None, None] + if not entity_description: + pres = self.gateway.const.Presentation + entity_description = SENSORS.get( + f"{set_req(self.value_type).name}_{pres(self.child_type).name}" ) - else: - sensor_type = _sensor_type - return sensor_type + + return entity_description From 1b256efb23631ff5ec23749b442281279046f7e8 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 16 Aug 2021 09:26:17 -0600 Subject: [PATCH 411/903] Bump simplisafe-python to 11.0.4 (#54701) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 8c23e575cc3..6bf029ead6e 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==11.0.3"], + "requirements": ["simplisafe-python==11.0.4"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 9203b216eb7..13018dcc9b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2114,7 +2114,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.3 +simplisafe-python==11.0.4 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2e8f737214..09b17f6415e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1165,7 +1165,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.3 +simplisafe-python==11.0.4 # homeassistant.components.slack slackclient==2.5.0 From 2b1299b540dd9c162f3148127c69962fa2aa2a8d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 16 Aug 2021 18:20:44 +0200 Subject: [PATCH 412/903] Update Toon to use new state classes (#54705) --- homeassistant/components/toon/const.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 1c9192c4544..1c58ec2cde7 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -6,12 +6,12 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PROBLEM, ) from homeassistant.components.sensor import ( - ATTR_LAST_RESET, ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -25,7 +25,6 @@ from homeassistant.const import ( TEMP_CELSIUS, VOLUME_CUBIC_METERS, ) -from homeassistant.util import dt as dt_util DOMAIN = "toon" @@ -152,9 +151,8 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "meter", ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:gas-cylinder", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), ATTR_DEFAULT_ENABLED: False, }, "gas_value": { @@ -200,8 +198,7 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "meter_high", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, ATTR_DEFAULT_ENABLED: False, }, "power_meter_reading_low": { @@ -210,8 +207,7 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "meter_low", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, ATTR_DEFAULT_ENABLED: False, }, "power_value": { @@ -228,8 +224,7 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "meter_produced_high", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, ATTR_DEFAULT_ENABLED: False, }, "solar_meter_reading_low_produced": { @@ -238,8 +233,7 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "meter_produced_low", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, ATTR_DEFAULT_ENABLED: False, }, "solar_value": { @@ -344,8 +338,7 @@ SENSOR_ENTITIES = { ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:water", ATTR_DEFAULT_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "water_value": { ATTR_NAME: "Current Water Usage", From 61ab2b0c60ceb58cfd7dabec99c125830d2185d3 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 16 Aug 2021 12:30:52 -0400 Subject: [PATCH 413/903] Use zwave_js.number platform for Basic CC values (#54512) * Use zwave_js.number platform for some Basic CC values * Remove Basic CC sensor discovery schema * update comment * update comment --- homeassistant/components/zwave_js/discovery.py | 15 ++++++++++++--- homeassistant/components/zwave_js/sensor.py | 16 ---------------- tests/components/zwave_js/common.py | 2 +- tests/components/zwave_js/test_number.py | 14 ++++++++++++++ tests/components/zwave_js/test_sensor.py | 11 ----------- 5 files changed, 27 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 588b4c76472..77590e780a5 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -542,10 +542,10 @@ DISCOVERY_SCHEMAS = [ allow_multi=True, entity_registry_enabled_default=False, ), - # sensor for basic CC + # number for Basic CC ZWaveDiscoverySchema( - platform="sensor", - hint="numeric_sensor", + platform="number", + hint="Basic", primary_value=ZWaveValueDiscoverySchema( command_class={ CommandClass.BASIC, @@ -553,6 +553,15 @@ DISCOVERY_SCHEMAS = [ type={"number"}, property={"currentValue"}, ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={ + CommandClass.BASIC, + }, + type={"number"}, + property={"targetValue"}, + ) + ], entity_registry_enabled_default=False, ), # binary switches diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index aa163fa8bd9..deacf3d874a 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -198,22 +198,6 @@ class ZWaveStringSensor(ZwaveSensorBase): class ZWaveNumericSensor(ZwaveSensorBase): """Representation of a Z-Wave Numeric sensor.""" - def __init__( - self, - config_entry: ConfigEntry, - client: ZwaveClient, - info: ZwaveDiscoveryInfo, - ) -> None: - """Initialize a ZWaveNumericSensor entity.""" - super().__init__(config_entry, client, info) - - # Entity class attributes - if self.info.primary_value.command_class == CommandClass.BASIC: - self._attr_name = self.generate_name( - include_value_name=True, - alternate_value_name=self.info.primary_value.command_class_name, - ) - @property def native_value(self) -> float: """Return state of the sensor.""" diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 44943fed9fb..0c6b19698a9 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -16,7 +16,7 @@ NOTIFICATION_MOTION_BINARY_SENSOR = ( ) NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_home_security_motion_sensor_status" INDICATOR_SENSOR = "sensor.z_wave_thermostat_indicator_value" -BASIC_SENSOR = "sensor.livingroomlight_basic" +BASIC_NUMBER_ENTITY = "number.livingroomlight_basic" PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( "binary_sensor.august_smart_lock_pro_3rd_gen_the_current_status_of_the_door" ) diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index b7d83068bea..6439d034587 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -1,6 +1,10 @@ """Test the Z-Wave JS number platform.""" from zwave_js_server.event import Event +from homeassistant.helpers import entity_registry as er + +from .common import BASIC_NUMBER_ENTITY + NUMBER_ENTITY = "number.thermostat_hvac_valve_control" @@ -67,3 +71,13 @@ async def test_number(hass, client, aeotec_radiator_thermostat, integration): state = hass.states.get(NUMBER_ENTITY) assert state.state == "99.0" + + +async def test_disabled_basic_number(hass, ge_in_wall_dimmer_switch, integration): + """Test number is created from Basic CC and is disabled.""" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(BASIC_NUMBER_ENTITY) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 04583559421..268d8ee1380 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -28,7 +28,6 @@ from homeassistant.helpers import entity_registry as er from .common import ( AIR_TEMPERATURE_SENSOR, - BASIC_SENSOR, CURRENT_SENSOR, DATETIME_LAST_RESET, DATETIME_ZERO, @@ -131,16 +130,6 @@ async def test_disabled_indcator_sensor( assert entity_entry.disabled_by == er.DISABLED_INTEGRATION -async def test_disabled_basic_sensor(hass, ge_in_wall_dimmer_switch, integration): - """Test sensor is created from Basic CC and is disabled.""" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(BASIC_SENSOR) - - assert entity_entry - assert entity_entry.disabled - assert entity_entry.disabled_by == er.DISABLED_INTEGRATION - - async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration): """Test config parameter sensor is created.""" ent_reg = er.async_get(hass) From 35389a6d28d417bf0f41c08cb945b592e7f21fab Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 16 Aug 2021 18:35:50 +0200 Subject: [PATCH 414/903] Remove last_reset attribute from dsmr sensors (#54699) --- homeassistant/components/dsmr/const.py | 36 ++++++++++---------------- tests/components/dsmr/test_sensor.py | 35 ++++++++++++++++--------- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index b5fb74bbbe6..0043113772e 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -5,7 +5,10 @@ import logging from dsmr_parser import obis_references -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.const import ( DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -13,7 +16,6 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_VOLTAGE, ) -from homeassistant.util import dt from .models import DSMRSensorEntityDescription @@ -67,32 +69,28 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( name="Energy Consumption (tarif 1)", device_class=DEVICE_CLASS_ENERGY, force_update=True, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_USED_TARIFF_2, name="Energy Consumption (tarif 2)", force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, name="Energy Production (tarif 1)", force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, name="Energy Production (tarif 2)", force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, @@ -229,8 +227,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.LUXEMBOURG_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, @@ -238,8 +235,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_IMPORTED_TOTAL, @@ -247,8 +243,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"2.2", "4", "5", "5B"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.HOURLY_GAS_METER_READING, @@ -258,8 +253,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( force_update=True, icon="mdi:fire", device_class=DEVICE_CLASS_GAS, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.BELGIUM_HOURLY_GAS_METER_READING, @@ -269,8 +263,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( force_update=True, icon="mdi:fire", device_class=DEVICE_CLASS_GAS, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.GAS_METER_READING, @@ -280,7 +273,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( force_update=True, icon="mdi:fire", device_class=DEVICE_CLASS_GAS, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), ) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index c7e0addd800..0f1c55f47b6 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -18,6 +18,7 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -167,8 +168,10 @@ async def test_default_setup(hass, dsmr_connection_fixture): assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None - assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS ) @@ -267,8 +270,10 @@ async def test_v4_meter(hass, dsmr_connection_fixture): assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None - assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS ) @@ -337,8 +342,10 @@ async def test_v5_meter(hass, dsmr_connection_fixture): assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None - assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS ) @@ -403,8 +410,8 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): assert power_tariff.state == "123.456" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY assert power_tariff.attributes.get(ATTR_ICON) is None - assert power_tariff.attributes.get(ATTR_LAST_RESET) is not None - assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert power_tariff.attributes.get(ATTR_LAST_RESET) is None + assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING assert ( power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR ) @@ -418,8 +425,10 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None - assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS ) @@ -488,8 +497,10 @@ async def test_belgian_meter(hass, dsmr_connection_fixture): assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is DEVICE_CLASS_GAS assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None - assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS ) From 441552e04cb4e8d08287e6cd7a4d4b2b1c9561ed Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Mon, 16 Aug 2021 13:02:01 -0400 Subject: [PATCH 415/903] Fix TypeError when climate component sets fan modes to None (#54709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- homeassistant/components/google_assistant/trait.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 36222902296..06d10c5372b 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1290,7 +1290,7 @@ class FanSpeedTrait(_Trait): ) elif domain == climate.DOMAIN: - modes = self.state.attributes.get(climate.ATTR_FAN_MODES, []) + modes = self.state.attributes.get(climate.ATTR_FAN_MODES) or [] for mode in modes: speed = { "speed_name": mode, From a41ee9e870f5134a9199fbadd853bfa0d3612128 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 16 Aug 2021 13:36:20 -0400 Subject: [PATCH 416/903] Create zwave-js select platform and discover additional siren values (#53018) * Create zwave-js select platform and add siren values to number and select platforms * use constants while we wait for lib release * comments * rename stuff in tests to prepare for protection CC PR * Switch to 0-1 range for number entity * Update homeassistant/components/zwave_js/number.py Co-authored-by: kpine * Change step * Switch to ToneID * Better error handling * Add test for coerage Co-authored-by: kpine --- .../components/zwave_js/discovery.py | 24 +++++ homeassistant/components/zwave_js/number.py | 40 ++++++- homeassistant/components/zwave_js/select.py | 91 ++++++++++++++++ tests/components/zwave_js/test_number.py | 94 ++++++++++++++++ tests/components/zwave_js/test_select.py | 101 ++++++++++++++++++ 5 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/zwave_js/select.py create mode 100644 tests/components/zwave_js/test_select.py diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 77590e780a5..58dae39781e 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -642,6 +642,30 @@ DISCOVERY_SCHEMAS = [ platform="siren", primary_value=SIREN_TONE_SCHEMA, ), + # select + # siren default tone + ZWaveDiscoverySchema( + platform="select", + hint="Default tone", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SOUND_SWITCH}, + property={"defaultToneId"}, + type={"number"}, + ), + required_values=[SIREN_TONE_SCHEMA], + ), + # number + # siren default volume + ZWaveDiscoverySchema( + platform="number", + hint="volume", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SOUND_SWITCH}, + property={"defaultVolume"}, + type={"number"}, + ), + required_values=[SIREN_TONE_SCHEMA], + ), ] diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index e53e5942999..675a396fb7b 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -26,7 +26,10 @@ async def async_setup_entry( def async_add_number(info: ZwaveDiscoveryInfo) -> None: """Add Z-Wave number entity.""" entities: list[ZWaveBaseEntity] = [] - entities.append(ZwaveNumberEntity(config_entry, client, info)) + if info.platform_hint == "volume": + entities.append(ZwaveVolumeNumberEntity(config_entry, client, info)) + else: + entities.append(ZwaveNumberEntity(config_entry, client, info)) async_add_entities(entities) config_entry.async_on_unload( @@ -87,3 +90,38 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): async def async_set_value(self, value: float) -> None: """Set new value.""" await self.info.node.async_set_value(self._target_value, value) + + +class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): + """Representation of a volume number entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveVolumeNumberEntity entity.""" + super().__init__(config_entry, client, info) + self.correction_factor = int( + self.info.primary_value.metadata.max - self.info.primary_value.metadata.min + ) + # Fallback in case we can't properly calculate correction factor + if self.correction_factor == 0: + self.correction_factor = 1 + + # Entity class attributes + self._attr_min_value = 0 + self._attr_max_value = 1 + self._attr_step = 0.01 + self._attr_name = self.generate_name(include_value_name=True) + + @property + def value(self) -> float | None: + """Return the entity value.""" + if self.info.primary_value.value is None: + return None + return float(self.info.primary_value.value) / self.correction_factor + + async def async_set_value(self, value: float) -> None: + """Set new value.""" + await self.info.node.async_set_value( + self.info.primary_value, round(value * self.correction_factor) + ) diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py new file mode 100644 index 00000000000..2bd711bfde3 --- /dev/null +++ b/homeassistant/components/zwave_js/select.py @@ -0,0 +1,91 @@ +"""Support for Z-Wave controls using the select platform.""" +from __future__ import annotations + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import CommandClass, ToneID + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_CLIENT, DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Z-Wave Select entity from Config Entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_select(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave select entity.""" + entities: list[ZWaveBaseEntity] = [] + if info.platform_hint == "Default tone": + entities.append(ZwaveDefaultToneSelectEntity(config_entry, client, info)) + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{SELECT_DOMAIN}", + async_add_select, + ) + ) + + +class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): + """Representation of a Z-Wave default tone select entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveDefaultToneSelectEntity entity.""" + super().__init__(config_entry, client, info) + self._tones_value = self.get_zwave_value( + "toneId", command_class=CommandClass.SOUND_SWITCH + ) + + # Entity class attributes + self._attr_name = self.generate_name( + include_value_name=True, alternate_value_name=info.platform_hint + ) + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + # We know we can assert because this value is part of the discovery schema + assert self._tones_value + return [ + val + for key, val in self._tones_value.metadata.states.items() + if int(key) not in (ToneID.DEFAULT, ToneID.OFF) + ] + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + # We know we can assert because this value is part of the discovery schema + assert self._tones_value + return str( + self._tones_value.metadata.states.get( + str(self.info.primary_value.value), self.info.primary_value.value + ) + ) + + async def async_select_option(self, option: str | int) -> None: + """Change the selected option.""" + # We know we can assert because this value is part of the discovery schema + assert self._tones_value + key = next( + key + for key, val in self._tones_value.metadata.states.items() + if val == option + ) + await self.info.node.async_set_value(self.info.primary_value, int(key)) diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index 6439d034587..6d9458d096c 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -1,11 +1,13 @@ """Test the Z-Wave JS number platform.""" from zwave_js_server.event import Event +from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers import entity_registry as er from .common import BASIC_NUMBER_ENTITY NUMBER_ENTITY = "number.thermostat_hvac_valve_control" +VOLUME_NUMBER_ENTITY = "number.indoor_siren_6_default_volume_2" async def test_number(hass, client, aeotec_radiator_thermostat, integration): @@ -73,6 +75,98 @@ async def test_number(hass, client, aeotec_radiator_thermostat, integration): assert state.state == "99.0" +async def test_volume_number(hass, client, aeotec_zw164_siren, integration): + """Test the volume number entity.""" + node = aeotec_zw164_siren + state = hass.states.get(VOLUME_NUMBER_ENTITY) + + assert state + assert state.state == "1.0" + assert state.attributes["step"] == 0.01 + assert state.attributes["max"] == 1.0 + assert state.attributes["min"] == 0 + + # Test turn on setting value + await hass.services.async_call( + "number", + "set_value", + {"entity_id": VOLUME_NUMBER_ENTITY, "value": 0.3}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%", + }, + "value": 100, + } + assert args["value"] == 30 + + client.async_send_command.reset_mock() + + # Test value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 4, + "args": { + "commandClassName": "Sound Switch", + "commandClass": 121, + "endpoint": 2, + "property": "defaultVolume", + "newValue": 30, + "prevValue": 100, + "propertyName": "defaultVolume", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(VOLUME_NUMBER_ENTITY) + assert state.state == "0.3" + + # Test null value + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 4, + "args": { + "commandClassName": "Sound Switch", + "commandClass": 121, + "endpoint": 2, + "property": "defaultVolume", + "newValue": None, + "prevValue": 30, + "propertyName": "defaultVolume", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(VOLUME_NUMBER_ENTITY) + assert state.state == STATE_UNKNOWN + + async def test_disabled_basic_number(hass, ge_in_wall_dimmer_switch, integration): """Test number is created from Basic CC and is disabled.""" ent_reg = er.async_get(hass) diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py new file mode 100644 index 00000000000..b94bac812b6 --- /dev/null +++ b/tests/components/zwave_js/test_select.py @@ -0,0 +1,101 @@ +"""Test the Z-Wave JS number platform.""" +from zwave_js_server.event import Event + +DEFAULT_TONE_SELECT_ENTITY = "select.indoor_siren_6_default_tone_2" + + +async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration): + """Test the default tone select entity.""" + node = aeotec_zw164_siren + state = hass.states.get(DEFAULT_TONE_SELECT_ENTITY) + + assert state + assert state.state == "17ALAR~1 (35 sec)" + attr = state.attributes + assert attr["options"] == [ + "01DING~1 (5 sec)", + "02DING~1 (9 sec)", + "03TRAD~1 (11 sec)", + "04ELEC~1 (2 sec)", + "05WEST~1 (13 sec)", + "06CHIM~1 (7 sec)", + "07CUCK~1 (31 sec)", + "08TRAD~1 (6 sec)", + "09SMOK~1 (11 sec)", + "10SMOK~1 (6 sec)", + "11FIRE~1 (35 sec)", + "12COSE~1 (5 sec)", + "13KLAX~1 (38 sec)", + "14DEEP~1 (41 sec)", + "15WARN~1 (37 sec)", + "16TORN~1 (46 sec)", + "17ALAR~1 (35 sec)", + "18DEEP~1 (62 sec)", + "19ALAR~1 (15 sec)", + "20ALAR~1 (7 sec)", + "21DIGI~1 (8 sec)", + "22ALER~1 (64 sec)", + "23SHIP~1 (4 sec)", + "25CHRI~1 (4 sec)", + "26GONG~1 (12 sec)", + "27SING~1 (1 sec)", + "28TONA~1 (5 sec)", + "29UPWA~1 (2 sec)", + "30DOOR~1 (27 sec)", + ] + + # Test select option with string value + await hass.services.async_call( + "select", + "select_option", + {"entity_id": DEFAULT_TONE_SELECT_ENTITY, "option": "30DOOR~1 (27 sec)"}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Default tone ID", + "min": 0, + "max": 254, + }, + "value": 17, + } + assert args["value"] == 30 + + client.async_send_command.reset_mock() + + # Test value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Sound Switch", + "commandClass": 121, + "endpoint": 2, + "property": "defaultToneId", + "newValue": 30, + "prevValue": 17, + "propertyName": "defaultToneId", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(DEFAULT_TONE_SELECT_ENTITY) + assert state.state == "30DOOR~1 (27 sec)" From c68253b5801715ae33bb91c58e04df7b2b5a44c6 Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Mon, 16 Aug 2021 19:37:49 +0200 Subject: [PATCH 417/903] Fix AsusWRT scanner entity DeviceInfo (#54648) --- .../components/asuswrt/device_tracker.py | 11 +++--- homeassistant/components/asuswrt/sensor.py | 38 ++++++++----------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 0b5d81e3de9..3e954eb25b9 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -60,6 +60,12 @@ class AsusWrtDevice(ScannerEntity): self._device = device self._attr_unique_id = device.mac self._attr_name = device.name or DEFAULT_DEVICE_NAME + self._attr_device_info = { + "connections": {(CONNECTION_NETWORK_MAC, device.mac)}, + "default_model": "ASUSWRT Tracked device", + } + if device.name: + self._attr_device_info["default_name"] = device.name @property def is_connected(self): @@ -90,11 +96,6 @@ class AsusWrtDevice(ScannerEntity): def async_on_demand_update(self): """Update state.""" self._device = self._router.devices[self._device.mac] - self._attr_device_info = { - "connections": {(CONNECTION_NETWORK_MAC, self._device.mac)}, - } - if self._device.name: - self._attr_device_info["default_name"] = self._device.name self._attr_extra_state_attributes = {} if self._device.last_activity: self._attr_extra_state_attributes[ diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 3367cc37ee4..a9a005b9837 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -4,22 +4,20 @@ from __future__ import annotations from dataclasses import dataclass import logging from numbers import Number -from typing import Any from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_GIGABYTES, DATA_RATE_MEGABITS_PER_SECOND from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from homeassistant.util import dt as dt_util from .const import ( DATA_ASUSWRT, @@ -48,12 +46,14 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_CONNECTED_DEVICE[0], name="Devices Connected", icon="mdi:router-network", + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=UNIT_DEVICES, ), AsusWrtSensorEntityDescription( key=SENSORS_RATES[0], name="Download Speed", icon="mdi:download-network", + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, entity_registry_enabled_default=False, factor=125000, @@ -62,6 +62,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_RATES[1], name="Upload Speed", icon="mdi:upload-network", + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, entity_registry_enabled_default=False, factor=125000, @@ -70,6 +71,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_BYTES[0], name="Download", icon="mdi:download", + state_class=STATE_CLASS_TOTAL_INCREASING, native_unit_of_measurement=DATA_GIGABYTES, entity_registry_enabled_default=False, factor=1000000000, @@ -78,6 +80,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_BYTES[1], name="Upload", icon="mdi:upload", + state_class=STATE_CLASS_TOTAL_INCREASING, native_unit_of_measurement=DATA_GIGABYTES, entity_registry_enabled_default=False, factor=1000000000, @@ -86,6 +89,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_LOAD_AVG[0], name="Load Avg (1m)", icon="mdi:cpu-32-bit", + state_class=STATE_CLASS_MEASUREMENT, entity_registry_enabled_default=False, factor=1, precision=1, @@ -94,6 +98,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_LOAD_AVG[1], name="Load Avg (5m)", icon="mdi:cpu-32-bit", + state_class=STATE_CLASS_MEASUREMENT, entity_registry_enabled_default=False, factor=1, precision=1, @@ -102,6 +107,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_LOAD_AVG[2], name="Load Avg (15m)", icon="mdi:cpu-32-bit", + state_class=STATE_CLASS_MEASUREMENT, entity_registry_enabled_default=False, factor=1, precision=1, @@ -143,33 +149,19 @@ class AsusWrtSensor(CoordinatorEntity, SensorEntity): ) -> None: """Initialize a AsusWrt sensor.""" super().__init__(coordinator) - self._router = router self.entity_description = description self._attr_name = f"{DEFAULT_PREFIX} {description.name}" self._attr_unique_id = f"{DOMAIN} {self.name}" - self._attr_state_class = STATE_CLASS_MEASUREMENT - - if description.native_unit_of_measurement == DATA_GIGABYTES: - self._attr_last_reset = dt_util.utc_from_timestamp(0) + self._attr_device_info = router.device_info + self._attr_extra_state_attributes = {"hostname": router.host} @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Return current state.""" descr = self.entity_description state = self.coordinator.data.get(descr.key) - if state is None: - return None - if descr.factor and isinstance(state, Number): - return round(state / descr.factor, descr.precision) + if state is not None: + if descr.factor and isinstance(state, Number): + return round(state / descr.factor, descr.precision) return state - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the attributes.""" - return {"hostname": self._router.host} - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return self._router.device_info From f40c672cd25ebbbaad5abcdd9e48d8f822210741 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Mon, 16 Aug 2021 13:52:53 -0600 Subject: [PATCH 418/903] Add light platform to MyQ (#54611) Co-authored-by: J. Nick Koston --- .coveragerc | 1 + CODEOWNERS | 2 +- homeassistant/components/myq/const.py | 14 ++- homeassistant/components/myq/light.py | 115 +++++++++++++++++++++ homeassistant/components/myq/manifest.json | 2 +- tests/components/myq/test_light.py | 36 +++++++ tests/fixtures/myq/devices.json | 34 +++++- 7 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/myq/light.py create mode 100644 tests/components/myq/test_light.py diff --git a/.coveragerc b/.coveragerc index 3795f7e49b8..2cc3bf2d019 100644 --- a/.coveragerc +++ b/.coveragerc @@ -672,6 +672,7 @@ omit = homeassistant/components/mystrom/switch.py homeassistant/components/myq/__init__.py homeassistant/components/myq/cover.py + homeassistant/components/myq/light.py homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/light.py homeassistant/components/neato/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 4b7cb8520b0..642de7a04d8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -321,7 +321,7 @@ homeassistant/components/msteams/* @peroyvind homeassistant/components/mullvad/* @meichthys homeassistant/components/mutesync/* @currentoor homeassistant/components/my/* @home-assistant/core -homeassistant/components/myq/* @bdraco +homeassistant/components/myq/* @bdraco @ehendrix23 homeassistant/components/mysensors/* @MartinHjelmare @functionpointer homeassistant/components/mystrom/* @fabaff homeassistant/components/nam/* @bieniu diff --git a/homeassistant/components/myq/const.py b/homeassistant/components/myq/const.py index 6189b1601ea..9f3a434ae37 100644 --- a/homeassistant/components/myq/const.py +++ b/homeassistant/components/myq/const.py @@ -5,18 +5,28 @@ from pymyq.garagedoor import ( STATE_OPEN as MYQ_COVER_STATE_OPEN, STATE_OPENING as MYQ_COVER_STATE_OPENING, ) +from pymyq.lamp import STATE_OFF as MYQ_LIGHT_STATE_OFF, STATE_ON as MYQ_LIGHT_STATE_ON -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING +from homeassistant.const import ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OFF, + STATE_ON, + STATE_OPEN, + STATE_OPENING, +) DOMAIN = "myq" -PLATFORMS = ["cover", "binary_sensor"] +PLATFORMS = ["cover", "binary_sensor", "light"] MYQ_TO_HASS = { MYQ_COVER_STATE_CLOSED: STATE_CLOSED, MYQ_COVER_STATE_CLOSING: STATE_CLOSING, MYQ_COVER_STATE_OPEN: STATE_OPEN, MYQ_COVER_STATE_OPENING: STATE_OPENING, + MYQ_LIGHT_STATE_ON: STATE_ON, + MYQ_LIGHT_STATE_OFF: STATE_OFF, } MYQ_GATEWAY = "myq_gateway" diff --git a/homeassistant/components/myq/light.py b/homeassistant/components/myq/light.py new file mode 100644 index 00000000000..f26d28fe3a3 --- /dev/null +++ b/homeassistant/components/myq/light.py @@ -0,0 +1,115 @@ +"""Support for MyQ-Enabled lights.""" +import logging + +from pymyq.const import ( + DEVICE_STATE as MYQ_DEVICE_STATE, + DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, + KNOWN_MODELS, + MANUFACTURER, +) +from pymyq.errors import MyQError + +from homeassistant.components.light import LightEntity +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up myq lights.""" + data = hass.data[DOMAIN][config_entry.entry_id] + myq = data[MYQ_GATEWAY] + coordinator = data[MYQ_COORDINATOR] + + async_add_entities( + [MyQLight(coordinator, device) for device in myq.lamps.values()], True + ) + + +class MyQLight(CoordinatorEntity, LightEntity): + """Representation of a MyQ light.""" + + _attr_supported_features = 0 + + def __init__(self, coordinator, device): + """Initialize with API object, device id.""" + super().__init__(coordinator) + self._device = device + self._attr_unique_id = device.device_id + self._attr_name = device.name + + @property + def available(self): + """Return if the device is online.""" + if not super().available: + return False + + # Not all devices report online so assume True if its missing + return self._device.device_json[MYQ_DEVICE_STATE].get( + MYQ_DEVICE_STATE_ONLINE, True + ) + + @property + def is_on(self): + """Return true if the light is on, else False.""" + return MYQ_TO_HASS.get(self._device.state) == STATE_ON + + @property + def is_off(self): + """Return true if the light is off, else False.""" + return MYQ_TO_HASS.get(self._device.state) == STATE_OFF + + async def async_turn_on(self, **kwargs): + """Issue on command to light.""" + if self.is_on: + return + + try: + await self._device.turnon(wait_for_state=True) + except MyQError as err: + raise HomeAssistantError( + f"Turning light {self._device.name} on failed with error: {err}" + ) from err + + # Write new state to HASS + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Issue off command to light.""" + if self.is_off: + return + + try: + await self._device.turnoff(wait_for_state=True) + except MyQError as err: + raise HomeAssistantError( + f"Turning light {self._device.name} off failed with error: {err}" + ) from err + + # Write opening state to HASS + self.async_write_ha_state() + + @property + def device_info(self): + """Return the device_info of the device.""" + device_info = { + "identifiers": {(DOMAIN, self._device.device_id)}, + "name": self._device.name, + "manufacturer": MANUFACTURER, + "sw_version": self._device.firmware_version, + } + if model := KNOWN_MODELS.get(self._device.device_id[2:4]): + device_info["model"] = model + if self._device.parent_device_id: + device_info["via_device"] = (DOMAIN, self._device.parent_device_id) + return device_info + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index a4de12290f1..33cbea71bcd 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -3,7 +3,7 @@ "name": "MyQ", "documentation": "https://www.home-assistant.io/integrations/myq", "requirements": ["pymyq==3.1.2"], - "codeowners": ["@bdraco"], + "codeowners": ["@bdraco","@ehendrix23"], "config_flow": true, "homekit": { "models": ["819LMB", "MYQ"] diff --git a/tests/components/myq/test_light.py b/tests/components/myq/test_light.py new file mode 100644 index 00000000000..c7b3dbc8427 --- /dev/null +++ b/tests/components/myq/test_light.py @@ -0,0 +1,36 @@ +"""The scene tests for the myq platform.""" + +from homeassistant.const import STATE_OFF, STATE_ON + +from .util import async_init_integration + + +async def test_create_lights(hass): + """Test creation of lights.""" + + await async_init_integration(hass) + + state = hass.states.get("light.garage_door_light_off") + assert state.state == STATE_OFF + expected_attributes = { + "friendly_name": "Garage Door Light Off", + "supported_features": 0, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == expected_attributes[key] for key in expected_attributes + ) + + state = hass.states.get("light.garage_door_light_on") + assert state.state == STATE_ON + expected_attributes = { + "friendly_name": "Garage Door Light On", + "supported_features": 0, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + + assert all( + state.attributes[key] == expected_attributes[key] for key in expected_attributes + ) diff --git a/tests/fixtures/myq/devices.json b/tests/fixtures/myq/devices.json index f7c65c6bb20..1e731ffe204 100644 --- a/tests/fixtures/myq/devices.json +++ b/tests/fixtures/myq/devices.json @@ -1,5 +1,5 @@ { - "count" : 4, + "count" : 6, "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices", "items" : [ { @@ -128,6 +128,36 @@ "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial", "device_type" : "wifigaragedooropener", "created_date" : "2020-02-10T23:11:47.487" - } + }, + { + "serial_number" : "garage_light_off", + "state" : { + "last_status" : "2020-03-30T02:48:45.7501595Z", + "online" : true, + "lamp_state" : "off", + "last_update" : "2020-03-26T15:45:31.4713796Z" + }, + "parent_device_id" : "gateway_serial", + "device_platform" : "myq", + "name" : "Garage Door Light Off", + "device_family" : "lamp", + "device_type" : "lamp", + "created_date" : "2020-02-10T23:11:47.487" + }, + { + "serial_number" : "garage_light_on", + "state" : { + "last_status" : "2020-03-30T02:48:45.7501595Z", + "online" : true, + "lamp_state" : "on", + "last_update" : "2020-03-26T15:45:31.4713796Z" + }, + "parent_device_id" : "gateway_serial", + "device_platform" : "myq", + "name" : "Garage Door Light On", + "device_family" : "lamp", + "device_type" : "lamp", + "created_date" : "2020-02-10T23:11:47.487" + } ] } From 0b3f322475c946930244dd504cadf3fd119ec9fd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 16 Aug 2021 22:02:32 +0200 Subject: [PATCH 419/903] Upgrade pre-commit to 2.14.0 (#54719) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index acfe29db593..d843745cbbf 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,7 +9,7 @@ coverage==5.5 jsonpickle==1.4.1 mock-open==1.4.0 mypy==0.902 -pre-commit==2.13.0 +pre-commit==2.14.0 pylint==2.9.5 pipdeptree==1.0.0 pylint-strict-informational==0.1 From 5e51f57f022c17d6f7b8d4d2b4b08dc101206150 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Aug 2021 15:19:32 -0500 Subject: [PATCH 420/903] Convert nmap_tracker to be a config flow (#54715) --- .coveragerc | 3 +- CODEOWNERS | 1 + .../components/nmap_tracker/__init__.py | 394 +++++++++++++++++- .../components/nmap_tracker/config_flow.py | 215 ++++++++++ .../components/nmap_tracker/const.py | 4 +- .../components/nmap_tracker/device_tracker.py | 261 +++++++----- .../components/nmap_tracker/manifest.json | 12 +- .../components/nmap_tracker/strings.json | 1 - .../nmap_tracker/translations/en.json | 3 +- homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 10 +- requirements_test_all.txt | 7 + tests/components/nmap_tracker/__init__.py | 1 + .../nmap_tracker/test_config_flow.py | 301 +++++++++++++ 14 files changed, 1103 insertions(+), 111 deletions(-) create mode 100644 homeassistant/components/nmap_tracker/config_flow.py create mode 100644 tests/components/nmap_tracker/__init__.py create mode 100644 tests/components/nmap_tracker/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2cc3bf2d019..4b5c0820650 100644 --- a/.coveragerc +++ b/.coveragerc @@ -697,7 +697,8 @@ omit = homeassistant/components/niko_home_control/light.py homeassistant/components/nilu/air_quality.py homeassistant/components/nissan_leaf/* - homeassistant/components/nmap_tracker/* + homeassistant/components/nmap_tracker/__init__.py + homeassistant/components/nmap_tracker/device_tracker.py homeassistant/components/nmbs/sensor.py homeassistant/components/notion/__init__.py homeassistant/components/notion/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 642de7a04d8..c6696c485fe 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -339,6 +339,7 @@ homeassistant/components/nfandroidtv/* @tkdrob homeassistant/components/nightscout/* @marciogranzotto homeassistant/components/nilu/* @hfurubotten homeassistant/components/nissan_leaf/* @filcole +homeassistant/components/nmap_tracker/* @bdraco homeassistant/components/nmbs/* @thibmaek homeassistant/components/no_ip/* @fabaff homeassistant/components/noaa_tides/* @jdelaney72 diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index da699caaa73..87e9ad895af 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -1 +1,393 @@ -"""The nmap_tracker component.""" +"""The Nmap Tracker integration.""" +from __future__ import annotations + +import asyncio +import contextlib +from dataclasses import dataclass +from datetime import datetime, timedelta +from functools import partial +import logging + +import aiohttp +from getmac import get_mac_address +from mac_vendor_lookup import AsyncMacLookup +from nmap import PortScanner, PortScannerError + +from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +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.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 .const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DOMAIN, + NMAP_TRACKED_DEVICES, + PLATFORMS, + TRACKER_SCAN_INTERVAL, +) + +# Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n' +NMAP_TRANSIENT_FAILURE = "Assertion failed: htn.toclock_running == true" +MAX_SCAN_ATTEMPTS = 16 +OFFLINE_SCANS_TO_MARK_UNAVAILABLE = 3 + + +def short_hostname(hostname): + """Return the first part of the hostname.""" + if hostname is None: + return None + return hostname.split(".")[0] + + +def human_readable_name(hostname, vendor, mac_address): + """Generate a human readable name.""" + if hostname: + return short_hostname(hostname) + if vendor: + return f"{vendor} {mac_address[-8:]}" + return f"Nmap Tracker {mac_address}" + + +@dataclass +class NmapDevice: + """Class for keeping track of an nmap tracked device.""" + + mac_address: str + hostname: str + name: str + ipv4: str + manufacturer: str + reason: str + last_update: datetime.datetime + offline_scans: int + + +class NmapTrackedDevices: + """Storage class for all nmap trackers.""" + + def __init__(self) -> None: + """Initialize the data.""" + self.tracked: dict = {} + self.ipv4_last_mac: dict = {} + self.config_entry_owner: dict = {} + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Nmap Tracker from a config entry.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + devices = domain_data.setdefault(NMAP_TRACKED_DEVICES, NmapTrackedDevices()) + scanner = domain_data[entry.entry_id] = NmapDeviceScanner(hass, entry, devices) + await scanner.async_setup() + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + _async_untrack_devices(hass, entry) + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +@callback +def _async_untrack_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove tracking for devices owned by this config entry.""" + devices = hass.data[DOMAIN][NMAP_TRACKED_DEVICES] + remove_mac_addresses = [ + mac_address + for mac_address, entry_id in devices.config_entry_owner.items() + if entry_id == entry.entry_id + ] + for mac_address in remove_mac_addresses: + if device := devices.tracked.pop(mac_address, None): + devices.ipv4_last_mac.pop(device.ipv4, None) + del devices.config_entry_owner[mac_address] + + +def signal_device_update(mac_address) -> str: + """Signal specific per nmap tracker entry to signal updates in device.""" + return f"{DOMAIN}-device-update-{mac_address}" + + +class NmapDeviceScanner: + """This class scans for devices using nmap.""" + + def __init__(self, hass, entry, devices): + """Initialize the scanner.""" + self.devices = devices + self.home_interval = None + + self._hass = hass + self._entry = entry + + self._scan_lock = None + self._stopping = False + self._scanner = None + + self._entry_id = entry.entry_id + self._hosts = None + self._options = None + self._exclude = None + self._scan_interval = None + + self._known_mac_addresses = {} + self._finished_first_scan = False + self._last_results = [] + self._mac_vendor_lookup = None + + async def async_setup(self): + """Set up the tracker.""" + config = self._entry.options + self._scan_interval = timedelta( + seconds=config.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL) + ) + hosts_list = cv.ensure_list_csv(config[CONF_HOSTS]) + self._hosts = [host for host in hosts_list if host != ""] + excludes_list = cv.ensure_list_csv(config[CONF_EXCLUDE]) + self._exclude = [exclude for exclude in excludes_list if exclude != ""] + self._options = config[CONF_OPTIONS] + self.home_interval = timedelta( + minutes=cv.positive_int(config[CONF_HOME_INTERVAL]) + ) + self._scan_lock = asyncio.Lock() + if self._hass.state == CoreState.running: + await self._async_start_scanner() + return + + self._entry.async_on_unload( + self._hass.bus.async_listen( + EVENT_HOMEASSISTANT_STARTED, self._async_start_scanner + ) + ) + registry = er.async_get(self._hass) + self._known_mac_addresses = { + entry.unique_id: entry.original_name + for entry in registry.entities.values() + if entry.config_entry_id == self._entry_id + } + + @property + def signal_device_new(self) -> str: + """Signal specific per nmap tracker entry to signal new device.""" + return f"{DOMAIN}-device-new-{self._entry_id}" + + @property + def signal_device_missing(self) -> str: + """Signal specific per nmap tracker entry to signal a missing device.""" + return f"{DOMAIN}-device-missing-{self._entry_id}" + + @callback + def _async_get_vendor(self, mac_address): + """Lookup the vendor.""" + oui = self._mac_vendor_lookup.sanitise(mac_address)[:6] + return self._mac_vendor_lookup.prefixes.get(oui) + + @callback + def _async_stop(self): + """Stop the scanner.""" + self._stopping = True + + async def _async_start_scanner(self, *_): + """Start the scanner.""" + self._entry.async_on_unload(self._async_stop) + self._entry.async_on_unload( + async_track_time_interval( + self._hass, + self._async_scan_devices, + self._scan_interval, + ) + ) + self._mac_vendor_lookup = AsyncMacLookup() + with contextlib.suppress((asyncio.TimeoutError, aiohttp.ClientError)): + # We don't care if this fails since it only + # improves the data when we don't have it from nmap + await self._mac_vendor_lookup.load_vendors() + self._hass.async_create_task(self._async_scan_devices()) + + def _build_options(self): + """Build the command line and strip out last results that do not need to be updated.""" + options = self._options + if self.home_interval: + boundary = dt_util.now() - self.home_interval + last_results = [ + device for device in self._last_results if device.last_update > boundary + ] + if last_results: + exclude_hosts = self._exclude + [device.ipv4 for device in last_results] + else: + exclude_hosts = self._exclude + else: + last_results = [] + exclude_hosts = self._exclude + if exclude_hosts: + options += f" --exclude {','.join(exclude_hosts)}" + # Report reason + if "--reason" not in options: + options += " --reason" + # Report down hosts + if "-v" not in options: + options += " -v" + self._last_results = last_results + return options + + async def _async_scan_devices(self, *_): + """Scan devices and dispatch.""" + if self._scan_lock.locked(): + _LOGGER.debug( + "Nmap scanning is taking longer than the scheduled interval: %s", + TRACKER_SCAN_INTERVAL, + ) + return + + async with self._scan_lock: + try: + await self._async_run_nmap_scan() + except PortScannerError as ex: + _LOGGER.error("Nmap scanning failed: %s", ex) + + if not self._finished_first_scan: + self._finished_first_scan = True + await self._async_mark_missing_devices_as_not_home() + + async def _async_mark_missing_devices_as_not_home(self): + # After all config entries have finished their first + # scan we mark devices that were not found as not_home + # from unavailable + now = dt_util.now() + for mac_address, original_name in self._known_mac_addresses.items(): + if mac_address in self.devices.tracked: + continue + self.devices.config_entry_owner[mac_address] = self._entry_id + self.devices.tracked[mac_address] = NmapDevice( + mac_address, + None, + original_name, + None, + self._async_get_vendor(mac_address), + "Device not found in initial scan", + now, + 1, + ) + async_dispatcher_send(self._hass, self.signal_device_missing, mac_address) + + def _run_nmap_scan(self): + """Run nmap and return the result.""" + options = self._build_options() + if not self._scanner: + self._scanner = PortScanner() + _LOGGER.debug("Scanning %s with args: %s", self._hosts, options) + for attempt in range(MAX_SCAN_ATTEMPTS): + try: + result = self._scanner.scan( + hosts=" ".join(self._hosts), + arguments=options, + timeout=TRACKER_SCAN_INTERVAL * 10, + ) + break + except PortScannerError as ex: + if attempt < (MAX_SCAN_ATTEMPTS - 1) and NMAP_TRANSIENT_FAILURE in str( + ex + ): + _LOGGER.debug("Nmap saw transient error %s", NMAP_TRANSIENT_FAILURE) + continue + raise + _LOGGER.debug( + "Finished scanning %s with args: %s", + self._hosts, + options, + ) + return result + + @callback + def _async_increment_device_offline(self, ipv4, reason): + """Mark an IP offline.""" + if not (formatted_mac := self.devices.ipv4_last_mac.get(ipv4)): + return + if not (device := self.devices.tracked.get(formatted_mac)): + # Device was unloaded + return + device.offline_scans += 1 + if device.offline_scans < OFFLINE_SCANS_TO_MARK_UNAVAILABLE: + return + device.reason = reason + async_dispatcher_send(self._hass, signal_device_update(formatted_mac), False) + del self.devices.ipv4_last_mac[ipv4] + + async def _async_run_nmap_scan(self): + """Scan the network for devices and dispatch events.""" + result = await self._hass.async_add_executor_job(self._run_nmap_scan) + if self._stopping: + return + + devices = self.devices + entry_id = self._entry_id + now = dt_util.now() + for ipv4, info in result["scan"].items(): + status = info["status"] + reason = status["reason"] + if status["state"] != "up": + self._async_increment_device_offline(ipv4, reason) + continue + # Mac address only returned if nmap ran as root + mac = info["addresses"].get( + "mac" + ) or await self._hass.async_add_executor_job( + partial(get_mac_address, ip=ipv4) + ) + if mac is None: + self._async_increment_device_offline(ipv4, "No MAC address found") + _LOGGER.info("No MAC address found for %s", ipv4) + continue + + formatted_mac = format_mac(mac) + new = formatted_mac not in devices.tracked + if ( + new + and formatted_mac not in devices.tracked + and formatted_mac not in self._known_mac_addresses + ): + continue + + if ( + devices.config_entry_owner.setdefault(formatted_mac, entry_id) + != entry_id + ): + continue + + hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 + vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac) + name = human_readable_name(hostname, vendor, mac) + device = NmapDevice( + formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 + ) + + devices.tracked[formatted_mac] = device + devices.ipv4_last_mac[ipv4] = formatted_mac + self._last_results.append(device) + + if new: + async_dispatcher_send(self._hass, self.signal_device_new, formatted_mac) + else: + async_dispatcher_send( + self._hass, signal_device_update(formatted_mac), True + ) diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py new file mode 100644 index 00000000000..eaea87e775a --- /dev/null +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -0,0 +1,215 @@ +"""Config flow for Nmap Tracker integration.""" +from __future__ import annotations + +from ipaddress import ip_address, ip_network, summarize_address_range +from typing import Any + +import ifaddr +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv +from homeassistant.util import get_local_ip + +from .const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DOMAIN, + TRACKER_SCAN_INTERVAL, +) + +DEFAULT_NETWORK_PREFIX = 24 + + +def get_network(): + """Search adapters for the network.""" + adapters = ifaddr.get_adapters() + local_ip = get_local_ip() + network_prefix = ( + get_ip_prefix_from_adapters(local_ip, adapters) or DEFAULT_NETWORK_PREFIX + ) + return str(ip_network(f"{local_ip}/{network_prefix}", False)) + + +def get_ip_prefix_from_adapters(local_ip, adapters): + """Find the network prefix for an adapter.""" + for adapter in adapters: + for ip_cfg in adapter.ips: + if local_ip == ip_cfg.ip: + return ip_cfg.network_prefix + + +def _normalize_ips_and_network(hosts_str): + """Check if a list of hosts are all ips or ip networks.""" + + normalized_hosts = [] + hosts = [host for host in cv.ensure_list_csv(hosts_str) if host != ""] + + for host in sorted(hosts): + try: + start, end = host.split("-", 1) + if "." not in end: + ip_1, ip_2, ip_3, _ = start.split(".", 3) + end = ".".join([ip_1, ip_2, ip_3, end]) + summarize_address_range(ip_address(start), ip_address(end)) + except ValueError: + pass + else: + normalized_hosts.append(host) + continue + + try: + ip_addr = ip_address(host) + except ValueError: + pass + else: + normalized_hosts.append(str(ip_addr)) + continue + + try: + network = ip_network(host) + except ValueError: + return None + else: + normalized_hosts.append(str(network)) + + return normalized_hosts + + +def normalize_input(user_input): + """Validate hosts and exclude are valid.""" + errors = {} + normalized_hosts = _normalize_ips_and_network(user_input[CONF_HOSTS]) + if not normalized_hosts: + errors[CONF_HOSTS] = "invalid_hosts" + else: + user_input[CONF_HOSTS] = ",".join(normalized_hosts) + + normalized_exclude = _normalize_ips_and_network(user_input[CONF_EXCLUDE]) + if normalized_exclude is None: + errors[CONF_EXCLUDE] = "invalid_hosts" + else: + user_input[CONF_EXCLUDE] = ",".join(normalized_exclude) + + return errors + + +async def _async_build_schema_with_user_input(hass, user_input, include_options): + hosts = user_input.get(CONF_HOSTS, await hass.async_add_executor_job(get_network)) + exclude = user_input.get( + CONF_EXCLUDE, await hass.async_add_executor_job(get_local_ip) + ) + schema = { + vol.Required(CONF_HOSTS, default=hosts): str, + vol.Required( + CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0) + ): int, + vol.Optional(CONF_EXCLUDE, default=exclude): str, + vol.Optional( + CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS) + ): str, + } + if include_options: + schema.update( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL), + ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), + } + ) + return vol.Schema(schema) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for homekit.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + errors = {} + if user_input is not None: + errors = normalize_input(user_input) + self.options.update(user_input) + + if not errors: + return self.async_create_entry( + title=f"Nmap Tracker {self.options[CONF_HOSTS]}", data=self.options + ) + + return self.async_show_form( + step_id="init", + data_schema=await _async_build_schema_with_user_input( + self.hass, self.options, True + ), + errors=errors, + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nmap Tracker.""" + + VERSION = 1 + + def __init__(self): + """Initialize config flow.""" + self.options = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + if not self._async_is_unique_host_list(user_input): + return self.async_abort(reason="already_configured") + + errors = normalize_input(user_input) + self.options.update(user_input) + + if not errors: + return self.async_create_entry( + title=f"Nmap Tracker {user_input[CONF_HOSTS]}", + data={}, + options=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=await _async_build_schema_with_user_input( + self.hass, self.options, False + ), + errors=errors, + ) + + def _async_is_unique_host_list(self, user_input): + hosts = _normalize_ips_and_network(user_input[CONF_HOSTS]) + for entry in self._async_current_entries(): + if _normalize_ips_and_network(entry.options[CONF_HOSTS]) == hosts: + return False + return True + + async def async_step_import(self, user_input=None): + """Handle import from yaml.""" + if not self._async_is_unique_host_list(user_input): + return self.async_abort(reason="already_configured") + + normalize_input(user_input) + + return self.async_create_entry( + title=f"Nmap Tracker {user_input[CONF_HOSTS]}", data={}, options=user_input + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/nmap_tracker/const.py b/homeassistant/components/nmap_tracker/const.py index 88118a81811..f8b467d2f19 100644 --- a/homeassistant/components/nmap_tracker/const.py +++ b/homeassistant/components/nmap_tracker/const.py @@ -9,8 +9,6 @@ NMAP_TRACKED_DEVICES = "nmap_tracked_devices" # Interval in minutes to exclude devices from a scan while they are home CONF_HOME_INTERVAL = "home_interval" CONF_OPTIONS = "scan_options" -DEFAULT_OPTIONS = "-F --host-timeout 5s" +DEFAULT_OPTIONS = "-F -T4 --min-rate 10 --host-timeout 5s" TRACKER_SCAN_INTERVAL = 120 - -DEFAULT_TRACK_NEW_DEVICES = True diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 69c65873e51..fcf9ae6189e 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -1,29 +1,35 @@ """Support for scanning a network with nmap.""" -from collections import namedtuple -from datetime import timedelta -import logging -from getmac import get_mac_address -from nmap import PortScanner, PortScannerError +import logging +from typing import Callable + import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA, - DeviceScanner, + SOURCE_TYPE_ROUTER, ) +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import NmapDeviceScanner, short_hostname, signal_device_update +from .const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DOMAIN, + TRACKER_SCAN_INTERVAL, +) _LOGGER = logging.getLogger(__name__) -# Interval in minutes to exclude devices from a scan while they are home -CONF_HOME_INTERVAL = "home_interval" -CONF_OPTIONS = "scan_options" -DEFAULT_OPTIONS = "-F --host-timeout 5s" - - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOSTS): cv.ensure_list, @@ -34,100 +40,161 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def get_scanner(hass, config): +async def async_get_scanner(hass, config): """Validate the configuration and return a Nmap scanner.""" - return NmapDeviceScanner(config[DOMAIN]) + validated_config = config[DEVICE_TRACKER_DOMAIN] + if CONF_SCAN_INTERVAL in validated_config: + scan_interval = validated_config[CONF_SCAN_INTERVAL].total_seconds() + else: + scan_interval = TRACKER_SCAN_INTERVAL -Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) + import_config = { + CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), + CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], + CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), + CONF_OPTIONS: validated_config[CONF_OPTIONS], + CONF_SCAN_INTERVAL: scan_interval, + } - -class NmapDeviceScanner(DeviceScanner): - """This class scans for devices using nmap.""" - - exclude = [] - - def __init__(self, config): - """Initialize the scanner.""" - self.last_results = [] - - self.hosts = config[CONF_HOSTS] - self.exclude = config[CONF_EXCLUDE] - minutes = config[CONF_HOME_INTERVAL] - self._options = config[CONF_OPTIONS] - self.home_interval = timedelta(minutes=minutes) - - _LOGGER.debug("Scanner initialized") - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - _LOGGER.debug("Nmap last results %s", self.last_results) - - return [device.mac for device in self.last_results] - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - filter_named = [ - result.name for result in self.last_results if result.mac == device - ] - - if filter_named: - return filter_named[0] - return None - - def get_extra_attributes(self, device): - """Return the IP of the given device.""" - filter_ip = next( - (result.ip for result in self.last_results if result.mac == device), None + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=import_config, ) - return {"ip": filter_ip} + ) - def _update_info(self): - """Scan the network for devices. + _LOGGER.warning( + "Your Nmap Tracker configuration has been imported into the UI, " + "please remove it from configuration.yaml. " + ) - Returns boolean if scanning successful. - """ - _LOGGER.debug("Scanning") - scanner = PortScanner() +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Set up device tracker for Nmap Tracker component.""" + nmap_tracker = hass.data[DOMAIN][entry.entry_id] - options = self._options + @callback + def device_new(mac_address): + """Signal a new device.""" + async_add_entities([NmapTrackerEntity(nmap_tracker, mac_address, True)]) - if self.home_interval: - boundary = dt_util.now() - self.home_interval - last_results = [ - device for device in self.last_results if device.last_update > boundary - ] - if last_results: - exclude_hosts = self.exclude + [device.ip for device in last_results] - else: - exclude_hosts = self.exclude - else: - last_results = [] - exclude_hosts = self.exclude - if exclude_hosts: - options += f" --exclude {','.join(exclude_hosts)}" + @callback + def device_missing(mac_address): + """Signal a missing device.""" + async_add_entities([NmapTrackerEntity(nmap_tracker, mac_address, False)]) - try: - result = scanner.scan(hosts=" ".join(self.hosts), arguments=options) - except PortScannerError: - return False + entry.async_on_unload( + async_dispatcher_connect(hass, nmap_tracker.signal_device_new, device_new) + ) + entry.async_on_unload( + async_dispatcher_connect( + hass, nmap_tracker.signal_device_missing, device_missing + ) + ) - now = dt_util.now() - for ipv4, info in result["scan"].items(): - if info["status"]["state"] != "up": - continue - name = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 - # Mac address only returned if nmap ran as root - mac = info["addresses"].get("mac") or get_mac_address(ip=ipv4) - if mac is None: - _LOGGER.info("No MAC address found for %s", ipv4) - continue - last_results.append(Device(mac.upper(), name, ipv4, now)) - self.last_results = last_results +class NmapTrackerEntity(ScannerEntity): + """An Nmap Tracker entity.""" - _LOGGER.debug("nmap scan successful") - return True + def __init__( + self, nmap_tracker: NmapDeviceScanner, mac_address: str, active: bool + ) -> None: + """Initialize an nmap tracker entity.""" + self._mac_address = mac_address + self._nmap_tracker = nmap_tracker + self._tracked = self._nmap_tracker.devices.tracked + self._active = active + + @property + def _device(self) -> bool: + """Get latest device state.""" + return self._tracked[self._mac_address] + + @property + def is_connected(self) -> bool: + """Return device status.""" + return self._active + + @property + def name(self) -> str: + """Return device name.""" + return self._device.name + + @property + def unique_id(self) -> str: + """Return device unique id.""" + return self._mac_address + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self._device.ipv4 + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self._mac_address + + @property + def hostname(self) -> str: + """Return hostname of the device.""" + return short_hostname(self._device.hostname) + + @property + def source_type(self) -> str: + """Return tracker source type.""" + return SOURCE_TYPE_ROUTER + + @property + def device_info(self): + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self._mac_address)}, + "default_manufacturer": self._device.manufacturer, + "default_name": self.name, + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def icon(self): + """Return device icon.""" + return "mdi:lan-connect" if self._active else "mdi:lan-disconnect" + + @callback + def async_process_update(self, online: bool) -> None: + """Update device.""" + self._active = online + + @property + def extra_state_attributes(self): + """Return the attributes.""" + return { + "last_time_reachable": self._device.last_update.isoformat( + timespec="seconds" + ), + "reason": self._device.reason, + } + + @callback + def async_on_demand_update(self, online: bool): + """Update state.""" + self.async_process_update(online) + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Register state update callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + signal_device_update(self._mac_address), + self.async_on_demand_update, + ) + ) diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 9f81c0facaf..ee05843c4fe 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -2,7 +2,13 @@ "domain": "nmap_tracker", "name": "Nmap Tracker", "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", - "requirements": ["python-nmap==0.6.1", "getmac==0.8.2"], - "codeowners": [], - "iot_class": "local_polling" + "requirements": [ + "netmap==0.7.0.2", + "getmac==0.8.2", + "ifaddr==0.1.7", + "mac-vendor-lookup==0.1.11" + ], + "codeowners": ["@bdraco"], + "iot_class": "local_polling", + "config_flow": true } diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index ecb470a6f0d..d42e1067503 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -9,7 +9,6 @@ "home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]", "exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]", "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]", - "track_new_devices": "Track new devices", "interval_seconds": "Scan interval" } } diff --git a/homeassistant/components/nmap_tracker/translations/en.json b/homeassistant/components/nmap_tracker/translations/en.json index 6b83532a0e2..985225414a6 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -29,8 +29,7 @@ "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", "hosts": "Network addresses (comma seperated) to scan", "interval_seconds": "Scan interval", - "scan_options": "Raw configurable scan options for Nmap", - "track_new_devices": "Track new devices" + "scan_options": "Raw configurable scan options for Nmap" }, "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)." } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d125f507d3a..b4a6fcc3775 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -180,6 +180,7 @@ FLOWS = [ "nexia", "nfandroidtv", "nightscout", + "nmap_tracker", "notion", "nuheat", "nuki", diff --git a/requirements_all.txt b/requirements_all.txt index 13018dcc9b7..2a03e1c4855 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -840,6 +840,7 @@ ibmiotf==0.3.4 icmplib==3.0 # homeassistant.components.network +# homeassistant.components.nmap_tracker ifaddr==0.1.7 # homeassistant.components.iglo @@ -941,6 +942,9 @@ lw12==0.9.2 # homeassistant.components.lyft lyft_rides==0.2 +# homeassistant.components.nmap_tracker +mac-vendor-lookup==0.1.11 + # homeassistant.components.magicseaweed magicseaweed==1.0.3 @@ -1019,6 +1023,9 @@ netdata==0.2.0 # homeassistant.components.discovery netdisco==2.9.0 +# homeassistant.components.nmap_tracker +netmap==0.7.0.2 + # homeassistant.components.nam nettigo-air-monitor==1.0.0 @@ -1871,9 +1878,6 @@ python-mystrom==1.1.2 # homeassistant.components.nest python-nest==4.1.0 -# homeassistant.components.nmap_tracker -python-nmap==0.6.1 - # homeassistant.components.ozw python-openzwave-mqtt[mqtt-client]==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09b17f6415e..706dcf1da77 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -486,6 +486,7 @@ iaqualink==0.3.90 icmplib==3.0 # homeassistant.components.network +# homeassistant.components.nmap_tracker ifaddr==0.1.7 # homeassistant.components.influxdb @@ -527,6 +528,9 @@ logi_circle==0.2.2 # homeassistant.components.luftdaten luftdaten==0.6.5 +# homeassistant.components.nmap_tracker +mac-vendor-lookup==0.1.11 + # homeassistant.components.maxcube maxcube-api==0.4.3 @@ -575,6 +579,9 @@ nessclient==0.9.15 # homeassistant.components.discovery netdisco==2.9.0 +# homeassistant.components.nmap_tracker +netmap==0.7.0.2 + # homeassistant.components.nam nettigo-air-monitor==1.0.0 diff --git a/tests/components/nmap_tracker/__init__.py b/tests/components/nmap_tracker/__init__.py new file mode 100644 index 00000000000..f5e0c85df31 --- /dev/null +++ b/tests/components/nmap_tracker/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nmap Tracker integration.""" diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py new file mode 100644 index 00000000000..6365dd7407a --- /dev/null +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -0,0 +1,301 @@ +"""Test the Nmap Tracker config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.nmap_tracker.const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DOMAIN, +) +from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS +from homeassistant.core import CoreState, HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "hosts", ["1.1.1.1", "192.168.1.0/24", "192.168.1.0/24,192.168.2.0/24"] +) +async def test_form(hass: HomeAssistant, hosts: str) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + schema_defaults = result["data_schema"]({}) + assert CONF_SCAN_INTERVAL not in schema_defaults + + with patch( + "homeassistant.components.nmap_tracker.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS: hosts, + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == f"Nmap Tracker {hosts}" + assert result2["data"] == {} + assert result2["options"] == { + CONF_HOSTS: hosts, + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_range(hass: HomeAssistant) -> None: + """Test we get the form and can take an ip range.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.nmap_tracker.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS: "192.168.0.5-12", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Nmap Tracker 192.168.0.5-12" + assert result2["data"] == {} + assert result2["options"] == { + CONF_HOSTS: "192.168.0.5-12", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_hosts(hass: HomeAssistant) -> None: + """Test invalid hosts passed in.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS: "not an ip block", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_HOSTS: "invalid_hosts"} + + +async def test_form_already_configured(hass: HomeAssistant) -> None: + """Test duplicate host list.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_HOSTS: "192.168.0.0/20", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + }, + ) + config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS: "192.168.0.0/20", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + +async def test_form_invalid_excludes(hass: HomeAssistant) -> None: + """Test invalid excludes passed in.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS: "3.3.3.3", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "not an exclude", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_EXCLUDE: "invalid_hosts"} + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test we can edit options.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_HOSTS: "192.168.1.0/24", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + }, + ) + config_entry.add_to_hass(hass) + hass.state = CoreState.stopped + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + assert result["data_schema"]({}) == { + CONF_EXCLUDE: "4.4.4.4", + CONF_HOME_INTERVAL: 3, + CONF_HOSTS: "192.168.1.0/24", + CONF_SCAN_INTERVAL: 120, + CONF_OPTIONS: "-F -T4 --min-rate 10 --host-timeout 5s", + } + + with patch( + "homeassistant.components.nmap_tracker.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_HOSTS: "192.168.1.0/24, 192.168.2.0/24", + CONF_HOME_INTERVAL: 5, + CONF_OPTIONS: "-sn", + CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", + CONF_SCAN_INTERVAL: 10, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24", + CONF_HOME_INTERVAL: 5, + CONF_OPTIONS: "-sn", + CONF_EXCLUDE: "4.4.4.4,5.5.5.5", + CONF_SCAN_INTERVAL: 10, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass: HomeAssistant) -> None: + """Test we can import from yaml.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.nmap_tracker.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOSTS: "1.2.3.4/20", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", + CONF_SCAN_INTERVAL: 2000, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == "Nmap Tracker 1.2.3.4/20" + assert result["data"] == {} + assert result["options"] == { + CONF_HOSTS: "1.2.3.4/20", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4,6.4.3.2", + CONF_SCAN_INTERVAL: 2000, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_aborts_if_matching(hass: HomeAssistant) -> None: + """Test we can import from yaml.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_HOSTS: "192.168.0.0/20", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + }, + ) + config_entry.add_to_hass(hass) + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOSTS: "192.168.0.0/20", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" From bb4a36c8772ba139a26714ce13d5be29118b8921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 16 Aug 2021 23:47:37 +0300 Subject: [PATCH 421/903] Upgrade mypy to 0.910 and types-* (#54574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel Hjelseth Høyer --- requirements_test.txt | 33 ++++++++++++++++++--------------- script/hassfest/mypy_config.py | 4 +++- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index d843745cbbf..63e102ec77e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,13 +2,16 @@ # make new things fail. Manually update these pins when pulling in a # new version +# types-* that have versions roughly corresponding to the packages they +# contain hints for available should be kept in sync with them + -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt codecov==2.1.12 coverage==5.5 jsonpickle==1.4.1 mock-open==1.4.0 -mypy==0.902 +mypy==0.910 pre-commit==2.14.0 pylint==2.9.5 pipdeptree==1.0.0 @@ -25,19 +28,19 @@ responses==0.12.0 respx==0.17.0 stdlib-list==0.7.0 tqdm==4.49.0 -types-backports==0.1.2 -types-certifi==0.1.3 -types-chardet==0.1.2 +types-backports==0.1.3 +types-certifi==0.1.4 +types-chardet==0.1.5 types-cryptography==3.3.2 -types-decorator==0.1.4 -types-emoji==1.2.1 -types-enum34==0.1.5 -types-ipaddress==0.1.2 +types-decorator==0.1.7 +types-emoji==1.2.4 +types-enum34==0.1.8 +types-ipaddress==0.1.5 types-jwt==0.1.3 -types-pkg-resources==0.1.2 -types-python-slugify==0.1.0 -types-pytz==0.1.1 -types-PyYAML==5.4.1 -types-requests==0.1.11 -types-toml==0.1.2 -types-ujson==0.1.0 +types-pkg-resources==0.1.3 +types-python-slugify==0.1.2 +types-pytz==2021.1.2 +types-PyYAML==5.4.6 +types-requests==2.25.1 +types-toml==0.1.5 +types-ujson==0.1.1 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 1b24a935084..a69a9ec8d88 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -195,7 +195,9 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { } # This is basically the list of checks which is enabled for "strict=true". -# But "strict=true" is applied globally, so we need to list all checks manually. +# "strict=false" in config files does not turn strict settings off if they've been +# set in a more general section (it instead means as if strict was not specified at +# all), so we need to list all checks manually to be able to flip them wholesale. STRICT_SETTINGS: Final[list[str]] = [ "check_untyped_defs", "disallow_incomplete_defs", From f9fbcd4aec7192cc0763aac1dc7d9eac0131501f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 16 Aug 2021 22:52:47 +0200 Subject: [PATCH 422/903] Use EntityDescription - qbittorrent (#54428) --- .../components/qbittorrent/sensor.py | 98 ++++++++++--------- 1 file changed, 50 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 5f57cd19cfe..4663b203248 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -1,11 +1,17 @@ """Support for monitoring the qBittorrent API.""" +from __future__ import annotations + import logging from qbittorrent.client import Client, LoginRequired from requests.exceptions import RequestException import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -25,11 +31,22 @@ SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" DEFAULT_NAME = "qBittorrent" -SENSOR_TYPES = { - SENSOR_TYPE_CURRENT_STATUS: ["Status", None], - SENSOR_TYPE_DOWNLOAD_SPEED: ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], - SENSOR_TYPE_UPLOAD_SPEED: ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TYPE_CURRENT_STATUS, + name="Status", + ), + SensorEntityDescription( + key=SENSOR_TYPE_DOWNLOAD_SPEED, + name="Down Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), + SensorEntityDescription( + key=SENSOR_TYPE_UPLOAD_SPEED, + name="Up Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -56,12 +73,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config.get(CONF_NAME) - dev = [] - for sensor_type in SENSOR_TYPES: - sensor = QBittorrentSensor(sensor_type, client, name, LoginRequired) - dev.append(sensor) + entities = [ + QBittorrentSensor(description, client, name, LoginRequired) + for description in SENSOR_TYPES + ] - add_entities(dev, True) + add_entities(entities, True) def format_speed(speed): @@ -73,45 +90,29 @@ def format_speed(speed): class QBittorrentSensor(SensorEntity): """Representation of an qBittorrent sensor.""" - def __init__(self, sensor_type, qbittorrent_client, client_name, exception): + def __init__( + self, + description: SensorEntityDescription, + qbittorrent_client, + client_name, + exception, + ): """Initialize the qBittorrent sensor.""" - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.client = qbittorrent_client - self.type = sensor_type - self.client_name = client_name - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._available = False self._exception = exception - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return true if device is available.""" - return self._available - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_name = f"{client_name} {description.name}" + self._attr_available = False def update(self): """Get the latest data from qBittorrent and updates the state.""" try: data = self.client.sync_main_data() - self._available = True + self._attr_available = True except RequestException: _LOGGER.error("Connection lost") - self._available = False + self._attr_available = False return except self._exception: _LOGGER.error("Invalid authentication") @@ -123,17 +124,18 @@ class QBittorrentSensor(SensorEntity): download = data["server_state"]["dl_info_speed"] upload = data["server_state"]["up_info_speed"] - if self.type == SENSOR_TYPE_CURRENT_STATUS: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TYPE_CURRENT_STATUS: if upload > 0 and download > 0: - self._state = "up_down" + self._attr_native_value = "up_down" elif upload > 0 and download == 0: - self._state = "seeding" + self._attr_native_value = "seeding" elif upload == 0 and download > 0: - self._state = "downloading" + self._attr_native_value = "downloading" else: - self._state = STATE_IDLE + self._attr_native_value = STATE_IDLE - elif self.type == SENSOR_TYPE_DOWNLOAD_SPEED: - self._state = format_speed(download) - elif self.type == SENSOR_TYPE_UPLOAD_SPEED: - self._state = format_speed(upload) + elif sensor_type == SENSOR_TYPE_DOWNLOAD_SPEED: + self._attr_native_value = format_speed(download) + elif sensor_type == SENSOR_TYPE_UPLOAD_SPEED: + self._attr_native_value = format_speed(upload) From 236ccb933c4664804496de54a8a7df732a7ef163 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 16 Aug 2021 22:54:56 +0200 Subject: [PATCH 423/903] Use EntityDescription - point (#54363) --- homeassistant/components/point/sensor.py | 87 +++++++++++++++++------- 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 87981d7b29e..8d4ee69fca2 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -1,7 +1,14 @@ """Support for Minut Point sensors.""" +from __future__ import annotations + +from dataclasses import dataclass import logging -from homeassistant.components.sensor import DOMAIN, SensorEntity +from homeassistant.components.sensor import ( + DOMAIN, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, @@ -21,12 +28,48 @@ _LOGGER = logging.getLogger(__name__) DEVICE_CLASS_SOUND = "sound_level" -SENSOR_TYPES = { - DEVICE_CLASS_TEMPERATURE: (None, 1, TEMP_CELSIUS), - DEVICE_CLASS_PRESSURE: (None, 0, PRESSURE_HPA), - DEVICE_CLASS_HUMIDITY: (None, 1, PERCENTAGE), - DEVICE_CLASS_SOUND: ("mdi:ear-hearing", 1, SOUND_PRESSURE_WEIGHTED_DBA), -} + +@dataclass +class MinutPointRequiredKeysMixin: + """Mixin for required keys.""" + + precision: int + + +@dataclass +class MinutPointSensorEntityDescription( + SensorEntityDescription, MinutPointRequiredKeysMixin +): + """Describes MinutPoint sensor entity.""" + + +SENSOR_TYPES: tuple[MinutPointSensorEntityDescription, ...] = ( + MinutPointSensorEntityDescription( + key="temperature", + precision=1, + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + MinutPointSensorEntityDescription( + key="pressure", + precision=0, + device_class=DEVICE_CLASS_PRESSURE, + native_unit_of_measurement=PRESSURE_HPA, + ), + MinutPointSensorEntityDescription( + key="humidity", + precision=1, + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + ), + MinutPointSensorEntityDescription( + key="sound", + precision=1, + device_class=DEVICE_CLASS_SOUND, + icon="mdi:ear-hearing", + native_unit_of_measurement=SOUND_PRESSURE_WEIGHTED_DBA, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -36,10 +79,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Discover and add a discovered sensor.""" client = hass.data[POINT_DOMAIN][config_entry.entry_id] async_add_entities( - ( - MinutPointSensor(client, device_id, sensor_type) - for sensor_type in SENSOR_TYPES - ), + [ + MinutPointSensor(client, device_id, description) + for description in SENSOR_TYPES + ], True, ) @@ -51,10 +94,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class MinutPointSensor(MinutPointEntity, SensorEntity): """The platform class required by Home Assistant.""" - def __init__(self, point_client, device_id, device_class): + entity_description: MinutPointSensorEntityDescription + + def __init__( + self, point_client, device_id, description: MinutPointSensorEntityDescription + ): """Initialize the sensor.""" - super().__init__(point_client, device_id, device_class) - self._device_prop = SENSOR_TYPES[device_class] + super().__init__(point_client, device_id, description.device_class) + self.entity_description = description async def _update_callback(self): """Update the value of the sensor.""" @@ -64,19 +111,9 @@ class MinutPointSensor(MinutPointEntity, SensorEntity): self._updated = parse_datetime(self.device.last_update) self.async_write_ha_state() - @property - def icon(self): - """Return the icon representation.""" - return self._device_prop[0] - @property def native_value(self): """Return the state of the sensor.""" if self.value is None: return None - return round(self.value, self._device_prop[1]) - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._device_prop[2] + return round(self.value, self.entity_description.precision) From b72ed68d61efdd8b52d17a27d2c2768a01959a4c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 16 Aug 2021 22:55:52 +0200 Subject: [PATCH 424/903] Activate mypy in sabnzbd (#54539) --- homeassistant/components/sabnzbd/__init__.py | 4 +++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 8574e82aa47..a420ca53814 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,4 +1,6 @@ """Support for monitoring an SABnzbd NZB client.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -31,7 +33,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "sabnzbd" DATA_SABNZBD = "sabznbd" -_CONFIGURING = {} +_CONFIGURING: dict[str, str] = {} ATTR_SPEED = "speed" BASE_URL_FORMAT = "{}://{}:{}/" diff --git a/mypy.ini b/mypy.ini index 91b40b63cc1..0f025345638 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1598,9 +1598,6 @@ ignore_errors = true [mypy-homeassistant.components.ruckus_unleashed.*] ignore_errors = true -[mypy-homeassistant.components.sabnzbd.*] -ignore_errors = true - [mypy-homeassistant.components.screenlogic.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index a69a9ec8d88..508c1fcb26a 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -125,7 +125,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.ring.*", "homeassistant.components.rpi_power.*", "homeassistant.components.ruckus_unleashed.*", - "homeassistant.components.sabnzbd.*", "homeassistant.components.screenlogic.*", "homeassistant.components.search.*", "homeassistant.components.sense.*", From 848c0be58a45371cb2b9a03398075ef00d136331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 17 Aug 2021 00:12:06 +0300 Subject: [PATCH 425/903] Avoid some implicit generic Anys (#54577) --- homeassistant/auth/auth_store.py | 4 +++- homeassistant/auth/providers/__init__.py | 4 +++- homeassistant/auth/providers/command_line.py | 4 +++- homeassistant/auth/providers/homeassistant.py | 4 +++- .../auth/providers/insecure_example.py | 6 ++++-- .../auth/providers/legacy_api_password.py | 6 ++++-- .../auth/providers/trusted_networks.py | 4 +++- homeassistant/config_entries.py | 14 +++++++++++--- homeassistant/exceptions.py | 10 ++++++---- homeassistant/helpers/reload.py | 17 +++++++++++------ homeassistant/helpers/script_variables.py | 4 +++- homeassistant/scripts/__init__.py | 4 ++-- homeassistant/setup.py | 11 ++++++++--- homeassistant/util/color.py | 6 +++++- 14 files changed, 69 insertions(+), 29 deletions(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 0b360668ad4..63cbeb1bf7e 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -17,6 +17,8 @@ from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY, GROUP_ID_USER from .permissions import PermissionLookup, system_policies from .permissions.types import PolicyType +# mypy: disallow-any-generics + STORAGE_VERSION = 1 STORAGE_KEY = "auth" GROUP_NAME_ADMIN = "Administrators" @@ -491,7 +493,7 @@ class AuthStore: self._store.async_delay_save(self._data_to_save, 1) @callback - def _data_to_save(self) -> dict: + def _data_to_save(self) -> dict[str, list[dict[str, Any]]]: """Return the data to store.""" assert self._users is not None assert self._groups is not None diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index d2dfa0e1c6d..4faa277a081 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -22,6 +22,8 @@ from ..auth_store import AuthStore from ..const import MFA_SESSION_EXPIRATION from ..models import Credentials, RefreshToken, User, UserMeta +# mypy: disallow-any-generics + _LOGGER = logging.getLogger(__name__) DATA_REQS = "auth_prov_reqs_processed" @@ -96,7 +98,7 @@ class AuthProvider: # Implement by extending class - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return the data flow for logging in with auth provider. Auth provider should extend LoginFlow and return an instance. diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index f462ad4be9d..6d1a1627fd5 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -17,6 +17,8 @@ from homeassistant.exceptions import HomeAssistantError from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + CONF_ARGS = "args" CONF_META = "meta" @@ -56,7 +58,7 @@ class CommandLineAuthProvider(AuthProvider): super().__init__(*args, **kwargs) self._user_meta: dict[str, dict[str, Any]] = {} - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return CommandLineLoginFlow(self) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index dfbf077a89d..b08c59bf3aa 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -19,6 +19,8 @@ from homeassistant.exceptions import HomeAssistantError from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + STORAGE_VERSION = 1 STORAGE_KEY = "auth_provider.homeassistant" @@ -235,7 +237,7 @@ class HassAuthProvider(AuthProvider): await data.async_load() self.data = data - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return HassLoginFlow(self) diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index 5a3a890ff66..fb390b65b0d 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Mapping import hmac -from typing import cast +from typing import Any, cast import voluptuous as vol @@ -15,6 +15,8 @@ from homeassistant.exceptions import HomeAssistantError from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + USER_SCHEMA = vol.Schema( { vol.Required("username"): str, @@ -37,7 +39,7 @@ class InvalidAuthError(HomeAssistantError): class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return ExampleLoginFlow(self) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index b385aa0ed59..af24506210b 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -7,7 +7,7 @@ from __future__ import annotations from collections.abc import Mapping import hmac -from typing import cast +from typing import Any, cast import voluptuous as vol @@ -19,6 +19,8 @@ import homeassistant.helpers.config_validation as cv from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + AUTH_PROVIDER_TYPE = "legacy_api_password" CONF_API_PASSWORD = "api_password" @@ -44,7 +46,7 @@ class LegacyApiPasswordAuthProvider(AuthProvider): """Return api_password.""" return str(self.config[CONF_API_PASSWORD]) - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return LegacyLoginFlow(self) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 7b609f371ef..a9ee6a48335 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -27,6 +27,8 @@ from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from .. import InvalidAuthError from ..models import Credentials, RefreshToken, UserMeta +# mypy: disallow-any-generics + IPAddress = Union[IPv4Address, IPv6Address] IPNetwork = Union[IPv4Network, IPv6Network] @@ -97,7 +99,7 @@ class TrustedNetworksAuthProvider(AuthProvider): """Trusted Networks auth provider does not support MFA.""" return False - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" assert context is not None ip_addr = cast(IPAddress, context.get("ip_address")) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ec074f81b95..07cb9eae7f9 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -21,7 +21,12 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.event import Event -from homeassistant.helpers.typing import UNDEFINED, DiscoveryInfoType, UndefinedType +from homeassistant.helpers.typing import ( + UNDEFINED, + ConfigType, + DiscoveryInfoType, + UndefinedType, +) from homeassistant.setup import async_process_deps_reqs, async_setup_component from homeassistant.util.decorator import Registry import homeassistant.util.uuid as uuid_util @@ -598,7 +603,10 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): """Manage all the config entry flows that are in progress.""" def __init__( - self, hass: HomeAssistant, config_entries: ConfigEntries, hass_config: dict + self, + hass: HomeAssistant, + config_entries: ConfigEntries, + hass_config: ConfigType, ) -> None: """Initialize the config entry flow manager.""" super().__init__(hass) @@ -748,7 +756,7 @@ class ConfigEntries: An instance of this object is available via `hass.config_entries`. """ - def __init__(self, hass: HomeAssistant, hass_config: dict) -> None: + def __init__(self, hass: HomeAssistant, hass_config: ConfigType) -> None: """Initialize the entry manager.""" self.hass = hass self.flow = ConfigEntriesFlowManager(hass, self, hass_config) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 844fd369cac..2a82c2652ed 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -9,6 +9,8 @@ import attr if TYPE_CHECKING: from .core import Context +# mypy: disallow-any-generics + class HomeAssistantError(Exception): """General Home Assistant exception occurred.""" @@ -42,7 +44,7 @@ class ConditionError(HomeAssistantError): """Return indentation.""" return " " * indent + message - def output(self, indent: int) -> Generator: + def output(self, indent: int) -> Generator[str, None, None]: """Yield an indented representation.""" raise NotImplementedError() @@ -58,7 +60,7 @@ class ConditionErrorMessage(ConditionError): # A message describing this error message: str = attr.ib() - def output(self, indent: int) -> Generator: + def output(self, indent: int) -> Generator[str, None, None]: """Yield an indented representation.""" yield self._indent(indent, f"In '{self.type}' condition: {self.message}") @@ -74,7 +76,7 @@ class ConditionErrorIndex(ConditionError): # The error that this error wraps error: ConditionError = attr.ib() - def output(self, indent: int) -> Generator: + def output(self, indent: int) -> Generator[str, None, None]: """Yield an indented representation.""" if self.total > 1: yield self._indent( @@ -93,7 +95,7 @@ class ConditionErrorContainer(ConditionError): # List of ConditionErrors that this error wraps errors: Sequence[ConditionError] = attr.ib() - def output(self, indent: int) -> Generator: + def output(self, indent: int) -> Generator[str, None, None]: """Yield an indented representation.""" for item in self.errors: yield from item.output(indent) diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index da6c6935b35..cedd07676ba 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable import logging +from typing import Any from homeassistant import config as conf_util from homeassistant.const import SERVICE_RELOAD @@ -15,11 +16,13 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component +# mypy: disallow-any-generics + _LOGGER = logging.getLogger(__name__) async def async_reload_integration_platforms( - hass: HomeAssistant, integration_name: str, integration_platforms: Iterable + hass: HomeAssistant, integration_name: str, integration_platforms: Iterable[str] ) -> None: """Reload an integration's platforms. @@ -62,7 +65,7 @@ async def _resetup_platform( if not conf: return - root_config: dict = {integration_platform: []} + root_config: dict[str, Any] = {integration_platform: []} # Extract only the config for template, ignore the rest. for p_type, p_config in config_per_platform(conf, integration_platform): if p_type != integration_name: @@ -102,7 +105,7 @@ async def _async_setup_platform( hass: HomeAssistant, integration_name: str, integration_platform: str, - platform_configs: list[dict], + platform_configs: list[dict[str, Any]], ) -> None: """Platform for the first time when new configuration is added.""" if integration_platform not in hass.data: @@ -120,7 +123,7 @@ async def _async_setup_platform( async def _async_reconfig_platform( - platform: EntityPlatform, platform_configs: list[dict] + platform: EntityPlatform, platform_configs: list[dict[str, Any]] ) -> None: """Reconfigure an already loaded platform.""" await platform.async_reset() @@ -155,7 +158,7 @@ def async_get_platform_without_config_entry( async def async_setup_reload_service( - hass: HomeAssistant, domain: str, platforms: Iterable + hass: HomeAssistant, domain: str, platforms: Iterable[str] ) -> None: """Create the reload service for the domain.""" if hass.services.has_service(domain, SERVICE_RELOAD): @@ -171,7 +174,9 @@ async def async_setup_reload_service( ) -def setup_reload_service(hass: HomeAssistant, domain: str, platforms: Iterable) -> None: +def setup_reload_service( + hass: HomeAssistant, domain: str, platforms: Iterable[str] +) -> None: """Sync version of async_setup_reload_service.""" asyncio.run_coroutine_threadsafe( async_setup_reload_service(hass, domain, platforms), diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py index 23241f22d1e..3dae84166f6 100644 --- a/homeassistant/helpers/script_variables.py +++ b/homeassistant/helpers/script_variables.py @@ -8,6 +8,8 @@ from homeassistant.core import HomeAssistant, callback from . import template +# mypy: disallow-any-generics + class ScriptVariables: """Class to hold and render script variables.""" @@ -65,6 +67,6 @@ class ScriptVariables: return rendered_variables - def as_dict(self) -> dict: + def as_dict(self) -> dict[str, Any]: """Return dict version of this class.""" return self.variables diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index b31fc718173..69ca1d6083b 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -15,10 +15,10 @@ from homeassistant.config import get_default_config_dir from homeassistant.requirements import pip_kwargs from homeassistant.util.package import install_package, is_installed, is_virtual_env -# mypy: allow-untyped-defs, no-warn-return-any +# mypy: allow-untyped-defs, disallow-any-generics, no-warn-return-any -def run(args: list) -> int: +def run(args: list[str]) -> int: """Run a script.""" scripts = [] path = os.path.dirname(__file__) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 9575a4331b8..95bb29c4b9d 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -16,10 +16,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, PLATFORM_FORMAT, ) +from homeassistant.core import CALLBACK_TYPE from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, ensure_unique_string +# mypy: disallow-any-generics + _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT = "component" @@ -422,7 +425,7 @@ def _async_when_setup( hass.async_create_task(when_setup()) return - listeners: list[Callable] = [] + listeners: list[CALLBACK_TYPE] = [] async def _matched_event(event: core.Event) -> None: """Call the callback when we matched an event.""" @@ -443,7 +446,7 @@ def _async_when_setup( @core.callback -def async_get_loaded_integrations(hass: core.HomeAssistant) -> set: +def async_get_loaded_integrations(hass: core.HomeAssistant) -> set[str]: """Return the complete list of loaded integrations.""" integrations = set() for component in hass.config.components: @@ -457,7 +460,9 @@ def async_get_loaded_integrations(hass: core.HomeAssistant) -> set: @contextlib.contextmanager -def async_start_setup(hass: core.HomeAssistant, components: Iterable) -> Generator: +def async_start_setup( + hass: core.HomeAssistant, components: Iterable[str] +) -> Generator[None, None, None]: """Keep track of when setup starts and finishes.""" setup_started = hass.data.setdefault(DATA_SETUP_STARTED, {}) started = dt_util.utcnow() diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 47144f0e782..c81beddb07a 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -6,6 +6,8 @@ import math import attr +# mypy: disallow-any-generics + # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 # names do not have spaces in them so that we can compare against @@ -392,7 +394,9 @@ def color_hs_to_xy( return color_RGB_to_xy(*color_hs_to_RGB(iH, iS), Gamut) -def _match_max_scale(input_colors: tuple, output_colors: tuple) -> tuple: +def _match_max_scale( + input_colors: tuple[int, ...], output_colors: tuple[int, ...] +) -> tuple[int, ...]: """Match the maximum value of the output to the input.""" max_in = max(input_colors) max_out = max(output_colors) From 85ff5e34cd4a06be8dfa96b5d9959be91f0161d3 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 16 Aug 2021 23:25:41 +0200 Subject: [PATCH 426/903] Active mypy for netio (#54543) --- homeassistant/components/netio/switch.py | 7 +++++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index c39b1598c89..88da77cbf90 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -1,7 +1,10 @@ """The Netio switch component.""" +from __future__ import annotations + from collections import namedtuple from datetime import timedelta import logging +from typing import Any from pynetio import Netio import voluptuous as vol @@ -29,8 +32,8 @@ CONF_OUTLETS = "outlets" DEFAULT_PORT = 1234 DEFAULT_USERNAME = "admin" -Device = namedtuple("device", ["netio", "entities"]) -DEVICES = {} +Device = namedtuple("Device", ["netio", "entities"]) +DEVICES: dict[str, Any] = {} MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) diff --git a/mypy.ini b/mypy.ini index 0f025345638..a12719f90df 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1514,9 +1514,6 @@ ignore_errors = true [mypy-homeassistant.components.nest.legacy.*] ignore_errors = true -[mypy-homeassistant.components.netio.*] -ignore_errors = true - [mypy-homeassistant.components.nightscout.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 508c1fcb26a..dc00be3efe4 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -97,7 +97,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.mullvad.*", "homeassistant.components.ness_alarm.*", "homeassistant.components.nest.legacy.*", - "homeassistant.components.netio.*", "homeassistant.components.nightscout.*", "homeassistant.components.nilu.*", "homeassistant.components.nmap_tracker.*", From de0460de6170b360fe6746e5f2963a04e6063547 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 16 Aug 2021 22:33:28 +0100 Subject: [PATCH 427/903] Add device classes that were part of deprecated air quality entity (#54075) --- homeassistant/components/sensor/__init__.py | 18 +++++++++++ .../components/sensor/device_condition.py | 32 +++++++++++++++++++ .../components/sensor/device_trigger.py | 32 +++++++++++++++++++ homeassistant/components/sensor/strings.json | 16 ++++++++++ homeassistant/const.py | 9 ++++++ .../components/sensor/test_device_trigger.py | 2 +- .../custom_components/test/sensor.py | 9 ++++++ 7 files changed, 117 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 087328ed4a6..950af5a1375 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CO, DEVICE_CLASS_CO2, @@ -21,10 +22,18 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_MONETARY, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_NITROGEN_MONOXIDE, + DEVICE_CLASS_NITROUS_OXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, @@ -51,6 +60,7 @@ ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" SCAN_INTERVAL: Final = timedelta(seconds=30) DEVICE_CLASSES: Final[list[str]] = [ + DEVICE_CLASS_AQI, # Air Quality Index DEVICE_CLASS_BATTERY, # % of battery that is left DEVICE_CLASS_CO, # ppm (parts per million) Carbon Monoxide gas concentration DEVICE_CLASS_CO2, # ppm (parts per million) Carbon Dioxide gas concentration @@ -59,7 +69,15 @@ DEVICE_CLASSES: Final[list[str]] = [ DEVICE_CLASS_HUMIDITY, # % of humidity in the air DEVICE_CLASS_ILLUMINANCE, # current light level (lx/lm) DEVICE_CLASS_MONETARY, # Amount of money (currency) + DEVICE_CLASS_OZONE, # Amount of O3 (µg/m³) + DEVICE_CLASS_NITROGEN_DIOXIDE, # Amount of NO2 (µg/m³) + DEVICE_CLASS_NITROUS_OXIDE, # Amount of NO (µg/m³) + DEVICE_CLASS_NITROGEN_MONOXIDE, # Amount of N2O (µg/m³) + DEVICE_CLASS_PM1, # Particulate matter <= 0.1 μm (µg/m³) + DEVICE_CLASS_PM10, # Particulate matter <= 10 μm (µg/m³) + DEVICE_CLASS_PM25, # Particulate matter <= 2.5 μm (µg/m³) DEVICE_CLASS_SIGNAL_STRENGTH, # signal strength (dB/dBm) + DEVICE_CLASS_SULPHUR_DIOXIDE, # Amount of SO2 (µg/m³) DEVICE_CLASS_TEMPERATURE, # temperature (C/F) DEVICE_CLASS_TIMESTAMP, # timestamp (ISO8601) DEVICE_CLASS_PRESSURE, # pressure (hPa/mbar) diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 3b9f3839cfb..dee20405e07 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -19,10 +19,18 @@ from homeassistant.const import ( DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_NITROGEN_MONOXIDE, + DEVICE_CLASS_NITROUS_OXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, ) @@ -49,10 +57,18 @@ CONF_IS_ENERGY = "is_energy" CONF_IS_HUMIDITY = "is_humidity" CONF_IS_GAS = "is_gas" CONF_IS_ILLUMINANCE = "is_illuminance" +CONF_IS_NITROGEN_DIOXIDE = "is_nitrogen_dioxide" +CONF_IS_NITROGEN_MONOXIDE = "is_nitrogen_monoxide" +CONF_IS_NITROUS_OXIDE = "is_nitrous_oxide" +CONF_IS_OZONE = "is_ozone" +CONF_IS_PM1 = "is_pm1" +CONF_IS_PM10 = "is_pm10" +CONF_IS_PM25 = "is_pm25" CONF_IS_POWER = "is_power" CONF_IS_POWER_FACTOR = "is_power_factor" CONF_IS_PRESSURE = "is_pressure" CONF_IS_SIGNAL_STRENGTH = "is_signal_strength" +CONF_IS_SULPHUR_DIOXIDE = "is_sulphur_dioxide" CONF_IS_TEMPERATURE = "is_temperature" CONF_IS_VOLTAGE = "is_voltage" CONF_IS_VALUE = "is_value" @@ -66,10 +82,18 @@ ENTITY_CONDITIONS = { DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_IS_GAS}], DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_IS_HUMIDITY}], DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_IS_ILLUMINANCE}], + DEVICE_CLASS_NITROGEN_DIOXIDE: [{CONF_TYPE: CONF_IS_NITROGEN_DIOXIDE}], + DEVICE_CLASS_NITROGEN_MONOXIDE: [{CONF_TYPE: CONF_IS_NITROGEN_MONOXIDE}], + DEVICE_CLASS_NITROUS_OXIDE: [{CONF_TYPE: CONF_IS_NITROUS_OXIDE}], + DEVICE_CLASS_OZONE: [{CONF_TYPE: CONF_IS_OZONE}], DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_IS_POWER}], DEVICE_CLASS_POWER_FACTOR: [{CONF_TYPE: CONF_IS_POWER_FACTOR}], + DEVICE_CLASS_PM1: [{CONF_TYPE: CONF_IS_PM1}], + DEVICE_CLASS_PM10: [{CONF_TYPE: CONF_IS_PM10}], + DEVICE_CLASS_PM25: [{CONF_TYPE: CONF_IS_PM25}], DEVICE_CLASS_PRESSURE: [{CONF_TYPE: CONF_IS_PRESSURE}], DEVICE_CLASS_SIGNAL_STRENGTH: [{CONF_TYPE: CONF_IS_SIGNAL_STRENGTH}], + DEVICE_CLASS_SULPHUR_DIOXIDE: [{CONF_TYPE: CONF_IS_SULPHUR_DIOXIDE}], DEVICE_CLASS_TEMPERATURE: [{CONF_TYPE: CONF_IS_TEMPERATURE}], DEVICE_CLASS_VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_VALUE}], @@ -89,10 +113,18 @@ CONDITION_SCHEMA = vol.All( CONF_IS_GAS, CONF_IS_HUMIDITY, CONF_IS_ILLUMINANCE, + CONF_IS_OZONE, + CONF_IS_NITROGEN_DIOXIDE, + CONF_IS_NITROGEN_MONOXIDE, + CONF_IS_NITROUS_OXIDE, CONF_IS_POWER, CONF_IS_POWER_FACTOR, + CONF_IS_PM1, + CONF_IS_PM10, + CONF_IS_PM25, CONF_IS_PRESSURE, CONF_IS_SIGNAL_STRENGTH, + CONF_IS_SULPHUR_DIOXIDE, CONF_IS_TEMPERATURE, CONF_IS_VOLTAGE, CONF_IS_VALUE, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index f7d72dd4c1b..2de09c01bc1 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -22,10 +22,18 @@ from homeassistant.const import ( DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_NITROGEN_MONOXIDE, + DEVICE_CLASS_NITROUS_OXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, ) @@ -48,10 +56,18 @@ CONF_ENERGY = "energy" CONF_GAS = "gas" CONF_HUMIDITY = "humidity" CONF_ILLUMINANCE = "illuminance" +CONF_NITROGEN_DIOXIDE = "nitrogen_dioxide" +CONF_NITROGEN_MONOXIDE = "nitrogen_monoxide" +CONF_NITROUS_OXIDE = "nitrous_oxide" +CONF_OZONE = "ozone" +CONF_PM1 = "pm1" +CONF_PM10 = "pm10" +CONF_PM25 = "pm25" CONF_POWER = "power" CONF_POWER_FACTOR = "power_factor" CONF_PRESSURE = "pressure" CONF_SIGNAL_STRENGTH = "signal_strength" +CONF_SULPHUR_DIOXIDE = "sulphur_dioxide" CONF_TEMPERATURE = "temperature" CONF_VOLTAGE = "voltage" CONF_VALUE = "value" @@ -65,10 +81,18 @@ ENTITY_TRIGGERS = { DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_GAS}], DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_HUMIDITY}], DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_ILLUMINANCE}], + DEVICE_CLASS_NITROGEN_DIOXIDE: [{CONF_TYPE: CONF_NITROGEN_DIOXIDE}], + DEVICE_CLASS_NITROGEN_MONOXIDE: [{CONF_TYPE: CONF_NITROGEN_MONOXIDE}], + DEVICE_CLASS_NITROUS_OXIDE: [{CONF_TYPE: CONF_NITROUS_OXIDE}], + DEVICE_CLASS_OZONE: [{CONF_TYPE: CONF_OZONE}], + DEVICE_CLASS_PM1: [{CONF_TYPE: CONF_PM1}], + DEVICE_CLASS_PM10: [{CONF_TYPE: CONF_PM10}], + DEVICE_CLASS_PM25: [{CONF_TYPE: CONF_PM25}], DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_POWER}], DEVICE_CLASS_POWER_FACTOR: [{CONF_TYPE: CONF_POWER_FACTOR}], DEVICE_CLASS_PRESSURE: [{CONF_TYPE: CONF_PRESSURE}], DEVICE_CLASS_SIGNAL_STRENGTH: [{CONF_TYPE: CONF_SIGNAL_STRENGTH}], + DEVICE_CLASS_SULPHUR_DIOXIDE: [{CONF_TYPE: CONF_SULPHUR_DIOXIDE}], DEVICE_CLASS_TEMPERATURE: [{CONF_TYPE: CONF_TEMPERATURE}], DEVICE_CLASS_VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_VALUE}], @@ -89,10 +113,18 @@ TRIGGER_SCHEMA = vol.All( CONF_GAS, CONF_HUMIDITY, CONF_ILLUMINANCE, + CONF_NITROGEN_DIOXIDE, + CONF_NITROGEN_MONOXIDE, + CONF_NITROUS_OXIDE, + CONF_OZONE, + CONF_PM1, + CONF_PM10, + CONF_PM25, CONF_POWER, CONF_POWER_FACTOR, CONF_PRESSURE, CONF_SIGNAL_STRENGTH, + CONF_SULPHUR_DIOXIDE, CONF_TEMPERATURE, CONF_VOLTAGE, CONF_VALUE, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 54d0f9ad76c..431e8a4789a 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -8,9 +8,17 @@ "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", "is_illuminance": "Current {entity_name} illuminance", + "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", + "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", + "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", + "is_ozone": "Current {entity_name} ozone concentration level", + "is_pm1": "Current {entity_name} PM1 concentration level", + "is_pm10": "Current {entity_name} PM10 concentration level", + "is_pm25": "Current {entity_name} PM2.5 concentration level", "is_power": "Current {entity_name} power", "is_pressure": "Current {entity_name} pressure", "is_signal_strength": "Current {entity_name} signal strength", + "is_sulphur_dioxide": "Current {entity_name} sulphur dioxide concentration level", "is_temperature": "Current {entity_name} temperature", "is_current": "Current {entity_name} current", "is_energy": "Current {entity_name} energy", @@ -25,9 +33,17 @@ "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", "illuminance": "{entity_name} illuminance changes", + "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", + "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", + "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", + "ozone": "{entity_name} ozone concentration changes", + "pm1": "{entity_name} PM1 concentration changes", + "pm10": "{entity_name} PM10 concentration changes", + "pm25": "{entity_name} PM2.5 concentration changes", "power": "{entity_name} power changes", "pressure": "{entity_name} pressure changes", "signal_strength": "{entity_name} signal strength changes", + "sulphur_dioxide": "{entity_name} sulphur dioxide concentration changes", "temperature": "{entity_name} temperature changes", "current": "{entity_name} current changes", "energy": "{entity_name} energy changes", diff --git a/homeassistant/const.py b/homeassistant/const.py index 9fa5c2cd231..ae1f50d0087 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -232,6 +232,7 @@ EVENT_TIME_CHANGED: Final = "time_changed" # #### DEVICE CLASSES #### +DEVICE_CLASS_AQI: Final = "aqi" DEVICE_CLASS_BATTERY: Final = "battery" DEVICE_CLASS_CO: Final = "carbon_monoxide" DEVICE_CLASS_CO2: Final = "carbon_dioxide" @@ -240,10 +241,18 @@ DEVICE_CLASS_ENERGY: Final = "energy" DEVICE_CLASS_HUMIDITY: Final = "humidity" DEVICE_CLASS_ILLUMINANCE: Final = "illuminance" DEVICE_CLASS_MONETARY: Final = "monetary" +DEVICE_CLASS_NITROGEN_DIOXIDE = "nitrogen_dioxide" +DEVICE_CLASS_NITROGEN_MONOXIDE = "nitrogen_monoxide" +DEVICE_CLASS_NITROUS_OXIDE = "nitrous_oxide" +DEVICE_CLASS_OZONE: Final = "ozone" DEVICE_CLASS_POWER_FACTOR: Final = "power_factor" DEVICE_CLASS_POWER: Final = "power" +DEVICE_CLASS_PM25: Final = "pm25" +DEVICE_CLASS_PM1: Final = "pm1" +DEVICE_CLASS_PM10: Final = "pm10" DEVICE_CLASS_PRESSURE: Final = "pressure" DEVICE_CLASS_SIGNAL_STRENGTH: Final = "signal_strength" +DEVICE_CLASS_SULPHUR_DIOXIDE = "sulphur_dioxide" DEVICE_CLASS_TEMPERATURE: Final = "temperature" DEVICE_CLASS_TIMESTAMP: Final = "timestamp" DEVICE_CLASS_VOLTAGE: Final = "voltage" diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index f955c3c19db..8e60714a9e2 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -86,7 +86,7 @@ async def test_get_triggers(hass, device_reg, entity_reg, enable_custom_integrat if device_class != "none" ] triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert len(triggers) == 14 + assert len(triggers) == 22 assert triggers == expected_triggers diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 63f47a0f854..010b82dc3a2 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -5,6 +5,7 @@ Call init before using it in your tests to ensure clean test data. """ import homeassistant.components.sensor as sensor from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, PRESSURE_HPA, @@ -23,7 +24,15 @@ UNITS_OF_MEASUREMENT = { sensor.DEVICE_CLASS_CO2: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO2 concentration sensor.DEVICE_CLASS_HUMIDITY: PERCENTAGE, # % of humidity in the air sensor.DEVICE_CLASS_ILLUMINANCE: "lm", # current light level (lx/lm) + sensor.DEVICE_CLASS_NITROGEN_DIOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen dioxide + sensor.DEVICE_CLASS_NITROGEN_MONOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen monoxide + sensor.DEVICE_CLASS_NITROUS_OXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen oxide + sensor.DEVICE_CLASS_OZONE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of ozone + sensor.DEVICE_CLASS_PM1: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM1 + sensor.DEVICE_CLASS_PM10: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM10 + sensor.DEVICE_CLASS_PM25: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM2.5 sensor.DEVICE_CLASS_SIGNAL_STRENGTH: SIGNAL_STRENGTH_DECIBELS, # signal strength (dB/dBm) + sensor.DEVICE_CLASS_SULPHUR_DIOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of sulphur dioxide sensor.DEVICE_CLASS_TEMPERATURE: "C", # temperature (C/F) sensor.DEVICE_CLASS_PRESSURE: PRESSURE_HPA, # pressure (hPa/mbar) sensor.DEVICE_CLASS_POWER: "kW", # power (W/kW) From 684d035969a28de6cfbf1b560fed80711966ad6b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 16 Aug 2021 23:54:11 +0200 Subject: [PATCH 428/903] Use state class total increasing for TPLink smart plugs (#54723) --- homeassistant/components/tplink/__init__.py | 8 +------- homeassistant/components/tplink/sensor.py | 9 +++------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 552e5666db8..5c69247eea8 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -1,7 +1,7 @@ """Component to embed TP-Link smart home devices.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import timedelta import logging import time from typing import Any @@ -11,7 +11,6 @@ from pyHS100.smartplug import SmartPlug import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.sensor import ATTR_LAST_RESET from homeassistant.components.switch import ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,7 +27,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.dt import utc_from_timestamp from .common import SmartDevices, async_discover_devices, get_static_devices from .const import ( @@ -258,12 +256,8 @@ class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): ATTR_TOTAL_ENERGY_KWH: round(float(emeter_readings["total"]), 3), ATTR_VOLTAGE: round(float(emeter_readings["voltage"]), 1), ATTR_CURRENT_A: round(float(emeter_readings["current"]), 2), - ATTR_LAST_RESET: {ATTR_TOTAL_ENERGY_KWH: utc_from_timestamp(0)}, } emeter_statics = self.smartplug.get_emeter_daily() - data[CONF_EMETER_PARAMS][ATTR_LAST_RESET][ - ATTR_TODAY_ENERGY_KWH - ] = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) if emeter_statics.get(int(time.strftime("%e"))): data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = round( float(emeter_statics[int(time.strftime("%e"))]), 3 diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index fae7939cd65..b38fa763ee9 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -6,8 +6,8 @@ from typing import Any, Final from pyHS100 import SmartPlug from homeassistant.components.sensor import ( - ATTR_LAST_RESET, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -62,14 +62,14 @@ ENERGY_SENSORS: Final[list[SensorEntityDescription]] = [ key=ATTR_TOTAL_ENERGY_KWH, unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, name="Total Consumption", ), SensorEntityDescription( key=ATTR_TODAY_ENERGY_KWH, unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, name="Today's Consumption", ), SensorEntityDescription( @@ -127,9 +127,6 @@ class SmartPlugSensor(CoordinatorEntity, SensorEntity): self.smartplug = smartplug self.entity_description = description self._attr_name = f"{coordinator.data[CONF_ALIAS]} {description.name}" - self._attr_last_reset = coordinator.data[CONF_EMETER_PARAMS][ - ATTR_LAST_RESET - ].get(description.key) @property def data(self) -> dict[str, Any]: From 41c3bd113c6ab9a6865863bdff7593466bec775d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Aug 2021 16:54:45 -0500 Subject: [PATCH 429/903] Bump zeroconf to 0.36.0 (#54720) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 05576accb78..84f9f4698e9 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.35.1"], + "requirements": ["zeroconf==0.36.0"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9409432e13f..3729b393470 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.17 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.35.1 +zeroconf==0.36.0 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 2a03e1c4855..dfbfd346929 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2443,7 +2443,7 @@ zeep[async]==4.0.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.35.1 +zeroconf==0.36.0 # homeassistant.components.zha zha-quirks==0.0.59 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 706dcf1da77..8ab3042137d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1354,7 +1354,7 @@ youless-api==0.10 zeep[async]==4.0.0 # homeassistant.components.zeroconf -zeroconf==0.35.1 +zeroconf==0.36.0 # homeassistant.components.zha zha-quirks==0.0.59 From 0abcfb42b3dd39a51c02ae9755f239ccfd84acce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 16 Aug 2021 23:57:59 +0200 Subject: [PATCH 430/903] Remove last_reset attribute from FritzBoxEnergySensor (#54644) --- homeassistant/components/fritzbox/sensor.py | 12 ++---------- tests/components/fritzbox/test_switch.py | 5 ++--- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 56e025cd605..09a652d64ad 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,13 +1,12 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" from __future__ import annotations -from datetime import datetime - from pyfritzhome import FritzhomeDevice from homeassistant.components.sensor import ( ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -28,7 +27,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util.dt import utc_from_timestamp from . import FritzBoxEntity from .const import ( @@ -99,7 +97,7 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}_total_energy", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, coordinator, ain, @@ -153,12 +151,6 @@ class FritzBoxEnergySensor(FritzBoxSensor): return energy / 1000 # type: ignore [no-any-return] return 0.0 - @property - def last_reset(self) -> datetime: - """Return the time when the sensor was last reset, if any.""" - # device does not provide timestamp of initialization - return utc_from_timestamp(0) - class FritzBoxTempSensor(FritzBoxSensor): """The entity class for FRITZ!SmartHome temperature sensors.""" diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 951528f1e7d..27461b2790f 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -10,10 +10,10 @@ from homeassistant.components.fritzbox.const import ( DOMAIN as FB_DOMAIN, ) from homeassistant.components.sensor import ( - ATTR_LAST_RESET, ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.components.switch import DOMAIN from homeassistant.const import ( @@ -73,10 +73,9 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_total_energy") assert state assert state.state == "1.234" - assert state.attributes[ATTR_LAST_RESET] == "1970-01-01T00:00:00+00:00" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Total Energy" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING async def test_turn_on(hass: HomeAssistant, fritz: Mock): From 38a210292f40dab36dfcf8be950f0231d50dde08 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 17 Aug 2021 00:14:00 +0200 Subject: [PATCH 431/903] Use EntityDescription - logi_circle (#54429) --- .../components/logi_circle/__init__.py | 8 +- homeassistant/components/logi_circle/const.py | 46 +++++++-- .../components/logi_circle/sensor.py | 95 ++++++++----------- 3 files changed, 81 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index 9e1a4803e11..d9060b10080 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -29,8 +29,8 @@ from .const import ( DEFAULT_CACHEDB, DOMAIN, LED_MODE_KEY, - LOGI_SENSORS, RECORDING_MODE_KEY, + SENSOR_TYPES, SIGNAL_LOGI_CIRCLE_RECONFIGURE, SIGNAL_LOGI_CIRCLE_RECORD, SIGNAL_LOGI_CIRCLE_SNAPSHOT, @@ -50,10 +50,12 @@ ATTR_DURATION = "duration" PLATFORMS = ["camera", "sensor"] +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] + SENSOR_SCHEMA = vol.Schema( { - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(LOGI_SENSORS)): vol.All( - cv.ensure_list, [vol.In(LOGI_SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) diff --git a/homeassistant/components/logi_circle/const.py b/homeassistant/components/logi_circle/const.py index 92967d2eb84..02e51993198 100644 --- a/homeassistant/components/logi_circle/const.py +++ b/homeassistant/components/logi_circle/const.py @@ -1,4 +1,7 @@ """Constants in Logi Circle component.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import PERCENTAGE DOMAIN = "logi_circle" @@ -12,15 +15,40 @@ DEFAULT_CACHEDB = ".logi_cache.pickle" LED_MODE_KEY = "LED" RECORDING_MODE_KEY = "RECORDING_MODE" -# Sensor types: Name, unit of measure, icon per sensor key. -LOGI_SENSORS = { - "battery_level": ["Battery", PERCENTAGE, "battery-50"], - "last_activity_time": ["Last Activity", None, "history"], - "recording": ["Recording Mode", None, "eye"], - "signal_strength_category": ["WiFi Signal Category", None, "wifi"], - "signal_strength_percentage": ["WiFi Signal Strength", PERCENTAGE, "wifi"], - "streaming": ["Streaming Mode", None, "camera"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="battery_level", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery-50", + ), + SensorEntityDescription( + key="last_activity_time", + name="Last Activity", + icon="mdi:history", + ), + SensorEntityDescription( + key="recording", + name="Recording Mode", + icon="mdi:eye", + ), + SensorEntityDescription( + key="signal_strength_category", + name="WiFi Signal Category", + icon="mdi:wifi", + ), + SensorEntityDescription( + key="signal_strength_percentage", + name="WiFi Signal Strength", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:wifi", + ), + SensorEntityDescription( + key="streaming", + name="Streaming Mode", + icon="mdi:camera", + ), +) SIGNAL_LOGI_CIRCLE_RECONFIGURE = "logi_circle_reconfigure" SIGNAL_LOGI_CIRCLE_SNAPSHOT = "logi_circle_snapshot" diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index a4158762b37..50671152587 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -1,7 +1,10 @@ """Support for Logi Circle sensors.""" -import logging +from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +import logging +from typing import Any + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, @@ -13,12 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util.dt import as_local -from .const import ( - ATTRIBUTION, - DEVICE_BRAND, - DOMAIN as LOGI_CIRCLE_DOMAIN, - LOGI_SENSORS as SENSOR_TYPES, -) +from .const import ATTRIBUTION, DEVICE_BRAND, DOMAIN as LOGI_CIRCLE_DOMAIN, SENSOR_TYPES _LOGGER = logging.getLogger(__name__) @@ -33,44 +31,30 @@ async def async_setup_entry(hass, entry, async_add_entities): devices = await hass.data[LOGI_CIRCLE_DOMAIN].cameras time_zone = str(hass.config.time_zone) - sensors = [] - for sensor_type in entry.data.get(CONF_SENSORS).get(CONF_MONITORED_CONDITIONS): - for device in devices: - if device.supports_feature(sensor_type): - sensors.append(LogiSensor(device, time_zone, sensor_type)) + monitored_conditions = entry.data.get(CONF_SENSORS).get(CONF_MONITORED_CONDITIONS) + entities = [ + LogiSensor(device, time_zone, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + for device in devices + if device.supports_feature(description.key) + ] - async_add_entities(sensors, True) + async_add_entities(entities, True) class LogiSensor(SensorEntity): """A sensor implementation for a Logi Circle camera.""" - def __init__(self, camera, time_zone, sensor_type): + def __init__(self, camera, time_zone, description: SensorEntityDescription): """Initialize a sensor for Logi Circle camera.""" - self._sensor_type = sensor_type + self.entity_description = description self._camera = camera - self._id = f"{self._camera.mac_address}-{self._sensor_type}" - self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}" - self._name = f"{self._camera.name} {SENSOR_TYPES.get(self._sensor_type)[0]}" - self._activity = {} - self._state = None + self._attr_unique_id = f"{camera.mac_address}-{description.key}" + self._attr_name = f"{camera.name} {description.name}" + self._activity: dict[Any, Any] = {} self._tz = time_zone - @property - def unique_id(self): - """Return a unique ID.""" - return self._id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - @property def device_info(self): """Return information about the device.""" @@ -93,7 +77,7 @@ class LogiSensor(SensorEntity): "microphone_gain": self._camera.microphone_gain, } - if self._sensor_type == "battery_level": + if self.entity_description.key == "battery_level": state[ATTR_BATTERY_CHARGING] = self._camera.charging return state @@ -101,37 +85,36 @@ class LogiSensor(SensorEntity): @property def icon(self): """Icon to use in the frontend, if any.""" - if self._sensor_type == "battery_level" and self._state is not None: + sensor_type = self.entity_description.key + if sensor_type == "battery_level" and self._attr_native_value is not None: return icon_for_battery_level( - battery_level=int(self._state), charging=False + battery_level=int(self._attr_native_value), charging=False ) - if self._sensor_type == "recording_mode" and self._state is not None: - return "mdi:eye" if self._state == STATE_ON else "mdi:eye-off" - if self._sensor_type == "streaming_mode" and self._state is not None: - return "mdi:camera" if self._state == STATE_ON else "mdi:camera-off" - return self._icon - - @property - def native_unit_of_measurement(self): - """Return the units of measurement.""" - return SENSOR_TYPES.get(self._sensor_type)[1] + if sensor_type == "recording_mode" and self._attr_native_value is not None: + return "mdi:eye" if self._attr_native_value == STATE_ON else "mdi:eye-off" + if sensor_type == "streaming_mode" and self._attr_native_value is not None: + return ( + "mdi:camera" + if self._attr_native_value == STATE_ON + else "mdi:camera-off" + ) + return self.entity_description.icon async def async_update(self): """Get the latest data and updates the state.""" - _LOGGER.debug("Pulling data from %s sensor", self._name) + _LOGGER.debug("Pulling data from %s sensor", self.name) await self._camera.update() - if self._sensor_type == "last_activity_time": + if self.entity_description.key == "last_activity_time": last_activity = await self._camera.get_last_activity(force_refresh=True) if last_activity is not None: last_activity_time = as_local(last_activity.end_time_utc) - self._state = ( + self._attr_native_value = ( f"{last_activity_time.hour:0>2}:{last_activity_time.minute:0>2}" ) else: - state = getattr(self._camera, self._sensor_type, None) + state = getattr(self._camera, self.entity_description.key, None) if isinstance(state, bool): - self._state = STATE_ON if state is True else STATE_OFF + self._attr_native_value = STATE_ON if state is True else STATE_OFF else: - self._state = state - self._state = state + self._attr_native_value = state From 7524acc38c819bef7a83c0ce892fa621224b2cf3 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 17 Aug 2021 00:19:12 +0200 Subject: [PATCH 432/903] Activate mypy for sesame (#54546) --- homeassistant/components/sesame/lock.py | 29 +++++++------------------ mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 8 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index acd71b7c9e7..261b2680499 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -1,17 +1,11 @@ """Support for Sesame, by CANDY HOUSE.""" -from typing import Callable +from __future__ import annotations import pysesame2 import voluptuous as vol from homeassistant.components.lock import PLATFORM_SCHEMA, LockEntity -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_DEVICE_ID, - CONF_API_KEY, - STATE_LOCKED, - STATE_UNLOCKED, -) +from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, CONF_API_KEY import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -20,9 +14,7 @@ ATTR_SERIAL_NO = "serial" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) -def setup_platform( - hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None -): +def setup_platform(hass, config: ConfigType, add_entities, discovery_info=None): """Set up the Sesame platform.""" api_key = config.get(CONF_API_KEY) @@ -35,20 +27,20 @@ def setup_platform( class SesameDevice(LockEntity): """Representation of a Sesame device.""" - def __init__(self, sesame: object) -> None: + def __init__(self, sesame: pysesame2.Sesame) -> None: """Initialize the Sesame device.""" - self._sesame = sesame + self._sesame: pysesame2.Sesame = sesame # Cached properties from pysesame object. - self._device_id = None + self._device_id: str | None = None self._serial = None - self._nickname = None + self._nickname: str | None = None self._is_locked = False self._responsive = False self._battery = -1 @property - def name(self) -> str: + def name(self) -> str | None: """Return the name of the device.""" return self._nickname @@ -62,11 +54,6 @@ class SesameDevice(LockEntity): """Return True if the device is currently locked, else False.""" return self._is_locked - @property - def state(self) -> str: - """Get the state of the device.""" - return STATE_LOCKED if self._is_locked else STATE_UNLOCKED - def lock(self, **kwargs) -> None: """Lock the device.""" self._sesame.lock() diff --git a/mypy.ini b/mypy.ini index a12719f90df..837dc73343a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1604,9 +1604,6 @@ ignore_errors = true [mypy-homeassistant.components.sense.*] ignore_errors = true -[mypy-homeassistant.components.sesame.*] -ignore_errors = true - [mypy-homeassistant.components.sharkiq.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index dc00be3efe4..513be3e59c2 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -127,7 +127,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.screenlogic.*", "homeassistant.components.search.*", "homeassistant.components.sense.*", - "homeassistant.components.sesame.*", "homeassistant.components.sharkiq.*", "homeassistant.components.sma.*", "homeassistant.components.smartthings.*", From a6b1dbefd4a3296d7cabee3c616f49c1e948c7fc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 17 Aug 2021 00:21:06 +0200 Subject: [PATCH 433/903] Use EntityDescription - mitemp_bt (#54503) --- homeassistant/components/mitemp_bt/sensor.py | 124 +++++++++---------- 1 file changed, 57 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/mitemp_bt/sensor.py b/homeassistant/components/mitemp_bt/sensor.py index 732beb11b3a..ed6c7f27b94 100644 --- a/homeassistant/components/mitemp_bt/sensor.py +++ b/homeassistant/components/mitemp_bt/sensor.py @@ -1,12 +1,19 @@ """Support for Xiaomi Mi Temp BLE environmental sensor.""" +from __future__ import annotations + import logging +from typing import Any import btlewrap from btlewrap.base import BluetoothBackendException from mitemp_bt import mitemp_bt_poller import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_FORCE_UPDATE, CONF_MAC, @@ -44,18 +51,34 @@ DEFAULT_RETRIES = 2 DEFAULT_TIMEOUT = 10 -# Sensor types are defined like: Name, units -SENSOR_TYPES = { - "temperature": [DEVICE_CLASS_TEMPERATURE, "Temperature", TEMP_CELSIUS], - "humidity": [DEVICE_CLASS_HUMIDITY, "Humidity", PERCENTAGE], - "battery": [DEVICE_CLASS_BATTERY, "Battery", PERCENTAGE], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="battery", + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + native_unit_of_measurement=PERCENTAGE, + ), +) + +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MAC): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int, @@ -73,79 +96,46 @@ def setup_platform(hass, config, add_entities, discovery_info=None): backend = BACKEND _LOGGER.debug("MiTempBt is using %s backend", backend.__name__) - cache = config.get(CONF_CACHE) + cache = config[CONF_CACHE] poller = mitemp_bt_poller.MiTempBtPoller( - config.get(CONF_MAC), + config[CONF_MAC], cache_timeout=cache, - adapter=config.get(CONF_ADAPTER), + adapter=config[CONF_ADAPTER], backend=backend, ) - force_update = config.get(CONF_FORCE_UPDATE) - median = config.get(CONF_MEDIAN) - poller.ble_timeout = config.get(CONF_TIMEOUT) - poller.retries = config.get(CONF_RETRIES) + prefix = config[CONF_NAME] + force_update = config[CONF_FORCE_UPDATE] + median = config[CONF_MEDIAN] + poller.ble_timeout = config[CONF_TIMEOUT] + poller.retries = config[CONF_RETRIES] - devs = [] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + MiTempBtSensor(poller, prefix, force_update, median, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - for parameter in config[CONF_MONITORED_CONDITIONS]: - device = SENSOR_TYPES[parameter][0] - name = SENSOR_TYPES[parameter][1] - unit = SENSOR_TYPES[parameter][2] - - prefix = config.get(CONF_NAME) - if prefix: - name = f"{prefix} {name}" - - devs.append( - MiTempBtSensor(poller, parameter, device, name, unit, force_update, median) - ) - - add_entities(devs) + add_entities(entities) class MiTempBtSensor(SensorEntity): """Implementing the MiTempBt sensor.""" - def __init__(self, poller, parameter, device, name, unit, force_update, median): + def __init__( + self, poller, prefix, force_update, median, description: SensorEntityDescription + ): """Initialize the sensor.""" + self.entity_description = description self.poller = poller - self.parameter = parameter - self._device = device - self._unit = unit - self._name = name - self._state = None - self.data = [] - self._force_update = force_update + self.data: list[Any] = [] + self._attr_name = f"{prefix} {description.name}" + self._attr_force_update = force_update # Median is used to filter out outliers. median of 3 will filter # single outliers, while median of 5 will filter double outliers # Use median_count = 1 if no filtering is required. self.median_count = median - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the units of measurement.""" - return self._unit - - @property - def device_class(self): - """Device class of this entity.""" - return self._device - - @property - def force_update(self): - """Force update.""" - return self._force_update - def update(self): """ Update current conditions. @@ -154,7 +144,7 @@ class MiTempBtSensor(SensorEntity): """ try: _LOGGER.debug("Polling data for %s", self.name) - data = self.poller.parameter_value(self.parameter) + data = self.poller.parameter_value(self.entity_description.key) except OSError as ioerr: _LOGGER.warning("Polling error %s", ioerr) return @@ -174,7 +164,7 @@ class MiTempBtSensor(SensorEntity): if self.data: self.data = self.data[1:] else: - self._state = None + self._attr_native_value = None return if len(self.data) > self.median_count: @@ -183,6 +173,6 @@ class MiTempBtSensor(SensorEntity): if len(self.data) == self.median_count: median = sorted(self.data)[int((self.median_count - 1) / 2)] _LOGGER.debug("Median is: %s", median) - self._state = median + self._attr_native_value = median else: _LOGGER.debug("Not yet enough data for median calculation") From af32bd956cf03b8ae49cddd87349575112572808 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 17 Aug 2021 01:30:32 +0200 Subject: [PATCH 434/903] Add DEVICE_CLASS_UPDATE to Binary Sensor (#53945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen Co-authored-by: Franck Nijhof --- homeassistant/components/binary_sensor/__init__.py | 4 ++++ .../components/binary_sensor/device_condition.py | 6 ++++++ homeassistant/components/binary_sensor/device_trigger.py | 5 +++++ homeassistant/components/binary_sensor/strings.json | 8 ++++++++ .../components/binary_sensor/translations/en.json | 8 ++++++++ 5 files changed, 31 insertions(+) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 2bd5de34d51..87d574fc4b0 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -92,6 +92,9 @@ DEVICE_CLASS_SMOKE = "smoke" # On means sound detected, Off means no sound (clear) DEVICE_CLASS_SOUND = "sound" +# On means update available, Off means up-to-date +DEVICE_CLASS_UPDATE = "update" + # On means vibration detected, Off means no vibration DEVICE_CLASS_VIBRATION = "vibration" @@ -121,6 +124,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, DEVICE_CLASS_SOUND, + DEVICE_CLASS_UPDATE, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ] diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index eed5c3f5896..309e26847a1 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -37,6 +37,7 @@ from . import ( DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, DEVICE_CLASS_SOUND, + DEVICE_CLASS_UPDATE, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, DOMAIN, @@ -82,6 +83,8 @@ CONF_IS_SMOKE = "is_smoke" CONF_IS_NO_SMOKE = "is_no_smoke" CONF_IS_SOUND = "is_sound" CONF_IS_NO_SOUND = "is_no_sound" +CONF_IS_UPDATE = "is_update" +CONF_IS_NO_UPDATE = "is_no_update" CONF_IS_VIBRATION = "is_vibration" CONF_IS_NO_VIBRATION = "is_no_vibration" CONF_IS_OPEN = "is_open" @@ -107,6 +110,7 @@ IS_ON = [ CONF_IS_PROBLEM, CONF_IS_SMOKE, CONF_IS_SOUND, + CONF_IS_UPDATE, CONF_IS_UNSAFE, CONF_IS_VIBRATION, CONF_IS_ON, @@ -133,6 +137,7 @@ IS_OFF = [ CONF_IS_NO_PROBLEM, CONF_IS_NO_SMOKE, CONF_IS_NO_SOUND, + CONF_IS_NO_UPDATE, CONF_IS_NO_VIBRATION, CONF_IS_OFF, ] @@ -187,6 +192,7 @@ ENTITY_CONDITIONS = { DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_IS_UNSAFE}, {CONF_TYPE: CONF_IS_NOT_UNSAFE}], DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_IS_SMOKE}, {CONF_TYPE: CONF_IS_NO_SMOKE}], DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_IS_SOUND}, {CONF_TYPE: CONF_IS_NO_SOUND}], + DEVICE_CLASS_UPDATE: [{CONF_TYPE: CONF_IS_UPDATE}, {CONF_TYPE: CONF_IS_NO_UPDATE}], DEVICE_CLASS_VIBRATION: [ {CONF_TYPE: CONF_IS_VIBRATION}, {CONF_TYPE: CONF_IS_NO_VIBRATION}, diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index ad5c26ed04f..a0966b5a018 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -35,6 +35,7 @@ from . import ( DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, DEVICE_CLASS_SOUND, + DEVICE_CLASS_UPDATE, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, DOMAIN, @@ -82,6 +83,8 @@ CONF_SMOKE = "smoke" CONF_NO_SMOKE = "no_smoke" CONF_SOUND = "sound" CONF_NO_SOUND = "no_sound" +CONF_UPDATE = "update" +CONF_NO_UPDATE = "no_update" CONF_VIBRATION = "vibration" CONF_NO_VIBRATION = "no_vibration" CONF_OPENED = "opened" @@ -108,6 +111,7 @@ TURNED_ON = [ CONF_SMOKE, CONF_SOUND, CONF_UNSAFE, + CONF_UPDATE, CONF_VIBRATION, CONF_TURNED_ON, ] @@ -169,6 +173,7 @@ ENTITY_TRIGGERS = { DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_UNSAFE}, {CONF_TYPE: CONF_NOT_UNSAFE}], DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_SMOKE}, {CONF_TYPE: CONF_NO_SMOKE}], DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_SOUND}, {CONF_TYPE: CONF_NO_SOUND}], + DEVICE_CLASS_UPDATE: [{CONF_TYPE: CONF_UPDATE}, {CONF_TYPE: CONF_NO_UPDATE}], DEVICE_CLASS_VIBRATION: [ {CONF_TYPE: CONF_VIBRATION}, {CONF_TYPE: CONF_NO_VIBRATION}, diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 7380d1be576..62b6ec20323 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -38,6 +38,8 @@ "is_no_smoke": "{entity_name} is not detecting smoke", "is_sound": "{entity_name} is detecting sound", "is_no_sound": "{entity_name} is not detecting sound", + "is_update": "{entity_name} has an update available", + "is_no_update": "{entity_name} is up-to-date", "is_vibration": "{entity_name} is detecting vibration", "is_no_vibration": "{entity_name} is not detecting vibration", "is_open": "{entity_name} is open", @@ -82,6 +84,8 @@ "no_smoke": "{entity_name} stopped detecting smoke", "sound": "{entity_name} started detecting sound", "no_sound": "{entity_name} stopped detecting sound", + "update": "{entity_name} got an update available", + "no_update": "{entity_name} became up-to-date", "vibration": "{entity_name} started detecting vibration", "no_vibration": "{entity_name} stopped detecting vibration", "opened": "{entity_name} opened", @@ -175,6 +179,10 @@ "off": "[%key:component::binary_sensor::state::gas::off%]", "on": "[%key:component::binary_sensor::state::gas::on%]" }, + "update": { + "off": "Up-to-date", + "on": "Update available" + }, "vibration": { "off": "[%key:component::binary_sensor::state::gas::off%]", "on": "[%key:component::binary_sensor::state::gas::on%]" diff --git a/homeassistant/components/binary_sensor/translations/en.json b/homeassistant/components/binary_sensor/translations/en.json index 98c8a3a220a..047820498da 100644 --- a/homeassistant/components/binary_sensor/translations/en.json +++ b/homeassistant/components/binary_sensor/translations/en.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} is not detecting problem", "is_no_smoke": "{entity_name} is not detecting smoke", "is_no_sound": "{entity_name} is not detecting sound", + "is_no_update": "{entity_name} is up-to-date", "is_no_vibration": "{entity_name} is not detecting vibration", "is_not_bat_low": "{entity_name} battery is normal", "is_not_cold": "{entity_name} is not cold", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} is detecting smoke", "is_sound": "{entity_name} is detecting sound", "is_unsafe": "{entity_name} is unsafe", + "is_update": "{entity_name} has an update available", "is_vibration": "{entity_name} is detecting vibration" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} stopped detecting problem", "no_smoke": "{entity_name} stopped detecting smoke", "no_sound": "{entity_name} stopped detecting sound", + "no_update": "{entity_name} became up-to-date", "no_vibration": "{entity_name} stopped detecting vibration", "not_bat_low": "{entity_name} battery normal", "not_cold": "{entity_name} became not cold", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} turned off", "turned_on": "{entity_name} turned on", "unsafe": "{entity_name} became unsafe", + "update": "{entity_name} got an update available", "vibration": "{entity_name} started detecting vibration" } }, @@ -178,6 +182,10 @@ "off": "Clear", "on": "Detected" }, + "update": { + "off": "Up-to-date", + "on": "Update available" + }, "vibration": { "off": "Clear", "on": "Detected" From 1661de5c19875205c77ee427dea28909ebbbec03 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 17 Aug 2021 00:12:45 +0000 Subject: [PATCH 435/903] [ci skip] Translation update --- .../components/adax/translations/es.json | 11 ++++++++++ .../airvisual/translations/sensor.es.json | 20 +++++++++++++++++++ .../components/co2signal/translations/es.json | 20 +++++++++++++++++++ .../components/flipr/translations/es.json | 4 ++++ .../forecast_solar/translations/es.json | 13 ++++++++++++ .../components/honeywell/translations/es.json | 10 ++++++++++ .../nfandroidtv/translations/es.json | 10 ++++++++++ .../nmap_tracker/translations/en.json | 3 ++- .../components/sensor/translations/de.json | 2 ++ .../components/sensor/translations/en.json | 16 +++++++++++++++ .../components/sensor/translations/es.json | 2 ++ .../components/sensor/translations/hu.json | 2 ++ .../synology_dsm/translations/es.json | 4 ++++ .../components/tractive/translations/de.json | 3 ++- .../components/tractive/translations/en.json | 4 +++- .../components/tractive/translations/hu.json | 4 +++- .../tractive/translations/zh-Hant.json | 4 +++- .../xiaomi_miio/translations/no.json | 2 +- .../xiaomi_miio/translations/ru.json | 2 +- 19 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/adax/translations/es.json create mode 100644 homeassistant/components/airvisual/translations/sensor.es.json create mode 100644 homeassistant/components/co2signal/translations/es.json create mode 100644 homeassistant/components/honeywell/translations/es.json create mode 100644 homeassistant/components/nfandroidtv/translations/es.json diff --git a/homeassistant/components/adax/translations/es.json b/homeassistant/components/adax/translations/es.json new file mode 100644 index 00000000000..4a65e469bcd --- /dev/null +++ b/homeassistant/components/adax/translations/es.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "account_id": "ID de la cuenta" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.es.json b/homeassistant/components/airvisual/translations/sensor.es.json new file mode 100644 index 00000000000..4a8a7cea1e3 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.es.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Mon\u00f3xido de carbono", + "n2": "Di\u00f3xido de nitr\u00f3geno", + "o3": "Ozono", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Di\u00f3xido de azufre" + }, + "airvisual__pollutant_level": { + "good": "Bien", + "hazardous": "Peligroso", + "moderate": "Moderado", + "unhealthy": "Insalubre", + "unhealthy_sensitive": "Incorrecto para grupos sensibles", + "very_unhealthy": "Muy poco saludable" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/es.json b/homeassistant/components/co2signal/translations/es.json new file mode 100644 index 00000000000..071ae642c74 --- /dev/null +++ b/homeassistant/components/co2signal/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "api_ratelimit": "Se ha superado el l\u00edmite de velocidad de la API" + }, + "step": { + "country": { + "data": { + "country_code": "C\u00f3digo del pa\u00eds" + } + }, + "user": { + "data": { + "location": "Obtener datos para" + }, + "description": "Visite https://co2signal.com/ para solicitar un token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/es.json b/homeassistant/components/flipr/translations/es.json index 56898d19a42..766f83856ec 100644 --- a/homeassistant/components/flipr/translations/es.json +++ b/homeassistant/components/flipr/translations/es.json @@ -1,10 +1,14 @@ { "config": { "error": { + "no_flipr_id_found": "Por ahora no hay ning\u00fan ID de Flipr asociado a tu cuenta. Deber\u00edas verificar que est\u00e1 funcionando con la aplicaci\u00f3n m\u00f3vil de Flipr primero.", "unknown": "Error desconocido" }, "step": { "flipr_id": { + "data": { + "flipr_id": "ID de Flipr" + }, "description": "Elija su ID de Flipr en la lista", "title": "Elige tu Flipr" }, diff --git a/homeassistant/components/forecast_solar/translations/es.json b/homeassistant/components/forecast_solar/translations/es.json index 2189cb91f77..8a1b51a5084 100644 --- a/homeassistant/components/forecast_solar/translations/es.json +++ b/homeassistant/components/forecast_solar/translations/es.json @@ -1,8 +1,21 @@ { + "config": { + "step": { + "user": { + "data": { + "azimuth": "Acimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", + "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", + "modules power": "Potencia total en vatios pico de sus m\u00f3dulos solares" + }, + "description": "Rellene los datos de sus paneles solares. Consulte la documentaci\u00f3n si alg\u00fan campo no est\u00e1 claro." + } + } + }, "options": { "step": { "init": { "data": { + "api_key": "Clave API de Forecast.Solar (opcional)", "azimuth": "Azimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", "damping": "Factor de amortiguaci\u00f3n: ajusta los resultados por la ma\u00f1ana y por la noche", "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", diff --git a/homeassistant/components/honeywell/translations/es.json b/homeassistant/components/honeywell/translations/es.json new file mode 100644 index 00000000000..41534be9d8d --- /dev/null +++ b/homeassistant/components/honeywell/translations/es.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "Por favor, introduzca las credenciales utilizadas para iniciar sesi\u00f3n en mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/es.json b/homeassistant/components/nfandroidtv/translations/es.json new file mode 100644 index 00000000000..e99ce545b74 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/es.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "Esta integraci\u00f3n requiere la aplicaci\u00f3n de Notificaciones para Android TV.\n\nPara Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPara Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nDebe configurar una reserva DHCP en su router (consulte el manual de usuario de su router) o una direcci\u00f3n IP est\u00e1tica en el dispositivo. Si no, el dispositivo acabar\u00e1 por no estar disponible.", + "title": "Notificaciones para Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/en.json b/homeassistant/components/nmap_tracker/translations/en.json index 985225414a6..6b83532a0e2 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -29,7 +29,8 @@ "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", "hosts": "Network addresses (comma seperated) to scan", "interval_seconds": "Scan interval", - "scan_options": "Raw configurable scan options for Nmap" + "scan_options": "Raw configurable scan options for Nmap", + "track_new_devices": "Track new devices" }, "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)." } diff --git a/homeassistant/components/sensor/translations/de.json b/homeassistant/components/sensor/translations/de.json index 4f16c07be01..c65959b8210 100644 --- a/homeassistant/components/sensor/translations/de.json +++ b/homeassistant/components/sensor/translations/de.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Aktuelle {entity_name} Kohlenstoffmonoxid-Konzentration", "is_current": "Aktueller Strom von {entity_name}", "is_energy": "Aktuelle Energie von {entity_name}", + "is_gas": "Aktuelles {entity_name} Gas", "is_humidity": "{entity_name} Feuchtigkeit", "is_illuminance": "Aktuelle {entity_name} Helligkeit", "is_power": "Aktuelle {entity_name} Leistung", @@ -22,6 +23,7 @@ "carbon_monoxide": "{entity_name} Kohlenstoffmonoxid-Konzentrations\u00e4nderung", "current": "{entity_name} Stromver\u00e4nderung", "energy": "{entity_name} Energie\u00e4nderungen", + "gas": "{entity_name} Gas\u00e4nderungen", "humidity": "{entity_name} Feuchtigkeits\u00e4nderungen", "illuminance": "{entity_name} Helligkeits\u00e4nderungen", "power": "{entity_name} Leistungs\u00e4nderungen", diff --git a/homeassistant/components/sensor/translations/en.json b/homeassistant/components/sensor/translations/en.json index 69737c7c93a..5fa23a334cb 100644 --- a/homeassistant/components/sensor/translations/en.json +++ b/homeassistant/components/sensor/translations/en.json @@ -9,10 +9,18 @@ "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", "is_illuminance": "Current {entity_name} illuminance", + "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", + "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", + "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", + "is_ozone": "Current {entity_name} ozone concentration level", + "is_pm1": "Current {entity_name} PM1 concentration level", + "is_pm10": "Current {entity_name} PM10 concentration level", + "is_pm25": "Current {entity_name} PM2.5 concentration level", "is_power": "Current {entity_name} power", "is_power_factor": "Current {entity_name} power factor", "is_pressure": "Current {entity_name} pressure", "is_signal_strength": "Current {entity_name} signal strength", + "is_sulphur_dioxide": "Current {entity_name} sulphur dioxide concentration level", "is_temperature": "Current {entity_name} temperature", "is_value": "Current {entity_name} value", "is_voltage": "Current {entity_name} voltage" @@ -26,10 +34,18 @@ "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", "illuminance": "{entity_name} illuminance changes", + "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", + "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", + "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", + "ozone": "{entity_name} ozone concentration changes", + "pm1": "{entity_name} PM1 concentration changes", + "pm10": "{entity_name} PM10 concentration changes", + "pm25": "{entity_name} PM2.5 concentration changes", "power": "{entity_name} power changes", "power_factor": "{entity_name} power factor changes", "pressure": "{entity_name} pressure changes", "signal_strength": "{entity_name} signal strength changes", + "sulphur_dioxide": "{entity_name} sulphur dioxide concentration changes", "temperature": "{entity_name} temperature changes", "value": "{entity_name} value changes", "voltage": "{entity_name} voltage changes" diff --git a/homeassistant/components/sensor/translations/es.json b/homeassistant/components/sensor/translations/es.json index da96c7d92db..48c61f321a1 100644 --- a/homeassistant/components/sensor/translations/es.json +++ b/homeassistant/components/sensor/translations/es.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Nivel actual de concentraci\u00f3n de mon\u00f3xido de carbono {entity_name}", "is_current": "Corriente actual de {entity_name}", "is_energy": "Energ\u00eda actual de {entity_name}", + "is_gas": "Gas actual de {entity_name}", "is_humidity": "Humedad actual de {entity_name}", "is_illuminance": "Luminosidad actual de {entity_name}", "is_power": "Potencia actual de {entity_name}", @@ -22,6 +23,7 @@ "carbon_monoxide": "{entity_name} cambios en la concentraci\u00f3n de mon\u00f3xido de carbono", "current": "Cambio de corriente en {entity_name}", "energy": "Cambio de energ\u00eda en {entity_name}", + "gas": "Cambio de gas de {entity_name}", "humidity": "Cambios de humedad de {entity_name}", "illuminance": "Cambios de luminosidad de {entity_name}", "power": "Cambios de potencia de {entity_name}", diff --git a/homeassistant/components/sensor/translations/hu.json b/homeassistant/components/sensor/translations/hu.json index 9b1c9bece82..1e2aba465cc 100644 --- a/homeassistant/components/sensor/translations/hu.json +++ b/homeassistant/components/sensor/translations/hu.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Jelenlegi {entity_name} sz\u00e9n-monoxid koncentr\u00e1ci\u00f3 szint", "is_current": "Jelenlegi {entity_name} \u00e1ram", "is_energy": "A jelenlegi {entity_name} energia", + "is_gas": "Jelenlegi {entity_name} g\u00e1z", "is_humidity": "{entity_name} aktu\u00e1lis p\u00e1ratartalma", "is_illuminance": "{entity_name} aktu\u00e1lis megvil\u00e1g\u00edt\u00e1sa", "is_power": "{entity_name} aktu\u00e1lis teljes\u00edtm\u00e9nye", @@ -22,6 +23,7 @@ "carbon_monoxide": "{entity_name} sz\u00e9n-monoxid koncentr\u00e1ci\u00f3ja megv\u00e1ltozik", "current": "{entity_name} aktu\u00e1lis v\u00e1ltoz\u00e1sai", "energy": "{entity_name} energiav\u00e1ltoz\u00e1sa", + "gas": "{entity_name} g\u00e1z v\u00e1ltoz\u00e1sok", "humidity": "{entity_name} p\u00e1ratartalma v\u00e1ltozik", "illuminance": "{entity_name} megvil\u00e1g\u00edt\u00e1sa v\u00e1ltozik", "power": "{entity_name} teljes\u00edtm\u00e9nye v\u00e1ltozik", diff --git a/homeassistant/components/synology_dsm/translations/es.json b/homeassistant/components/synology_dsm/translations/es.json index f76ce7ab27a..7b86c248110 100644 --- a/homeassistant/components/synology_dsm/translations/es.json +++ b/homeassistant/components/synology_dsm/translations/es.json @@ -29,6 +29,10 @@ "description": "\u00bfQuieres configurar {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "description": "Raz\u00f3n: {details}", + "title": "Synology DSM Volver a autenticar la integraci\u00f3n" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/tractive/translations/de.json b/homeassistant/components/tractive/translations/de.json index 522649fe393..fbb3411a6c5 100644 --- a/homeassistant/components/tractive/translations/de.json +++ b/homeassistant/components/tractive/translations/de.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "invalid_auth": "Ung\u00fcltige Authentifizierung", diff --git a/homeassistant/components/tractive/translations/en.json b/homeassistant/components/tractive/translations/en.json index c85034b0729..dcb3a128ac4 100644 --- a/homeassistant/components/tractive/translations/en.json +++ b/homeassistant/components/tractive/translations/en.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again.", + "reauth_successful": "Re-authentication was successful" }, "error": { "invalid_auth": "Invalid authentication", diff --git a/homeassistant/components/tractive/translations/hu.json b/homeassistant/components/tractive/translations/hu.json index 8830cb61711..d0f75a28ed0 100644 --- a/homeassistant/components/tractive/translations/hu.json +++ b/homeassistant/components/tractive/translations/hu.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_failed_existing": "Nem siker\u00fclt friss\u00edteni a konfigur\u00e1ci\u00f3s bejegyz\u00e9st. K\u00e9rj\u00fck, t\u00e1vol\u00edtsa el az integr\u00e1ci\u00f3t, \u00e9s \u00e1ll\u00edtsa be \u00fajra.", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt" }, "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", diff --git a/homeassistant/components/tractive/translations/zh-Hant.json b/homeassistant/components/tractive/translations/zh-Hant.json index 64aba47b6b8..8c9ec055f63 100644 --- a/homeassistant/components/tractive/translations/zh-Hant.json +++ b/homeassistant/components/tractive/translations/zh-Hant.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_failed_existing": "\u7121\u6cd5\u66f4\u65b0\u8a2d\u5b9a\u5be6\u9ad4\uff0c\u8acb\u79fb\u9664\u6574\u5408\u4e26\u91cd\u65b0\u8a2d\u5b9a\u3002", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", diff --git a/homeassistant/components/xiaomi_miio/translations/no.json b/homeassistant/components/xiaomi_miio/translations/no.json index 8fa93169647..a296dd7aa08 100644 --- a/homeassistant/components/xiaomi_miio/translations/no.json +++ b/homeassistant/components/xiaomi_miio/translations/no.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "Tilkobling mislyktes", "cloud_credentials_incomplete": "Utskriftsinformasjon for skyen er fullstendig. Fyll ut brukernavn, passord og land", - "cloud_login_error": "Kunne ikke logge p\u00e5 Xioami Miio Cloud, sjekk legitimasjonen.", + "cloud_login_error": "Kunne ikke logge inn p\u00e5 Xiaomi Miio Cloud, sjekk legitimasjonen.", "cloud_no_devices": "Ingen enheter funnet i denne Xiaomi Miio-skykontoen.", "no_device_selected": "Ingen enhet valgt, vennligst velg en enhet.", "unknown_device": "Enhetsmodellen er ikke kjent, kan ikke konfigurere enheten ved hjelp av konfigurasjonsflyt." diff --git a/homeassistant/components/xiaomi_miio/translations/ru.json b/homeassistant/components/xiaomi_miio/translations/ru.json index f9aeb824b20..017660e51c6 100644 --- a/homeassistant/components/xiaomi_miio/translations/ru.json +++ b/homeassistant/components/xiaomi_miio/translations/ru.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "cloud_credentials_incomplete": "\u0423\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0432 \u043e\u0431\u043b\u0430\u043a\u0435 \u043d\u0435\u043f\u043e\u043b\u043d\u044b\u0435. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f, \u043f\u0430\u0440\u043e\u043b\u044c \u0438 \u0441\u0442\u0440\u0430\u043d\u0443.", - "cloud_login_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u043e\u0439\u0442\u0438 \u0432 Xioami Miio Cloud, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "cloud_login_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u043e\u0439\u0442\u0438 \u0432 Xiaomi Miio Cloud, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", "cloud_no_devices": "\u0412 \u044d\u0442\u043e\u0439 \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Xiaomi Miio \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u044b.", "no_device_selected": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u043d\u043e \u0438\u0437 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432.", "unknown_device": "\u041c\u043e\u0434\u0435\u043b\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430, \u043d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u044d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043c\u0430\u0441\u0442\u0435\u0440\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438." From 476f3b5cb543eb00a43c6f4610758c1d750e019c Mon Sep 17 00:00:00 2001 From: Bert Roos Date: Tue, 17 Aug 2021 05:20:16 +0200 Subject: [PATCH 436/903] Fix Google Calendar event loading (#54231) --- homeassistant/components/google/__init__.py | 23 +++++++++-------- homeassistant/components/google/calendar.py | 28 +++++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 6cc7221ba1d..33afac6f57b 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -108,16 +108,19 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -_SINGLE_CALSEARCH_CONFIG = vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_DEVICE_ID): cv.string, - vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean, - vol.Optional(CONF_OFFSET): cv.string, - vol.Optional(CONF_SEARCH): cv.string, - vol.Optional(CONF_TRACK): cv.boolean, - vol.Optional(CONF_MAX_RESULTS): cv.positive_int, - } +_SINGLE_CALSEARCH_CONFIG = vol.All( + cv.deprecated(CONF_MAX_RESULTS), + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean, + vol.Optional(CONF_OFFSET): cv.string, + vol.Optional(CONF_SEARCH): cv.string, + vol.Optional(CONF_TRACK): cv.boolean, + vol.Optional(CONF_MAX_RESULTS): cv.positive_int, # Now unused + } + ), ) DEVICE_SCHEMA = vol.Schema( diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 2cc66121948..5c06e0fbb94 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -18,7 +18,6 @@ from homeassistant.util import Throttle, dt from . import ( CONF_CAL_ID, CONF_IGNORE_AVAILABILITY, - CONF_MAX_RESULTS, CONF_SEARCH, CONF_TRACK, DEFAULT_CONF_OFFSET, @@ -30,7 +29,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_GOOGLE_SEARCH_PARAMS = { "orderBy": "startTime", - "maxResults": 5, "singleEvents": True, } @@ -71,7 +69,6 @@ class GoogleCalendarEventDevice(CalendarEventDevice): calendar, data.get(CONF_SEARCH), data.get(CONF_IGNORE_AVAILABILITY), - data.get(CONF_MAX_RESULTS), ) self._event = None self._name = data[CONF_NAME] @@ -113,15 +110,12 @@ class GoogleCalendarEventDevice(CalendarEventDevice): class GoogleCalendarData: """Class to utilize calendar service object to get next event.""" - def __init__( - self, calendar_service, calendar_id, search, ignore_availability, max_results - ): + def __init__(self, calendar_service, calendar_id, search, ignore_availability): """Set up how we are going to search the google calendar.""" self.calendar_service = calendar_service self.calendar_id = calendar_id self.search = search self.ignore_availability = ignore_availability - self.max_results = max_results self.event = None def _prepare_query(self): @@ -132,8 +126,8 @@ class GoogleCalendarData: return None, None params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) params["calendarId"] = self.calendar_id - if self.max_results: - params["maxResults"] = self.max_results + params["maxResults"] = 100 # Page size + if self.search: params["q"] = self.search @@ -147,18 +141,30 @@ class GoogleCalendarData: params["timeMin"] = start_date.isoformat("T") params["timeMax"] = end_date.isoformat("T") + event_list = [] events = await hass.async_add_executor_job(service.events) + page_token = None + while True: + page_token = await self.async_get_events_page( + hass, events, params, page_token, event_list + ) + if not page_token: + break + return event_list + + async def async_get_events_page(self, hass, events, params, page_token, event_list): + """Get a page of events in a specific time frame.""" + params["pageToken"] = page_token result = await hass.async_add_executor_job(events.list(**params).execute) items = result.get("items", []) - event_list = [] for item in items: if not self.ignore_availability and "transparency" in item: if item["transparency"] == "opaque": event_list.append(item) else: event_list.append(item) - return event_list + return result.get("nextPageToken") @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): From e0a8ec4f62afef83e015f470bd7348c34bbbdccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 17 Aug 2021 08:30:35 +0200 Subject: [PATCH 437/903] Add device class update to the updater binary_sensor (#54732) --- .../components/updater/binary_sensor.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/updater/binary_sensor.py b/homeassistant/components/updater/binary_sensor.py index 1c6bacede62..25339f6308a 100644 --- a/homeassistant/components/updater/binary_sensor.py +++ b/homeassistant/components/updater/binary_sensor.py @@ -1,7 +1,10 @@ """Support for Home Assistant Updater binary sensors.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_UPDATE, + BinarySensorEntity, +) from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ATTR_NEWEST_VERSION, ATTR_RELEASE_NOTES, DOMAIN as UPDATER_DOMAIN @@ -18,15 +21,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class UpdaterBinary(CoordinatorEntity, BinarySensorEntity): """Representation of an updater binary sensor.""" - @property - def name(self) -> str: - """Return the name of the binary sensor, if any.""" - return "Updater" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return "updater" + _attr_device_class = DEVICE_CLASS_UPDATE + _attr_name = "Updater" + _attr_unique_id = "updater" @property def is_on(self) -> bool | None: From 789e6555cc8746c666e7249f2f2f1bbce9d73452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 17 Aug 2021 08:30:55 +0200 Subject: [PATCH 438/903] Add device class update to hassio update entities (#54733) --- homeassistant/components/hassio/binary_sensor.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index 01930b5ec0e..7345dd4a000 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -1,7 +1,10 @@ """Binary sensor platform for Hass.io addons.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_UPDATE, + BinarySensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -35,6 +38,8 @@ async def async_setup_entry( class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity): """Binary sensor to track whether an update is available for a Hass.io add-on.""" + _attr_device_class = DEVICE_CLASS_UPDATE + @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" @@ -44,6 +49,8 @@ class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity): class HassioOSBinarySensor(HassioOSEntity, BinarySensorEntity): """Binary sensor to track whether an update is available for Hass.io OS.""" + _attr_device_class = DEVICE_CLASS_UPDATE + @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" From 4c5d5a8f5a4402cb7c908d6016041ff0d001d374 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 17 Aug 2021 08:34:41 +0200 Subject: [PATCH 439/903] Update deCONZ to use new state classes (#54729) --- homeassistant/components/deconz/sensor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index a741a2d37c1..012e686534f 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -17,6 +17,7 @@ from pydeconz.sensor import ( from homeassistant.components.sensor import ( DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -41,7 +42,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.util import dt as dt_util from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice @@ -68,7 +68,7 @@ ICON = { } STATE_CLASS = { - Consumption: STATE_CLASS_MEASUREMENT, + Consumption: STATE_CLASS_TOTAL_INCREASING, Humidity: STATE_CLASS_MEASUREMENT, Pressure: STATE_CLASS_MEASUREMENT, Temperature: STATE_CLASS_MEASUREMENT, @@ -164,9 +164,6 @@ class DeconzSensor(DeconzDevice, SensorEntity): type(self._device) ) - if device.type in Consumption.ZHATYPE: - self._attr_last_reset = dt_util.utc_from_timestamp(0) - @callback def async_update_callback(self, force_update=False): """Update the sensor's state.""" From afade22feb2ed439e70a1bbfc4b6288de9b8bdc7 Mon Sep 17 00:00:00 2001 From: Andre Richter Date: Tue, 17 Aug 2021 10:05:28 +0200 Subject: [PATCH 440/903] Add state classes to Vallox sensors (#54297) --- homeassistant/components/vallox/sensor.py | 25 +++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 836931f089e..dd669e156cf 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2, @@ -44,6 +44,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device_class=None, unit_of_measurement=PERCENTAGE, icon="mdi:fan", + state_class=STATE_CLASS_MEASUREMENT, ), ValloxSensor( name=f"{name} Extract Air", @@ -52,6 +53,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device_class=DEVICE_CLASS_TEMPERATURE, unit_of_measurement=TEMP_CELSIUS, icon=None, + state_class=STATE_CLASS_MEASUREMENT, ), ValloxSensor( name=f"{name} Exhaust Air", @@ -60,6 +62,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device_class=DEVICE_CLASS_TEMPERATURE, unit_of_measurement=TEMP_CELSIUS, icon=None, + state_class=STATE_CLASS_MEASUREMENT, ), ValloxSensor( name=f"{name} Outdoor Air", @@ -68,6 +71,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device_class=DEVICE_CLASS_TEMPERATURE, unit_of_measurement=TEMP_CELSIUS, icon=None, + state_class=STATE_CLASS_MEASUREMENT, ), ValloxSensor( name=f"{name} Supply Air", @@ -76,6 +80,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device_class=DEVICE_CLASS_TEMPERATURE, unit_of_measurement=TEMP_CELSIUS, icon=None, + state_class=STATE_CLASS_MEASUREMENT, ), ValloxSensor( name=f"{name} Humidity", @@ -84,6 +89,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device_class=DEVICE_CLASS_HUMIDITY, unit_of_measurement=PERCENTAGE, icon=None, + state_class=STATE_CLASS_MEASUREMENT, ), ValloxFilterRemainingSensor( name=f"{name} Remaining Time For Filter", @@ -100,6 +106,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device_class=None, unit_of_measurement=PERCENTAGE, icon="mdi:gauge", + state_class=STATE_CLASS_MEASUREMENT, ), ValloxSensor( name=f"{name} CO2", @@ -108,6 +115,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device_class=DEVICE_CLASS_CO2, unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, icon=None, + state_class=STATE_CLASS_MEASUREMENT, ), ] @@ -118,13 +126,21 @@ class ValloxSensor(SensorEntity): """Representation of a Vallox sensor.""" def __init__( - self, name, state_proxy, metric_key, device_class, unit_of_measurement, icon + self, + name, + state_proxy, + metric_key, + device_class, + unit_of_measurement, + icon, + state_class=None, ) -> None: """Initialize the Vallox sensor.""" self._name = name self._state_proxy = state_proxy self._metric_key = metric_key self._device_class = device_class + self._state_class = state_class self._unit_of_measurement = unit_of_measurement self._icon = icon self._available = None @@ -150,6 +166,11 @@ class ValloxSensor(SensorEntity): """Return the device class.""" return self._device_class + @property + def state_class(self): + """Return the state class.""" + return self._state_class + @property def icon(self): """Return the icon.""" From 69bc6bbe489b175bec6441ccc831d6522b77bd67 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 17 Aug 2021 10:10:56 +0200 Subject: [PATCH 441/903] Activate mypy for google_pubsub (#54649) --- .coveragerc | 1 + homeassistant/components/google_pubsub/__init__.py | 9 ++++++--- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.coveragerc b/.coveragerc index 4b5c0820650..e25f664efe8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -375,6 +375,7 @@ omit = homeassistant/components/google/* homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py + homeassistant/components/google_pubsub/__init__.py homeassistant/components/google_travel_time/__init__.py homeassistant/components/google_travel_time/helpers.py homeassistant/components/google_travel_time/sensor.py diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index 514b919e877..19530d9d663 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -45,9 +45,12 @@ def setup(hass: HomeAssistant, yaml_config: dict[str, Any]): config = yaml_config[DOMAIN] project_id = config[CONF_PROJECT_ID] topic_name = config[CONF_TOPIC_NAME] - service_principal_path = os.path.join( - hass.config.config_dir, config[CONF_SERVICE_PRINCIPAL] - ) + if hass.config.config_dir: + service_principal_path = os.path.join( + hass.config.config_dir, config[CONF_SERVICE_PRINCIPAL] + ) + else: + service_principal_path = config[CONF_SERVICE_PRINCIPAL] if not os.path.isfile(service_principal_path): _LOGGER.error("Path to credentials file cannot be found") diff --git a/mypy.ini b/mypy.ini index 837dc73343a..3108f73a49e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1367,9 +1367,6 @@ ignore_errors = true [mypy-homeassistant.components.google_assistant.*] ignore_errors = true -[mypy-homeassistant.components.google_pubsub.*] -ignore_errors = true - [mypy-homeassistant.components.gpmdp.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 513be3e59c2..6a863355afc 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -48,7 +48,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.geniushub.*", "homeassistant.components.glances.*", "homeassistant.components.google_assistant.*", - "homeassistant.components.google_pubsub.*", "homeassistant.components.gpmdp.*", "homeassistant.components.gree.*", "homeassistant.components.growatt_server.*", From 6d7ad8903f74959c6e3309907c77bc256bff5555 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Tue, 17 Aug 2021 09:19:44 +0100 Subject: [PATCH 442/903] Energy support for Solax inverters (#54654) Co-authored-by: Franck Nijhof --- homeassistant/components/solax/sensor.py | 51 ++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index 4d1652e8b12..7854142c32b 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -6,8 +6,23 @@ from solax import real_time_api from solax.inverter import InverterError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, TEMP_CELSIUS +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PORT, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + TEMP_CELSIUS, +) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval @@ -34,10 +49,28 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_track_time_interval(hass, endpoint.async_refresh, SCAN_INTERVAL) devices = [] for sensor, (idx, unit) in api.inverter.sensor_map().items(): + device_class = state_class = None if unit == "C": + device_class = DEVICE_CLASS_TEMPERATURE + state_class = STATE_CLASS_MEASUREMENT unit = TEMP_CELSIUS + elif unit == "kWh": + device_class = DEVICE_CLASS_ENERGY + state_class = STATE_CLASS_TOTAL_INCREASING + elif unit == "V": + device_class = DEVICE_CLASS_VOLTAGE + state_class = STATE_CLASS_MEASUREMENT + elif unit == "A": + device_class = DEVICE_CLASS_CURRENT + state_class = STATE_CLASS_MEASUREMENT + elif unit == "W": + device_class = DEVICE_CLASS_POWER + state_class = STATE_CLASS_MEASUREMENT + elif unit == "%": + device_class = DEVICE_CLASS_BATTERY + state_class = STATE_CLASS_MEASUREMENT uid = f"{serial}-{idx}" - devices.append(Inverter(uid, serial, sensor, unit)) + devices.append(Inverter(uid, serial, sensor, unit, state_class, device_class)) endpoint.sensors = devices async_add_entities(devices) @@ -75,13 +108,23 @@ class RealTimeDataEndpoint: class Inverter(SensorEntity): """Class for a sensor.""" - def __init__(self, uid, serial, key, unit): + def __init__( + self, + uid, + serial, + key, + unit, + state_class=None, + device_class=None, + ): """Initialize an inverter sensor.""" self.uid = uid self.serial = serial self.key = key self.value = None self.unit = unit + self._attr_state_class = state_class + self._attr_device_class = device_class @property def native_value(self): From 4f3d1c5e126223c2ec6a18deb42be265338f9098 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 17 Aug 2021 10:49:22 +0200 Subject: [PATCH 443/903] Use PM1, PM25 and PM10 device classes in Nettigo Air Monitor integration (#54741) --- homeassistant/components/nam/const.py | 15 +++++++++------ tests/components/nam/test_sensor.py | 15 +++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py index a9d044f2c1d..da4831de9e5 100644 --- a/homeassistant/components/nam/const.py +++ b/homeassistant/components/nam/const.py @@ -13,6 +13,9 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, @@ -122,14 +125,14 @@ SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( key=ATTR_SDS011_P1, name=f"{DEFAULT_NAME} SDS011 Particulate Matter 10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM10, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SDS011_P2, name=f"{DEFAULT_NAME} SDS011 Particulate Matter 2.5", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM25, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( @@ -150,28 +153,28 @@ SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( key=ATTR_SPS30_P0, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 1.0", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM1, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P1, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM10, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P2, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 2.5", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM25, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P4, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 4.0", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + icon="mdi:molecule", state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index c5850ce719d..ce9a221007a 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -19,6 +19,9 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, @@ -212,12 +215,12 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sds011_particulate_matter_10") assert state assert state.state == "19" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get( "sensor.nettigo_air_monitor_sds011_particulate_matter_10" @@ -228,12 +231,12 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sds011_particulate_matter_2_5") assert state assert state.state == "11" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get( "sensor.nettigo_air_monitor_sds011_particulate_matter_2_5" @@ -244,12 +247,12 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_1_0") assert state assert state.state == "31" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM1 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get( "sensor.nettigo_air_monitor_sps30_particulate_matter_1_0" @@ -260,12 +263,12 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_10") assert state assert state.state == "21" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get("sensor.nettigo_air_monitor_sps30_particulate_matter_10") assert entry @@ -274,12 +277,12 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_2_5") assert state assert state.state == "34" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get( "sensor.nettigo_air_monitor_sps30_particulate_matter_2_5" @@ -295,7 +298,7 @@ async def test_sensor(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_ICON) == "mdi:molecule" entry = registry.async_get( "sensor.nettigo_air_monitor_sps30_particulate_matter_4_0" From f1f05cdf1b7a75b7f76d09e43c1749fa17c8768d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 17 Aug 2021 11:57:45 +0200 Subject: [PATCH 444/903] Use DEVICE_CLASS_UPDATE in Shelly integration (#54746) --- homeassistant/components/shelly/binary_sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index dd1b3a9d66d..96d62152830 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_POWER, DEVICE_CLASS_PROBLEM, DEVICE_CLASS_SMOKE, + DEVICE_CLASS_UPDATE, DEVICE_CLASS_VIBRATION, STATE_ON, BinarySensorEntity, @@ -99,7 +100,7 @@ REST_SENSORS: Final = { ), "fwupdate": RestAttributeDescription( name="Firmware Update", - icon="mdi:update", + device_class=DEVICE_CLASS_UPDATE, value=lambda status, _: status["update"]["has_update"], default_enabled=False, extra_state_attributes=lambda status: { From a2c9cfbf415bfe10afc64a08a7999ae38ee90c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 17 Aug 2021 12:14:14 +0200 Subject: [PATCH 445/903] Use entity descriptions for hassio entities (#54749) --- .../components/hassio/binary_sensor.py | 52 ++++++++----- homeassistant/components/hassio/const.py | 5 +- homeassistant/components/hassio/entity.py | 75 +++---------------- homeassistant/components/hassio/sensor.py | 48 ++++++++---- 4 files changed, 86 insertions(+), 94 deletions(-) diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index 7345dd4a000..dfd13adbde6 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -4,15 +4,25 @@ from __future__ import annotations from homeassistant.components.binary_sensor import ( DEVICE_CLASS_UPDATE, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ADDONS_COORDINATOR -from .const import ATTR_UPDATE_AVAILABLE +from .const import ATTR_UPDATE_AVAILABLE, DATA_KEY_ADDONS, DATA_KEY_OS from .entity import HassioAddonEntity, HassioOSEntity +ENTITY_DESCRIPTIONS = ( + BinarySensorEntityDescription( + device_class=DEVICE_CLASS_UPDATE, + entity_registry_enabled_default=False, + key=ATTR_UPDATE_AVAILABLE, + name="Update Available", + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -22,36 +32,44 @@ async def async_setup_entry( """Binary sensor set up for Hass.io config entry.""" coordinator = hass.data[ADDONS_COORDINATOR] - entities = [ - HassioAddonBinarySensor( - coordinator, addon, ATTR_UPDATE_AVAILABLE, "Update Available" - ) - for addon in coordinator.data["addons"].values() - ] - if coordinator.is_hass_os: - entities.append( - HassioOSBinarySensor(coordinator, ATTR_UPDATE_AVAILABLE, "Update Available") - ) + entities = [] + + for entity_description in ENTITY_DESCRIPTIONS: + for addon in coordinator.data[DATA_KEY_ADDONS].values(): + entities.append( + HassioAddonBinarySensor( + addon=addon, + coordinator=coordinator, + entity_description=entity_description, + ) + ) + + if coordinator.is_hass_os: + entities.append( + HassioOSBinarySensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + async_add_entities(entities) class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity): """Binary sensor to track whether an update is available for a Hass.io add-on.""" - _attr_device_class = DEVICE_CLASS_UPDATE - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.addon_info[self.attribute_name] + return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ + self.entity_description.key + ] class HassioOSBinarySensor(HassioOSEntity, BinarySensorEntity): """Binary sensor to track whether an update is available for Hass.io OS.""" - _attr_device_class = DEVICE_CLASS_UPDATE - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.os_info[self.attribute_name] + return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 6104e57fb17..134fba15f70 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -40,7 +40,6 @@ WS_TYPE_SUBSCRIBE = "supervisor/subscribe" EVENT_SUPERVISOR_EVENT = "supervisor_event" -# Add-on keys ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_UPDATE_AVAILABLE = "update_available" @@ -49,6 +48,10 @@ ATTR_URL = "url" ATTR_REPOSITORY = "repository" +DATA_KEY_ADDONS = "addons" +DATA_KEY_OS = "os" + + class SupervisorEntityModel(str, Enum): """Supervisor entity model.""" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 4885ba8979f..4a342e9965f 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant.const import ATTR_NAME -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, HassioDataUpdateCoordinator @@ -17,42 +17,16 @@ class HassioAddonEntity(CoordinatorEntity): def __init__( self, coordinator: HassioDataUpdateCoordinator, + entity_description: EntityDescription, addon: dict[str, Any], - attribute_name: str, - sensor_name: str, ) -> None: """Initialize base entity.""" - self.addon_slug = addon[ATTR_SLUG] - self.addon_name = addon[ATTR_NAME] - self._data_key = "addons" - self.attribute_name = attribute_name - self.sensor_name = sensor_name super().__init__(coordinator) - - @property - def addon_info(self) -> dict[str, Any]: - """Return add-on info.""" - return self.coordinator.data[self._data_key][self.addon_slug] - - @property - def name(self) -> str: - """Return entity name.""" - return f"{self.addon_name}: {self.sensor_name}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def unique_id(self) -> str: - """Return unique ID for entity.""" - return f"{self.addon_slug}_{self.attribute_name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return {"identifiers": {(DOMAIN, self.addon_slug)}} + self.entity_description = entity_description + self._addon_slug = addon[ATTR_SLUG] + self._attr_name = f"{addon[ATTR_NAME]}: {entity_description.name}" + self._attr_unique_id = f"{addon[ATTR_SLUG]}_{entity_description.key}" + self._attr_device_info = {"identifiers": {(DOMAIN, addon[ATTR_SLUG])}} class HassioOSEntity(CoordinatorEntity): @@ -61,36 +35,11 @@ class HassioOSEntity(CoordinatorEntity): def __init__( self, coordinator: HassioDataUpdateCoordinator, - attribute_name: str, - sensor_name: str, + entity_description: EntityDescription, ) -> None: """Initialize base entity.""" - self._data_key = "os" - self.attribute_name = attribute_name - self.sensor_name = sensor_name super().__init__(coordinator) - - @property - def os_info(self) -> dict[str, Any]: - """Return OS info.""" - return self.coordinator.data[self._data_key] - - @property - def name(self) -> str: - """Return entity name.""" - return f"Home Assistant Operating System: {self.sensor_name}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def unique_id(self) -> str: - """Return unique ID for entity.""" - return f"home_assistant_os_{self.attribute_name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return {"identifiers": {(DOMAIN, "OS")}} + self.entity_description = entity_description + self._attr_name = f"Home Assistant Operating System: {entity_description.name}" + self._attr_unique_id = f"home_assistant_os_{entity_description.key}" + self._attr_device_info = {"identifiers": {(DOMAIN, "OS")}} diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index c0c3e63715c..55678eb29c4 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -1,15 +1,28 @@ """Sensor platform for Hass.io addons.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ADDONS_COORDINATOR -from .const import ATTR_VERSION, ATTR_VERSION_LATEST +from .const import ATTR_VERSION, ATTR_VERSION_LATEST, DATA_KEY_ADDONS, DATA_KEY_OS from .entity import HassioAddonEntity, HassioOSEntity +ENTITY_DESCRIPTIONS = ( + SensorEntityDescription( + entity_registry_enabled_default=False, + key=ATTR_VERSION, + name="Version", + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key=ATTR_VERSION_LATEST, + name="Newest Version", + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -21,16 +34,23 @@ async def async_setup_entry( entities = [] - for attribute_name, sensor_name in ( - (ATTR_VERSION, "Version"), - (ATTR_VERSION_LATEST, "Newest Version"), - ): - for addon in coordinator.data["addons"].values(): + for entity_description in ENTITY_DESCRIPTIONS: + for addon in coordinator.data[DATA_KEY_ADDONS].values(): entities.append( - HassioAddonSensor(coordinator, addon, attribute_name, sensor_name) + HassioAddonSensor( + addon=addon, + coordinator=coordinator, + entity_description=entity_description, + ) ) + if coordinator.is_hass_os: - entities.append(HassioOSSensor(coordinator, attribute_name, sensor_name)) + entities.append( + HassioOSSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) async_add_entities(entities) @@ -40,8 +60,10 @@ class HassioAddonSensor(HassioAddonEntity, SensorEntity): @property def native_value(self) -> str: - """Return state of entity.""" - return self.addon_info[self.attribute_name] + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ + self.entity_description.key + ] class HassioOSSensor(HassioOSEntity, SensorEntity): @@ -49,5 +71,5 @@ class HassioOSSensor(HassioOSEntity, SensorEntity): @property def native_value(self) -> str: - """Return state of entity.""" - return self.os_info[self.attribute_name] + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] From 013b998974c889c8a80d04637a9ea8e43c7e2fc3 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 17 Aug 2021 07:34:00 -0400 Subject: [PATCH 446/903] Relax zwave_js lock discovery rules to cover more use cases (#54710) --- .../components/zwave_js/discovery.py | 20 - tests/components/zwave_js/conftest.py | 20 +- tests/components/zwave_js/test_discovery.py | 11 + ...pp_electric_strike_lock_control_state.json | 568 ++++++++++++++++++ 4 files changed, 598 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/zwave_js/lock_popp_electric_strike_lock_control_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 58dae39781e..dcae65b0395 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -359,24 +359,11 @@ DISCOVERY_SCHEMAS = [ get_config_parameter_discovery_schema( property_name={"Door lock mode"}, device_class_generic={"Entry Control"}, - device_class_specific={ - "Door Lock", - "Advanced Door Lock", - "Secure Keypad Door Lock", - "Secure Lockbox", - }, ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks ZWaveDiscoverySchema( platform="lock", - device_class_generic={"Entry Control"}, - device_class_specific={ - "Door Lock", - "Advanced Door Lock", - "Secure Keypad Door Lock", - "Secure Lockbox", - }, primary_value=ZWaveValueDiscoverySchema( command_class={ CommandClass.LOCK, @@ -390,13 +377,6 @@ DISCOVERY_SCHEMAS = [ ZWaveDiscoverySchema( platform="binary_sensor", hint="property", - device_class_generic={"Entry Control"}, - device_class_specific={ - "Door Lock", - "Advanced Door Lock", - "Secure Keypad Door Lock", - "Secure Lockbox", - }, primary_value=ZWaveValueDiscoverySchema( command_class={ CommandClass.LOCK, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 75b5ab65d38..8165dac33a7 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -452,6 +452,14 @@ def aeotec_zw164_siren_state_fixture(): return json.loads(load_fixture("zwave_js/aeotec_zw164_siren_state.json")) +@pytest.fixture(name="lock_popp_electric_strike_lock_control_state", scope="session") +def lock_popp_electric_strike_lock_control_state_fixture(): + """Load the popp electric strike lock control node state fixture data.""" + return json.loads( + load_fixture("zwave_js/lock_popp_electric_strike_lock_control_state.json") + ) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state, log_config_state): """Mock a client.""" @@ -830,12 +838,22 @@ def ge_in_wall_dimmer_switch_fixture(client, ge_in_wall_dimmer_switch_state): @pytest.fixture(name="aeotec_zw164_siren") def aeotec_zw164_siren_fixture(client, aeotec_zw164_siren_state): - """Mock a wallmote central scene node.""" + """Mock a aeotec zw164 siren node.""" node = Node(client, copy.deepcopy(aeotec_zw164_siren_state)) client.driver.controller.nodes[node.node_id] = node return node +@pytest.fixture(name="lock_popp_electric_strike_lock_control") +def lock_popp_electric_strike_lock_control_fixture( + client, lock_popp_electric_strike_lock_control_state +): + """Mock a popp electric strike lock control node.""" + node = Node(client, copy.deepcopy(lock_popp_electric_strike_lock_control_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="firmware_file") def firmware_file_fixture(): """Return mock firmware file stream.""" diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 8914019cd43..9758d3b0f44 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -57,6 +57,17 @@ async def test_vision_security_zl7432( assert state.attributes["assumed_state"] +async def test_lock_popp_electric_strike_lock_control( + hass, client, lock_popp_electric_strike_lock_control, integration +): + """Test that the Popp Electric Strike Lock Control gets discovered correctly.""" + assert hass.states.get("lock.node_62") is not None + assert ( + hass.states.get("binary_sensor.node_62_the_current_status_of_the_door") + is not None + ) + + async def test_firmware_version_range_exception(hass): """Test FirmwareVersionRange exception.""" with pytest.raises(ValueError): diff --git a/tests/fixtures/zwave_js/lock_popp_electric_strike_lock_control_state.json b/tests/fixtures/zwave_js/lock_popp_electric_strike_lock_control_state.json new file mode 100644 index 00000000000..2b4a3a88984 --- /dev/null +++ b/tests/fixtures/zwave_js/lock_popp_electric_strike_lock_control_state.json @@ -0,0 +1,568 @@ +{ + "nodeId": 62, + "index": 0, + "installerIcon": 768, + "userIcon": 768, + "status": 4, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 340, + "productId": 1, + "productType": 5, + "firmwareVersion": "1.3", + "zwavePlusVersion": 1, + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 62, + "index": 0, + "installerIcon": 768, + "userIcon": 768, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 64, + "label": "Entry Control" + }, + "specific": { + "key": 10, + "label": "Lockbox" + }, + "mandatorySupportedCCs": [113, 133, 98, 114, 152, 134], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 48, + "commandClassName": "Binary Sensor", + "property": "Door/Window", + "propertyName": "Door/Window", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Door/Window", + "ccSpecific": { + "sensorType": 10 + } + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "currentMode", + "propertyName": "currentMode", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current lock mode", + "min": 0, + "max": 255, + "states": { + "0": "Unsecured", + "1": "UnsecuredWithTimeout", + "16": "InsideUnsecured", + "17": "InsideUnsecuredWithTimeout", + "32": "OutsideUnsecured", + "33": "OutsideUnsecuredWithTimeout", + "254": "Unknown", + "255": "Secured" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "targetMode", + "propertyName": "targetMode", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target lock mode", + "min": 0, + "max": 255, + "states": { + "0": "Unsecured", + "1": "UnsecuredWithTimeout", + "16": "InsideUnsecured", + "17": "InsideUnsecuredWithTimeout", + "32": "OutsideUnsecured", + "33": "OutsideUnsecuredWithTimeout", + "254": "Unknown", + "255": "Secured" + } + } + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "outsideHandlesCanOpenDoor", + "propertyName": "outsideHandlesCanOpenDoor", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Which outside handles can open the door (actual status)" + }, + "value": [false, false, false, false] + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "insideHandlesCanOpenDoor", + "propertyName": "insideHandlesCanOpenDoor", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Which inside handles can open the door (actual status)" + }, + "value": [false, false, false, false] + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "latchStatus", + "propertyName": "latchStatus", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "The current status of the latch" + }, + "value": "closed" + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "boltStatus", + "propertyName": "boltStatus", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "The current status of the bolt" + }, + "value": "unlocked" + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "doorStatus", + "propertyName": "doorStatus", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "The current status of the door" + }, + "value": "closed" + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "lockTimeout", + "propertyName": "lockTimeout", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Seconds until lock mode times out" + } + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "operationType", + "propertyName": "operationType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Lock operation type", + "min": 0, + "max": 255, + "states": { + "1": "Constant", + "2": "Timed" + } + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "outsideHandlesCanOpenDoorConfiguration", + "propertyName": "outsideHandlesCanOpenDoorConfiguration", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Which outside handles can open the door (configuration)" + }, + "value": [false, false, false, false] + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "insideHandlesCanOpenDoorConfiguration", + "propertyName": "insideHandlesCanOpenDoorConfiguration", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Which inside handles can open the door (configuration)" + }, + "value": [false, false, false, false] + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "lockTimeoutConfiguration", + "propertyName": "lockTimeoutConfiguration", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Duration of timed mode in seconds", + "min": 0, + "max": 65535 + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Access Control", + "propertyKey": "Door state", + "propertyName": "Access Control", + "propertyKeyName": "Door state", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Door state", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "22": "Window/door is open", + "23": "Window/door is closed" + } + }, + "value": 23 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 340 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level" + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.5" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["1.3"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 1 + } + ], + "isFrequentListening": "1000ms", + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 64, + "label": "Entry Control" + }, + "specific": { + "key": 10, + "label": "Lockbox" + }, + "mandatorySupportedCCs": [113, 133, 98, 114, 152, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 48, + "name": "Binary Sensor", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": true + }, + { + "id": 98, + "name": "Door Lock", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 5, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 3, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0154:0x0005:0x0001:1.3", + "statistics": { + "commandsTX": 1, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + } +} From 9dab920d01c831659b9fe71ab16cf41122cb99e8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 17 Aug 2021 14:26:02 +0200 Subject: [PATCH 447/903] DSMR: Remove icon from sensors with gas device class (#54752) --- homeassistant/components/dsmr/const.py | 3 --- tests/components/dsmr/test_sensor.py | 5 ----- 2 files changed, 8 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 0043113772e..6c392526ee3 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -251,7 +251,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"4", "5", "5L"}, is_gas=True, force_update=True, - icon="mdi:fire", device_class=DEVICE_CLASS_GAS, state_class=STATE_CLASS_TOTAL_INCREASING, ), @@ -261,7 +260,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"5B"}, is_gas=True, force_update=True, - icon="mdi:fire", device_class=DEVICE_CLASS_GAS, state_class=STATE_CLASS_TOTAL_INCREASING, ), @@ -271,7 +269,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"2.2"}, is_gas=True, force_update=True, - icon="mdi:fire", device_class=DEVICE_CLASS_GAS, state_class=STATE_CLASS_TOTAL_INCREASING, ), diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 0f1c55f47b6..88e984cea1b 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -167,7 +167,6 @@ async def test_default_setup(hass, dsmr_connection_fixture): gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS - assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING @@ -269,7 +268,6 @@ async def test_v4_meter(hass, dsmr_connection_fixture): assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS - assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING @@ -341,7 +339,6 @@ async def test_v5_meter(hass, dsmr_connection_fixture): gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS - assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING @@ -424,7 +421,6 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS - assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING @@ -496,7 +492,6 @@ async def test_belgian_meter(hass, dsmr_connection_fixture): gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is DEVICE_CLASS_GAS - assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING From 346310ccafed1f4dfa60237b64723f7fcb0423cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20M=C3=BCndler?= Date: Tue, 17 Aug 2021 14:59:56 +0200 Subject: [PATCH 448/903] Bump pyfronius version to 0.5.5 (#54758) - allows for trailing slashes in configuration (which would otherwise cause errors in the newest fronius firmware) - fixes units of energy related sensors --- homeassistant/components/fronius/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 1ae95d30fd5..a8e9c44805d 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -2,7 +2,7 @@ "domain": "fronius", "name": "Fronius", "documentation": "https://www.home-assistant.io/integrations/fronius", - "requirements": ["pyfronius==0.5.3"], + "requirements": ["pyfronius==0.5.5"], "codeowners": ["@nielstron"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index dfbfd346929..21d8d158545 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1466,7 +1466,7 @@ pyfreedompro==1.1.0 pyfritzhome==0.6.2 # homeassistant.components.fronius -pyfronius==0.5.3 +pyfronius==0.5.5 # homeassistant.components.ifttt pyfttt==0.3 From 043841e70f7fabdd9dd1bee94a9a19fb5ed03d90 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Tue, 17 Aug 2021 23:06:22 +1000 Subject: [PATCH 449/903] Solax 0.2.8 (#54759) --- homeassistant/components/solax/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index d14cfea2501..f6a6f581e12 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -2,7 +2,7 @@ "domain": "solax", "name": "SolaX Power", "documentation": "https://www.home-assistant.io/integrations/solax", - "requirements": ["solax==0.2.6"], + "requirements": ["solax==0.2.8"], "codeowners": ["@squishykid"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 21d8d158545..1f0e4e1b665 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2165,7 +2165,7 @@ solaredge-local==0.2.0 solaredge==0.0.2 # homeassistant.components.solax -solax==0.2.6 +solax==0.2.8 # homeassistant.components.honeywell somecomfort==0.5.2 From f39dc749bb81db0c271eac508fad265b37631bb1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 17 Aug 2021 16:28:18 +0200 Subject: [PATCH 450/903] Toon: Remove icon from sensors with gas device class (#54753) --- homeassistant/components/toon/const.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 1c58ec2cde7..678b3400b88 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -127,7 +127,6 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "day_average", ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_ICON: "mdi:gas-cylinder", ATTR_DEFAULT_ENABLED: False, }, "gas_daily_usage": { @@ -136,7 +135,6 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "day_usage", ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_ICON: "mdi:gas-cylinder", }, "gas_daily_cost": { ATTR_NAME: "Gas Cost Today", @@ -150,7 +148,6 @@ SENSOR_ENTITIES = { ATTR_SECTION: "gas_usage", ATTR_MEASUREMENT: "meter", ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_ICON: "mdi:gas-cylinder", ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, ATTR_DEFAULT_ENABLED: False, From ea8061469c25e5e90abfd98931048e2e702b99ad Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 17 Aug 2021 15:29:52 +0100 Subject: [PATCH 451/903] Deprecate homekit_controller's air quality entity in favor of separate sensor entities (#54673) --- .../homekit_controller/air_quality.py | 18 + .../components/homekit_controller/sensor.py | 55 +- .../specific_devices/test_arlo_baby.py | 84 +++ .../homekit_controller/test_air_quality.py | 8 + .../homekit_controller/arlo_baby.json | 484 ++++++++++++++++++ 5 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 tests/components/homekit_controller/specific_devices/test_arlo_baby.py create mode 100644 tests/fixtures/homekit_controller/arlo_baby.json diff --git a/homeassistant/components/homekit_controller/air_quality.py b/homeassistant/components/homekit_controller/air_quality.py index 2a162eb2b2a..b4ca2f4918a 100644 --- a/homeassistant/components/homekit_controller/air_quality.py +++ b/homeassistant/components/homekit_controller/air_quality.py @@ -1,4 +1,6 @@ """Support for HomeKit Controller air quality sensors.""" +import logging + from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes @@ -7,6 +9,8 @@ from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity +_LOGGER = logging.getLogger(__name__) + AIR_QUALITY_TEXT = { 0: "unknown", 1: "excellent", @@ -20,6 +24,20 @@ AIR_QUALITY_TEXT = { class HomeAirQualitySensor(HomeKitEntity, AirQualityEntity): """Representation of a HomeKit Controller Air Quality sensor.""" + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + _LOGGER.warning( + "The homekit_controller air_quality entity has been " + "deprecated and will be removed in 2021.12.0" + ) + await super().async_added_to_hass() + + @property + def entity_registry_enabled_default(self) -> bool: + """Whether or not to enable this entity by default.""" + # This entity is deprecated, so don't enable by default + return False + def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" return [ diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index b599e7263c8..ac4f19dadb4 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -4,12 +4,19 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, LIGHT_LUX, PERCENTAGE, @@ -52,7 +59,7 @@ SIMPLE_SENSOR = { "state_class": STATE_CLASS_MEASUREMENT, "unit": PRESSURE_HPA, }, - CharacteristicsTypes.get_uuid(CharacteristicsTypes.TEMPERATURE_CURRENT): { + CharacteristicsTypes.TEMPERATURE_CURRENT: { "name": "Current Temperature", "device_class": DEVICE_CLASS_TEMPERATURE, "state_class": STATE_CLASS_MEASUREMENT, @@ -62,7 +69,7 @@ SIMPLE_SENSOR = { "probe": lambda char: char.service.type != ServicesTypes.get_uuid(ServicesTypes.TEMPERATURE_SENSOR), }, - CharacteristicsTypes.get_uuid(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT): { + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: { "name": "Current Humidity", "device_class": DEVICE_CLASS_HUMIDITY, "state_class": STATE_CLASS_MEASUREMENT, @@ -72,8 +79,52 @@ SIMPLE_SENSOR = { "probe": lambda char: char.service.type != ServicesTypes.get_uuid(ServicesTypes.HUMIDITY_SENSOR), }, + CharacteristicsTypes.AIR_QUALITY: { + "name": "Air Quality", + "device_class": DEVICE_CLASS_AQI, + "state_class": STATE_CLASS_MEASUREMENT, + }, + CharacteristicsTypes.DENSITY_PM25: { + "name": "PM2.5 Density", + "device_class": DEVICE_CLASS_PM25, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + CharacteristicsTypes.DENSITY_PM10: { + "name": "PM10 Density", + "device_class": DEVICE_CLASS_PM10, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + CharacteristicsTypes.DENSITY_OZONE: { + "name": "Ozone Density", + "device_class": DEVICE_CLASS_OZONE, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + CharacteristicsTypes.DENSITY_NO2: { + "name": "Nitrogen Dioxide Density", + "device_class": DEVICE_CLASS_NITROGEN_DIOXIDE, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + CharacteristicsTypes.DENSITY_SO2: { + "name": "Sulphur Dioxide Density", + "device_class": DEVICE_CLASS_SULPHUR_DIOXIDE, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, } +# For legacy reasons, "built-in" characteristic types are in their short form +# And vendor types don't have a short form +# This means long and short forms get mixed up in this dict, and comparisons +# don't work! +# We call get_uuid on *every* type to normalise them to the long form +# Eventually aiohomekit will use the long form exclusively amd this can be removed. +for k, v in list(SIMPLE_SENSOR.items()): + SIMPLE_SENSOR[CharacteristicsTypes.get_uuid(k)] = SIMPLE_SENSOR.pop(k) + class HomeKitHumiditySensor(HomeKitEntity, SensorEntity): """Representation of a Homekit humidity sensor.""" diff --git a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py new file mode 100644 index 00000000000..86fb9f65f11 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py @@ -0,0 +1,84 @@ +"""Make sure that an Arlo Baby can be setup.""" + +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_arlo_baby_setup(hass): + """Test that an Arlo Baby can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "arlo_baby.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + sensors = [ + ( + "camera.arlobabya0", + "homekit-00A0000000000-aid:1", + "ArloBabyA0", + ), + ( + "binary_sensor.arlobabya0", + "homekit-00A0000000000-500", + "ArloBabyA0", + ), + ( + "sensor.arlobabya0_battery", + "homekit-00A0000000000-700", + "ArloBabyA0 Battery", + ), + ( + "sensor.arlobabya0_humidity", + "homekit-00A0000000000-900", + "ArloBabyA0 Humidity", + ), + ( + "sensor.arlobabya0_temperature", + "homekit-00A0000000000-1000", + "ArloBabyA0 Temperature", + ), + ( + "sensor.arlobabya0_air_quality", + "homekit-00A0000000000-aid:1-sid:800-cid:802", + "ArloBabyA0 - Air Quality", + ), + ( + "light.arlobabya0", + "homekit-00A0000000000-1100", + "ArloBabyA0", + ), + ] + + device_ids = set() + + for (entity_id, unique_id, friendly_name) in sensors: + entry = entity_registry.async_get(entity_id) + assert entry.unique_id == unique_id + + helper = Helper( + hass, + entity_id, + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == friendly_name + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == "Netgear, Inc" + assert device.name == "ArloBabyA0" + assert device.model == "ABC1000" + assert device.sw_version == "1.10.931" + assert device.via_device_id is None + + device_ids.add(entry.device_id) + + # All entities should be part of same device + assert len(device_ids) == 1 diff --git a/tests/components/homekit_controller/test_air_quality.py b/tests/components/homekit_controller/test_air_quality.py index 52c79f2b28a..f75335ca357 100644 --- a/tests/components/homekit_controller/test_air_quality.py +++ b/tests/components/homekit_controller/test_air_quality.py @@ -2,6 +2,8 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from homeassistant.helpers import entity_registry as er + from tests.components.homekit_controller.common import setup_test_component @@ -35,6 +37,12 @@ async def test_air_quality_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit temperature sensor accessory.""" helper = await setup_test_component(hass, create_air_quality_sensor_service) + entity_registry = er.async_get(hass) + entity_registry.async_update_entity( + entity_id="air_quality.testdevice", disabled_by=None + ) + await hass.async_block_till_done() + state = await helper.poll_and_get_state() assert state.state == "4444" diff --git a/tests/fixtures/homekit_controller/arlo_baby.json b/tests/fixtures/homekit_controller/arlo_baby.json new file mode 100644 index 00000000000..6a124a5f56f --- /dev/null +++ b/tests/fixtures/homekit_controller/arlo_baby.json @@ -0,0 +1,484 @@ +[ + { + "aid": 1, + "services": [ + { + "type": "0000003E-0000-1000-8000-0026BB765291", + "iid": 1, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "value": "ArloBabyA0", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "value": "Netgear, Inc", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "value": "00A0000000000", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "value": "ABC1000", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 7, + "value": "1.10.931", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": [ + "pw" + ], + "format": "bool" + } + ] + }, + { + "type": "000000A2-0000-1000-8000-0026BB765291", + "iid": 20, + "characteristics": [ + { + "type": "00000037-0000-1000-8000-0026BB765291", + "iid": 21, + "value": "1.1.0", + "perms": [ + "pr" + ], + "format": "string" + } + ] + }, + { + "type": "00000110-0000-1000-8000-0026BB765291", + "iid": 100, + "characteristics": [ + { + "type": "00000120-0000-1000-8000-0026BB765291", + "iid": 106, + "value": "AQEB", + "perms": [ + "pr", + "ev" + ], + "format": "tlv8" + }, + { + "type": "00000114-0000-1000-8000-0026BB765291", + "iid": 101, + "value": "AY8BAQACFQEBAAEBAQEBAQIBAAMBAAQBAAUBAQMLAQKABwICOAQDAR4DCwECAAUCAsADAwEeAwsBAgAEAgIAAwMBHgMLAQIABQIC0AIDAR4DCwECgAICAmgBAwEeAwsBAuABAgIOAQMBHgMLAQKAAgIC4AEDAR4DCwEC4AECAmgBAwEeAwsBAkABAgLwAAMBHg==", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000115-0000-1000-8000-0026BB765291", + "iid": 102, + "value": "AQ4BAQMCCQEBAQIBAAMBAQIBAA==", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000116-0000-1000-8000-0026BB765291", + "iid": 103, + "value": "AgEAAgEBAgEC", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000117-0000-1000-8000-0026BB765291", + "iid": 104, + "value": "", + "perms": [ + "pr", + "pw" + ], + "format": "tlv8" + }, + { + "type": "00000118-0000-1000-8000-0026BB765291", + "iid": 108, + "value": "", + "perms": [ + "pr", + "pw" + ], + "format": "tlv8" + } + ] + }, + { + "type": "00000110-0000-1000-8000-0026BB765291", + "iid": 110, + "characteristics": [ + { + "type": "00000120-0000-1000-8000-0026BB765291", + "iid": 116, + "value": "AQEA", + "perms": [ + "pr", + "ev" + ], + "format": "tlv8" + }, + { + "type": "00000114-0000-1000-8000-0026BB765291", + "iid": 111, + "value": "AWgBAQACFQEBAAEBAQEBAQIBAAMBAAQBAAUBAQMLAQIABQIC0AIDAR4DCwECgAICAmgBAwEeAwsBAuABAgIOAQMBHgMLAQKAAgIC4AEDAR4DCwEC4AECAmgBAwEeAwsBAkABAgLwAAMBHg==", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000115-0000-1000-8000-0026BB765291", + "iid": 112, + "value": "AQ4BAQMCCQEBAQIBAAMBAQIBAA==", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000116-0000-1000-8000-0026BB765291", + "iid": 113, + "value": "AgEAAgEBAgEC", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000117-0000-1000-8000-0026BB765291", + "iid": 114, + "value": "", + "perms": [ + "pr", + "pw" + ], + "format": "tlv8" + }, + { + "type": "00000118-0000-1000-8000-0026BB765291", + "iid": 118, + "value": "", + "perms": [ + "pr", + "pw" + ], + "format": "tlv8" + } + ] + }, + { + "type": "00000112-0000-1000-8000-0026BB765291", + "iid": 300, + "characteristics": [ + { + "type": "0000011A-0000-1000-8000-0026BB765291", + "iid": 302, + "value": false, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "bool" + } + ] + }, + { + "type": "00000113-0000-1000-8000-0026BB765291", + "iid": 400, + "characteristics": [ + { + "type": "0000011A-0000-1000-8000-0026BB765291", + "iid": 402, + "value": false, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "bool" + }, + { + "type": "00000119-0000-1000-8000-0026BB765291", + "iid": 403, + "value": 50, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "uint8", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + "unit": "percentage" + } + ] + }, + { + "type": "00000085-0000-1000-8000-0026BB765291", + "iid": 500, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 501, + "value": "Motion", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 502, + "value": false, + "perms": [ + "pr", + "ev" + ], + "format": "bool" + } + ] + }, + { + "type": "00000096-0000-1000-8000-0026BB765291", + "iid": 700, + "characteristics": [ + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 701, + "value": 82, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + "unit": "percentage" + }, + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 702, + "value": 0, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 703, + "value": 0, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + } + ] + }, + { + "type": "0000008D-0000-1000-8000-0026BB765291", + "iid": 800, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 801, + "value": "Air Quality", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000095-0000-1000-8000-0026BB765291", + "iid": 802, + "value": 1, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "minValue": 0, + "maxValue": 5, + "minStep": 1 + } + ] + }, + { + "type": "00000082-0000-1000-8000-0026BB765291", + "iid": 900, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 901, + "value": "Humidity", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000010-0000-1000-8000-0026BB765291", + "iid": 902, + "value": 60.099998, + "perms": [ + "pr", + "ev" + ], + "format": "float", + "minValue": 0.0, + "maxValue": 100.0, + "minStep": 1.0, + "unit": "percentage" + } + ] + }, + { + "type": "0000008A-0000-1000-8000-0026BB765291", + "iid": 1000, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 1001, + "value": "Temperature", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 1002, + "value": 24.0, + "perms": [ + "pr", + "ev" + ], + "format": "float", + "minValue": 0.0, + "maxValue": 100.0, + "minStep": 0.1, + "unit": "celsius" + } + ] + }, + { + "type": "00000043-0000-1000-8000-0026BB765291", + "iid": 1100, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 1101, + "value": "Nightlight", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000025-0000-1000-8000-0026BB765291", + "iid": 1102, + "value": false, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "bool" + }, + { + "type": "00000008-0000-1000-8000-0026BB765291", + "iid": 1103, + "value": 100, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "int", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + "unit": "percentage" + }, + { + "type": "00000013-0000-1000-8000-0026BB765291", + "iid": 1104, + "value": 0.0, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "float", + "minValue": 0.0, + "maxValue": 360.0, + "minStep": 1.0, + "unit": "arcdegrees" + }, + { + "type": "0000002F-0000-1000-8000-0026BB765291", + "iid": 1105, + "value": 0.0, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "float", + "minValue": 0.0, + "maxValue": 100.0, + "minStep": 1.0, + "unit": "percentage" + } + ] + } + ] + } +] \ No newline at end of file From 35f563e23e3c6a83c4db27ef1ee83201d5c72c20 Mon Sep 17 00:00:00 2001 From: LonePurpleWolf <38847877+LonePurpleWolf@users.noreply.github.com> Date: Wed, 18 Aug 2021 01:29:20 +1000 Subject: [PATCH 452/903] Airtouch4 integration (#43513) * airtouch 4 climate control integration * enhance tests for airtouch. Fix linting issues * Fix tests * rework tests * fix latest qa issues * Clean up * add already_configured message * Use common string * further qa fixes * simplify airtouch4 domain storage Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/airtouch4/__init__.py | 81 +++++ homeassistant/components/airtouch4/climate.py | 335 ++++++++++++++++++ .../components/airtouch4/config_flow.py | 50 +++ homeassistant/components/airtouch4/const.py | 3 + .../components/airtouch4/manifest.json | 13 + .../components/airtouch4/strings.json | 19 + .../components/airtouch4/translations/en.json | 17 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/airtouch4/__init__.py | 1 + .../components/airtouch4/test_config_flow.py | 123 +++++++ 14 files changed, 653 insertions(+) create mode 100644 homeassistant/components/airtouch4/__init__.py create mode 100644 homeassistant/components/airtouch4/climate.py create mode 100644 homeassistant/components/airtouch4/config_flow.py create mode 100644 homeassistant/components/airtouch4/const.py create mode 100644 homeassistant/components/airtouch4/manifest.json create mode 100644 homeassistant/components/airtouch4/strings.json create mode 100644 homeassistant/components/airtouch4/translations/en.json create mode 100644 tests/components/airtouch4/__init__.py create mode 100644 tests/components/airtouch4/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index e25f664efe8..fd4f87da858 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,6 +36,9 @@ omit = homeassistant/components/agent_dvr/helpers.py homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py + homeassistant/components/airtouch4/__init__.py + homeassistant/components/airtouch4/climate.py + homeassistant/components/airtouch4/const.py homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/sensor.py homeassistant/components/aladdin_connect/* diff --git a/CODEOWNERS b/CODEOWNERS index c6696c485fe..1dedb1d421b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -29,6 +29,7 @@ homeassistant/components/aemet/* @noltari homeassistant/components/agent_dvr/* @ispysoftware homeassistant/components/airly/* @bieniu homeassistant/components/airnow/* @asymworks +homeassistant/components/airtouch4/* @LonePurpleWolf homeassistant/components/airvisual/* @bachya homeassistant/components/alarmdecoder/* @ajschmidt8 homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py new file mode 100644 index 00000000000..0ec63161ea3 --- /dev/null +++ b/homeassistant/components/airtouch4/__init__.py @@ -0,0 +1,81 @@ +"""The AirTouch4 integration.""" +import logging + +from airtouch4pyapi import AirTouch +from airtouch4pyapi.airtouch import AirTouchStatus + +from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["climate"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up AirTouch4 from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + host = entry.data[CONF_HOST] + airtouch = AirTouch(host) + await airtouch.UpdateInfo() + info = airtouch.GetAcs() + if not info: + raise ConfigEntryNotReady + coordinator = AirtouchDataUpdateCoordinator(hass, airtouch) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Airtouch data.""" + + def __init__(self, hass, airtouch): + """Initialize global Airtouch data updater.""" + self.airtouch = airtouch + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from Airtouch.""" + await self.airtouch.UpdateInfo() + if self.airtouch.Status != AirTouchStatus.OK: + raise UpdateFailed("Airtouch connection issue") + return { + "acs": [ + {"ac_number": ac.AcNumber, "is_on": ac.IsOn} + for ac in self.airtouch.GetAcs() + ], + "groups": [ + { + "group_number": group.GroupNumber, + "group_name": group.GroupName, + "is_on": group.IsOn, + } + for group in self.airtouch.GetGroups() + ], + } diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py new file mode 100644 index 00000000000..7202feb0527 --- /dev/null +++ b/homeassistant/components/airtouch4/climate.py @@ -0,0 +1,335 @@ +"""AirTouch 4 component to control of AirTouch 4 Climate Devices.""" + +import logging + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE +AT_TO_HA_STATE = { + "Heat": HVAC_MODE_HEAT, + "Cool": HVAC_MODE_COOL, + "AutoHeat": HVAC_MODE_AUTO, # airtouch reports either autoheat or autocool + "AutoCool": HVAC_MODE_AUTO, + "Auto": HVAC_MODE_AUTO, + "Dry": HVAC_MODE_DRY, + "Fan": HVAC_MODE_FAN_ONLY, +} + +HA_STATE_TO_AT = { + HVAC_MODE_HEAT: "Heat", + HVAC_MODE_COOL: "Cool", + HVAC_MODE_AUTO: "Auto", + HVAC_MODE_DRY: "Dry", + HVAC_MODE_FAN_ONLY: "Fan", + HVAC_MODE_OFF: "Off", +} + +AT_TO_HA_FAN_SPEED = { + "Quiet": FAN_DIFFUSE, + "Low": FAN_LOW, + "Medium": FAN_MEDIUM, + "High": FAN_HIGH, + "Powerful": FAN_FOCUS, + "Auto": FAN_AUTO, + "Turbo": "turbo", +} + +AT_GROUP_MODES = [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY] + +HA_FAN_SPEED_TO_AT = {value: key for key, value in AT_TO_HA_FAN_SPEED.items()} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Airtouch 4.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + info = coordinator.data + entities = [ + AirtouchGroup(coordinator, group["group_number"], info) + for group in info["groups"] + ] + [AirtouchAC(coordinator, ac["ac_number"], info) for ac in info["acs"]] + + _LOGGER.debug(" Found entities %s", entities) + + async_add_entities(entities) + + +class AirtouchAC(CoordinatorEntity, ClimateEntity): + """Representation of an AirTouch 4 ac.""" + + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, coordinator, ac_number, info): + """Initialize the climate device.""" + super().__init__(coordinator) + self._ac_number = ac_number + self._airtouch = coordinator.airtouch + self._info = info + self._unit = self._airtouch.GetAcs()[self._ac_number] + + @callback + def _handle_coordinator_update(self): + self._unit = self._airtouch.GetAcs()[self._ac_number] + return super()._handle_coordinator_update() + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Airtouch", + "model": "Airtouch 4", + } + + @property + def unique_id(self): + """Return unique ID for this device.""" + return f"ac_{self._ac_number}" + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._unit.Temperature + + @property + def name(self): + """Return the name of the climate device.""" + return f"AC {self._ac_number}" + + @property + def fan_mode(self): + """Return fan mode of the AC this group belongs to.""" + return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._ac_number].AcFanSpeed] + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsForAc(self._ac_number) + return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds] + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + is_off = self._unit.PowerState == "Off" + if is_off: + return HVAC_MODE_OFF + + return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode] + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number) + modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes] + modes.append(HVAC_MODE_OFF) + return modes + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + if hvac_mode not in HA_STATE_TO_AT: + raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") + + if hvac_mode == HVAC_MODE_OFF: + return await self.async_turn_off() + await self._airtouch.SetCoolingModeForAc( + self._ac_number, HA_STATE_TO_AT[hvac_mode] + ) + # in case it isn't already, unless the HVAC mode was off, then the ac should be on + await self.async_turn_on() + self._unit = self._airtouch.GetAcs()[self._ac_number] + _LOGGER.debug("Setting operation mode of %s to %s", self._ac_number, hvac_mode) + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + if fan_mode not in self.fan_modes: + raise ValueError(f"Unsupported fan mode: {fan_mode}") + + _LOGGER.debug("Setting fan mode of %s to %s", self._ac_number, fan_mode) + await self._airtouch.SetFanSpeedForAc( + self._ac_number, HA_FAN_SPEED_TO_AT[fan_mode] + ) + self._unit = self._airtouch.GetAcs()[self._ac_number] + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self.unique_id) + # in case ac is not on. Airtouch turns itself off if no groups are turned on + # (even if groups turned back on) + await self._airtouch.TurnAcOn(self._ac_number) + + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self.unique_id) + await self._airtouch.TurnAcOff(self._ac_number) + self.async_write_ha_state() + + +class AirtouchGroup(CoordinatorEntity, ClimateEntity): + """Representation of an AirTouch 4 group.""" + + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_temperature_unit = TEMP_CELSIUS + _attr_hvac_modes = AT_GROUP_MODES + + def __init__(self, coordinator, group_number, info): + """Initialize the climate device.""" + super().__init__(coordinator) + self._group_number = group_number + self._airtouch = coordinator.airtouch + self._info = info + self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) + + @callback + def _handle_coordinator_update(self): + self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) + return super()._handle_coordinator_update() + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Airtouch", + "model": "Airtouch 4", + } + + @property + def unique_id(self): + """Return unique ID for this device.""" + return self._group_number + + @property + def min_temp(self): + """Return Minimum Temperature for AC of this group.""" + return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint + + @property + def max_temp(self): + """Return Max Temperature for AC of this group.""" + return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint + + @property + def name(self): + """Return the name of the climate device.""" + return self._unit.GroupName + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._unit.Temperature + + @property + def target_temperature(self): + """Return the temperature we are trying to reach.""" + return self._unit.TargetSetpoint + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + # there are other power states that aren't 'on' but still count as on (eg. 'Turbo') + is_off = self._unit.PowerState == "Off" + if is_off: + return HVAC_MODE_OFF + + return HVAC_MODE_FAN_ONLY + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + if hvac_mode not in HA_STATE_TO_AT: + raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") + + if hvac_mode == HVAC_MODE_OFF: + return await self.async_turn_off() + if self.hvac_mode == HVAC_MODE_OFF: + await self.async_turn_on() + self._unit = self._airtouch.GetGroups()[self._group_number] + _LOGGER.debug( + "Setting operation mode of %s to %s", self._group_number, hvac_mode + ) + self.async_write_ha_state() + + @property + def fan_mode(self): + """Return fan mode of the AC this group belongs to.""" + return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._unit.BelongsToAc].AcFanSpeed] + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsByGroup( + self._group_number + ) + return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds] + + async def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + temp = kwargs.get(ATTR_TEMPERATURE) + + _LOGGER.debug("Setting temp of %s to %s", self._group_number, str(temp)) + self._unit = await self._airtouch.SetGroupToTemperature( + self._group_number, int(temp) + ) + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + if fan_mode not in self.fan_modes: + raise ValueError(f"Unsupported fan mode: {fan_mode}") + + _LOGGER.debug("Setting fan mode of %s to %s", self._group_number, fan_mode) + self._unit = await self._airtouch.SetFanSpeedByGroup( + self._group_number, HA_FAN_SPEED_TO_AT[fan_mode] + ) + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self.unique_id) + await self._airtouch.TurnGroupOn(self._group_number) + + # in case ac is not on. Airtouch turns itself off if no groups are turned on + # (even if groups turned back on) + await self._airtouch.TurnAcOn( + self._airtouch.GetGroupByGroupNumber(self._group_number).BelongsToAc + ) + # this might cause the ac object to be wrong, so force the shared data + # store to update + await self.coordinator.async_request_refresh() + self.async_write_ha_state() + + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self.unique_id) + await self._airtouch.TurnGroupOff(self._group_number) + # this will cause the ac object to be wrong + # (ac turns off automatically if no groups are running) + # so force the shared data store to update + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/airtouch4/config_flow.py b/homeassistant/components/airtouch4/config_flow.py new file mode 100644 index 00000000000..e395c71349b --- /dev/null +++ b/homeassistant/components/airtouch4/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for AirTouch4.""" +from airtouch4pyapi import AirTouch, AirTouchStatus +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST + +from .const import DOMAIN + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +class AirtouchConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an Airtouch config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = {} + + host = user_input[CONF_HOST] + self._async_abort_entries_match({CONF_HOST: host}) + + airtouch = AirTouch(host) + await airtouch.UpdateInfo() + airtouch_status = airtouch.Status + airtouch_has_groups = bool( + airtouch.Status == AirTouchStatus.OK and airtouch.GetGroups() + ) + + if airtouch_status != AirTouchStatus.OK: + errors["base"] = "cannot_connect" + elif not airtouch_has_groups: + errors["base"] = "no_units" + + if errors: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + return self.async_create_entry( + title=user_input[CONF_HOST], + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) diff --git a/homeassistant/components/airtouch4/const.py b/homeassistant/components/airtouch4/const.py new file mode 100644 index 00000000000..e110a6cee81 --- /dev/null +++ b/homeassistant/components/airtouch4/const.py @@ -0,0 +1,3 @@ +"""Constants for the AirTouch4 integration.""" + +DOMAIN = "airtouch4" diff --git a/homeassistant/components/airtouch4/manifest.json b/homeassistant/components/airtouch4/manifest.json new file mode 100644 index 00000000000..8297081ae9d --- /dev/null +++ b/homeassistant/components/airtouch4/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "airtouch4", + "name": "AirTouch 4", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airtouch4", + "requirements": [ + "airtouch4pyapi==1.0.5" + ], + "codeowners": [ + "@LonePurpleWolf" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json new file mode 100644 index 00000000000..5259b20fb73 --- /dev/null +++ b/homeassistant/components/airtouch4/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_units": "Could not find any AirTouch 4 Groups." + }, + "step": { + "user": { + "title": "Setup your AirTouch 4 connection details.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + } + } +} diff --git a/homeassistant/components/airtouch4/translations/en.json b/homeassistant/components/airtouch4/translations/en.json new file mode 100644 index 00000000000..2bde2ea760a --- /dev/null +++ b/homeassistant/components/airtouch4/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + + "error": { + "cannot_connect": "Failed to connect", + "no_units": "Could not find any AirTouch 4 Groups." + }, + "step": { + "user": { + "title": "Setup your AirTouch 4.", + "data": { + "host": "Host" + } + } + } + } + } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b4a6fcc3775..6be4f70b38e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -16,6 +16,7 @@ FLOWS = [ "agent_dvr", "airly", "airnow", + "airtouch4", "airvisual", "alarmdecoder", "almond", diff --git a/requirements_all.txt b/requirements_all.txt index 1f0e4e1b665..5a535b86c13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,6 +257,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airtouch4 +airtouch4pyapi==1.0.5 + # homeassistant.components.aladdin_connect aladdin_connect==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ab3042137d..16591436f25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -178,6 +178,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airtouch4 +airtouch4pyapi==1.0.5 + # homeassistant.components.ambee ambee==0.3.0 diff --git a/tests/components/airtouch4/__init__.py b/tests/components/airtouch4/__init__.py new file mode 100644 index 00000000000..cc267ee41d1 --- /dev/null +++ b/tests/components/airtouch4/__init__.py @@ -0,0 +1 @@ +"""Tests for the AirTouch4 integration.""" diff --git a/tests/components/airtouch4/test_config_flow.py b/tests/components/airtouch4/test_config_flow.py new file mode 100644 index 00000000000..a98b24ef88d --- /dev/null +++ b/tests/components/airtouch4/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the AirTouch 4 config flow.""" +from unittest.mock import AsyncMock, Mock, patch + +from airtouch4pyapi.airtouch import AirTouch, AirTouchAc, AirTouchGroup, AirTouchStatus + +from homeassistant import config_entries +from homeassistant.components.airtouch4.const import DOMAIN + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + mock_ac = AirTouchAc() + mock_groups = AirTouchGroup() + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.Status = AirTouchStatus.OK + mock_airtouch.GetAcs = Mock(return_value=[mock_ac]) + mock_airtouch.GetGroups = Mock(return_value=[mock_groups]) + + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ), patch( + "homeassistant.components.airtouch4.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "0.0.0.1" + assert result2["data"] == { + "host": "0.0.0.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_timeout(hass): + """Test we handle a connection timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.status = AirTouchStatus.CONNECTION_INTERRUPTED + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_library_error_message(hass): + """Test we handle an unknown error message from the library.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.status = AirTouchStatus.ERROR + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_connection_refused(hass): + """Test we handle a connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.status = AirTouchStatus.NOT_CONNECTED + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_no_units(hass): + """Test we handle no units found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_ac = AirTouchAc() + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.Status = AirTouchStatus.OK + mock_airtouch.GetAcs = Mock(return_value=[mock_ac]) + mock_airtouch.GetGroups = Mock(return_value=[]) + + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "no_units"} From cff6883b5cb58353971453f4f37dbb718c1576b2 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 17 Aug 2021 12:22:27 -0400 Subject: [PATCH 453/903] Add zwave_js Protection CC select entities (#54717) * Add Protection CC select entities comment * Disable entity by default * use class attribute * Enable protection entity by default * add guard for none --- .../components/zwave_js/discovery.py | 10 ++ homeassistant/components/zwave_js/select.py | 36 +++++++ tests/components/zwave_js/test_select.py | 100 ++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index dcae65b0395..d59a3d935a0 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -646,6 +646,16 @@ DISCOVERY_SCHEMAS = [ ), required_values=[SIREN_TONE_SCHEMA], ), + # select + # protection CC + ZWaveDiscoverySchema( + platform="select", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.PROTECTION}, + property={"local", "rf"}, + type={"number"}, + ), + ), ] diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 2bd711bfde3..7aedc6521d9 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -29,6 +29,8 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "Default tone": entities.append(ZwaveDefaultToneSelectEntity(config_entry, client, info)) + else: + entities.append(ZwaveSelectEntity(config_entry, client, info)) async_add_entities(entities) config_entry.async_on_unload( @@ -40,6 +42,40 @@ async def async_setup_entry( ) +class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): + """Representation of a Z-Wave select entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveSelectEntity entity.""" + super().__init__(config_entry, client, info) + + # Entity class attributes + self._attr_name = self.generate_name(include_value_name=True) + self._attr_options = list(self.info.primary_value.metadata.states.values()) + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + if self.info.primary_value.value is None: + return None + return str( + self.info.primary_value.metadata.states.get( + str(self.info.primary_value.value), self.info.primary_value.value + ) + ) + + async def async_select_option(self, option: str | int) -> None: + """Change the selected option.""" + key = next( + key + for key, val in self.info.primary_value.metadata.states.items() + if val == option + ) + await self.info.node.async_set_value(self.info.primary_value, int(key)) + + class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): """Representation of a Z-Wave default tone select entity.""" diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index b94bac812b6..43f44f0bba0 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -1,7 +1,10 @@ """Test the Z-Wave JS number platform.""" from zwave_js_server.event import Event +from homeassistant.const import STATE_UNKNOWN + DEFAULT_TONE_SELECT_ENTITY = "select.indoor_siren_6_default_tone_2" +PROTECTION_SELECT_ENTITY = "select.family_room_combo_local_protection_state" async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration): @@ -99,3 +102,100 @@ async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration state = hass.states.get(DEFAULT_TONE_SELECT_ENTITY) assert state.state == "30DOOR~1 (27 sec)" + + +async def test_protection_select(hass, client, inovelli_lzw36, integration): + """Test the default tone select entity.""" + node = inovelli_lzw36 + state = hass.states.get(PROTECTION_SELECT_ENTITY) + + assert state + assert state.state == "Unprotected" + attr = state.attributes + assert attr["options"] == [ + "Unprotected", + "ProtectedBySequence", + "NoOperationPossible", + ] + + # Test select option with string value + await hass.services.async_call( + "select", + "select_option", + {"entity_id": PROTECTION_SELECT_ENTITY, "option": "ProtectedBySequence"}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "1": "ProtectedBySequence", + "2": "NoOperationPossible", + }, + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command.reset_mock() + + # Test value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "newValue": 1, + "prevValue": 0, + "propertyName": "local", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(PROTECTION_SELECT_ENTITY) + assert state.state == "ProtectedBySequence" + + # Test null value + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "newValue": None, + "prevValue": 1, + "propertyName": "local", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(PROTECTION_SELECT_ENTITY) + assert state.state == STATE_UNKNOWN From 15feb430fca10c71d45e1a0bae3c8e305466f924 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 17 Aug 2021 19:15:02 +0200 Subject: [PATCH 454/903] Use DEVICE_CLASS_UPDATE in Synology DSM (#54769) --- homeassistant/components/synology_dsm/const.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index fdbbb5678c2..633c264f3c8 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -10,7 +10,10 @@ from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation -from homeassistant.components.binary_sensor import DEVICE_CLASS_SAFETY +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_SAFETY, + DEVICE_CLASS_UPDATE, +) from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -81,8 +84,8 @@ UPGRADE_BINARY_SENSORS: dict[str, EntityInfo] = { f"{SynoCoreUpgrade.API_KEY}:update_available": { ATTR_NAME: "Update available", ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: "mdi:update", - ATTR_DEVICE_CLASS: None, + ATTR_ICON: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_UPDATE, ENTITY_ENABLE: True, ATTR_STATE_CLASS: None, }, From 5b75c8254b747e6583cef23da4b1f5343843c21f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 17 Aug 2021 19:49:38 +0200 Subject: [PATCH 455/903] Use path helper method for principal file in google_pubsub (#54744) Co-authored-by: Martin Hjelmare --- .../components/google_pubsub/__init__.py | 8 +----- tests/components/google_pubsub/test_init.py | 26 +++++++++---------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index 19530d9d663..d583bc5aac0 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -41,16 +41,10 @@ CONFIG_SCHEMA = vol.Schema( def setup(hass: HomeAssistant, yaml_config: dict[str, Any]): """Activate Google Pub/Sub component.""" - config = yaml_config[DOMAIN] project_id = config[CONF_PROJECT_ID] topic_name = config[CONF_TOPIC_NAME] - if hass.config.config_dir: - service_principal_path = os.path.join( - hass.config.config_dir, config[CONF_SERVICE_PRINCIPAL] - ) - else: - service_principal_path = config[CONF_SERVICE_PRINCIPAL] + service_principal_path = hass.config.path(config[CONF_SERVICE_PRINCIPAL]) if not os.path.isfile(service_principal_path): _LOGGER.error("Path to credentials file cannot be found") diff --git a/tests/components/google_pubsub/test_init.py b/tests/components/google_pubsub/test_init.py index fc1fecb04ed..d31d28e7302 100644 --- a/tests/components/google_pubsub/test_init.py +++ b/tests/components/google_pubsub/test_init.py @@ -1,6 +1,7 @@ """The tests for the Google Pub/Sub component.""" from dataclasses import dataclass from datetime import datetime +import os import unittest.mock as mock import pytest @@ -51,13 +52,12 @@ def mock_client_fixture(): yield client -@pytest.fixture(autouse=True, name="mock_os") -def mock_os_fixture(): - """Mock the OS cli.""" - with mock.patch(f"{GOOGLE_PUBSUB_PATH}.os") as os_cli: - os_cli.path = mock.MagicMock() - setattr(os_cli.path, "join", mock.MagicMock(return_value="path")) - yield os_cli +@pytest.fixture(autouse=True, name="mock_is_file") +def mock_is_file_fixture(): + """Mock os.path.isfile.""" + with mock.patch(f"{GOOGLE_PUBSUB_PATH}.os.path.isfile") as is_file: + is_file.return_value = True + yield is_file @pytest.fixture(autouse=True) @@ -84,9 +84,9 @@ async def test_minimal_config(hass, mock_client): assert hass.bus.listen.called assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED assert mock_client.PublisherClient.from_service_account_json.call_count == 1 - assert ( - mock_client.PublisherClient.from_service_account_json.call_args[0][0] == "path" - ) + assert mock_client.PublisherClient.from_service_account_json.call_args[0][ + 0 + ] == os.path.join(hass.config.config_dir, "creds") async def test_full_config(hass, mock_client): @@ -111,9 +111,9 @@ async def test_full_config(hass, mock_client): assert hass.bus.listen.called assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED assert mock_client.PublisherClient.from_service_account_json.call_count == 1 - assert ( - mock_client.PublisherClient.from_service_account_json.call_args[0][0] == "path" - ) + assert mock_client.PublisherClient.from_service_account_json.call_args[0][ + 0 + ] == os.path.join(hass.config.config_dir, "creds") def make_event(entity_id): From 8bf79d61ee686ff2d977b2ff80dd7a581c960790 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 17 Aug 2021 12:23:41 -0600 Subject: [PATCH 456/903] Add upnp binary sensor for connectivity status (#54489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * New binary sensor for connectivity * Add binary_sensor * New binary sensor for connectivity * Add binary_sensor * Handle values returned as None * Small text update for Uptime * Update homeassistant/components/upnp/binary_sensor.py Co-authored-by: Joakim Sørensen * Update homeassistant/components/upnp/binary_sensor.py Co-authored-by: Joakim Sørensen * Update homeassistant/components/upnp/binary_sensor.py Co-authored-by: Joakim Sørensen * Update homeassistant/components/upnp/binary_sensor.py Co-authored-by: Joakim Sørensen * Update homeassistant/components/upnp/binary_sensor.py Co-authored-by: Joakim Sørensen * Update homeassistant/components/upnp/binary_sensor.py Co-authored-by: Joakim Sørensen * Update homeassistant/components/upnp/binary_sensor.py Co-authored-by: Joakim Sørensen * Update homeassistant/components/upnp/binary_sensor.py Co-authored-by: Joakim Sørensen * Updates based on review * Update homeassistant/components/upnp/binary_sensor.py Co-authored-by: Joakim Sørensen * Further updates based on review * Set device_class as a class atribute * Create 1 combined data coordinator and UpnpEntity class * Updates on coordinator * Update comment * Fix in async_step_init for coordinator * Add async_get_status to mocked device and set times polled for each call seperately * Updated to get device through coordinator Check polling for each status call seperately * Use collections.abc instead of Typing for Mapping * Remove adding device to hass.data as coordinator is now saved * Removed setting _coordinator * Added myself as codeowner * Update type in __init__ * Removed attributes from binary sensor * Fix async_unload_entry * Add expected return value to is_on Co-authored-by: Joakim Sørensen --- CODEOWNERS | 2 +- homeassistant/components/upnp/__init__.py | 97 ++++++++++++--- .../components/upnp/binary_sensor.py | 54 +++++++++ homeassistant/components/upnp/config_flow.py | 30 +++-- homeassistant/components/upnp/const.py | 3 + homeassistant/components/upnp/device.py | 18 +++ homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/upnp/sensor.py | 111 +++++------------- tests/components/upnp/mock_upnp_device.py | 17 ++- tests/components/upnp/test_config_flow.py | 18 +-- 10 files changed, 222 insertions(+), 130 deletions(-) create mode 100644 homeassistant/components/upnp/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1dedb1d421b..85b89649a99 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -545,7 +545,7 @@ homeassistant/components/upb/* @gwww homeassistant/components/upc_connect/* @pvizeli @fabaff homeassistant/components/upcloud/* @scop homeassistant/components/updater/* @home-assistant/core -homeassistant/components/upnp/* @StevenLooman +homeassistant/components/upnp/* @StevenLooman @ehendrix23 homeassistant/components/uptimerobot/* @ludeeus homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 08e6a35f5b3..c21c1d24f0c 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +from datetime import timedelta from ipaddress import ip_address from typing import Any @@ -17,24 +18,30 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import ( CONF_LOCAL_IP, CONFIG_ENTRY_HOSTNAME, + CONFIG_ENTRY_SCAN_INTERVAL, CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, + DEFAULT_SCAN_INTERVAL, DOMAIN, DOMAIN_CONFIG, DOMAIN_DEVICES, DOMAIN_LOCAL_IP, - LOGGER as _LOGGER, + LOGGER, ) from .device import Device NOTIFICATION_ID = "upnp_notification" NOTIFICATION_TITLE = "UPnP/IGD Setup" -PLATFORMS = ["sensor"] +PLATFORMS = ["binary_sensor", "sensor"] CONFIG_SCHEMA = vol.Schema( { @@ -50,7 +57,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType): """Set up UPnP component.""" - _LOGGER.debug("async_setup, config: %s", config) + LOGGER.debug("async_setup, config: %s", config) conf_default = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] conf = config.get(DOMAIN, conf_default) local_ip = await async_get_source_ip(hass, PUBLIC_TARGET_IP) @@ -73,7 +80,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up UPnP/IGD device from a config entry.""" - _LOGGER.debug("Setting up config entry: %s", entry.unique_id) + LOGGER.debug("Setting up config entry: %s", entry.unique_id) udn = entry.data[CONFIG_ENTRY_UDN] st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name @@ -86,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def device_discovered(info: Mapping[str, Any]) -> None: nonlocal discovery_info - _LOGGER.debug( + LOGGER.debug( "Device discovered: %s, at: %s", usn, info[ssdp.ATTR_SSDP_LOCATION] ) discovery_info = info @@ -103,7 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await asyncio.wait_for(device_discovered_event.wait(), timeout=10) except asyncio.TimeoutError as err: - _LOGGER.debug("Device not discovered: %s", usn) + LOGGER.debug("Device not discovered: %s", usn) raise ConfigEntryNotReady from err finally: cancel_discovered_callback() @@ -114,12 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] device = await Device.async_create_device(hass, location) - # Save device. - hass.data[DOMAIN][DOMAIN_DEVICES][udn] = device - # Ensure entry has a unique_id. if not entry.unique_id: - _LOGGER.debug( + LOGGER.debug( "Setting unique_id: %s, for config_entry: %s", device.unique_id, entry, @@ -150,8 +154,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=device.model_name, ) + update_interval_sec = entry.options.get( + CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + update_interval = timedelta(seconds=update_interval_sec) + LOGGER.debug("update_interval: %s", update_interval) + coordinator = UpnpDataUpdateCoordinator( + hass, + device=device, + update_interval=update_interval, + ) + + # Save coordinator. + hass.data[DOMAIN][entry.entry_id] = coordinator + + await coordinator.async_config_entry_first_refresh() + # Create sensors. - _LOGGER.debug("Enabling sensors") + LOGGER.debug("Enabling sensors") hass.config_entries.async_setup_platforms(entry, PLATFORMS) # Start device updater. @@ -162,14 +182,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a UPnP/IGD device from a config entry.""" - _LOGGER.debug("Unloading config entry: %s", config_entry.unique_id) + LOGGER.debug("Unloading config entry: %s", config_entry.unique_id) - udn = config_entry.data.get(CONFIG_ENTRY_UDN) - if udn in hass.data[DOMAIN][DOMAIN_DEVICES]: - device = hass.data[DOMAIN][DOMAIN_DEVICES][udn] - await device.async_stop() + if coordinator := hass.data[DOMAIN].pop(config_entry.entry_id, None): + await coordinator.device.async_stop() - del hass.data[DOMAIN][DOMAIN_DEVICES][udn] - - _LOGGER.debug("Deleting sensors") + LOGGER.debug("Deleting sensors") return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +class UpnpDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to update data from UPNP device.""" + + def __init__( + self, hass: HomeAssistant, device: Device, update_interval: timedelta + ) -> None: + """Initialize.""" + self.device = device + + super().__init__( + hass, LOGGER, name=device.name, update_interval=update_interval + ) + + async def _async_update_data(self) -> Mapping[str, Any]: + """Update data.""" + update_values = await asyncio.gather( + self.device.async_get_traffic_data(), + self.device.async_get_status(), + ) + + data = dict(update_values[0]) + data.update(update_values[1]) + + return data + + +class UpnpEntity(CoordinatorEntity): + """Base class for UPnP/IGD entities.""" + + coordinator: UpnpDataUpdateCoordinator + + def __init__(self, coordinator: UpnpDataUpdateCoordinator) -> None: + """Initialize the base entities.""" + super().__init__(coordinator) + self._device = coordinator.device + self._attr_device_info = { + "connections": {(dr.CONNECTION_UPNP, coordinator.device.udn)}, + "name": coordinator.device.name, + "manufacturer": coordinator.device.manufacturer, + "model": coordinator.device.model_name, + } diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py new file mode 100644 index 00000000000..2f2f0af0e96 --- /dev/null +++ b/homeassistant/components/upnp/binary_sensor.py @@ -0,0 +1,54 @@ +"""Support for UPnP/IGD Binary Sensors.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import UpnpDataUpdateCoordinator, UpnpEntity +from .const import DOMAIN, LOGGER, WANSTATUS + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the UPnP/IGD sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + LOGGER.debug("Adding binary sensor") + + sensors = [ + UpnpStatusBinarySensor(coordinator), + ] + async_add_entities(sensors) + + +class UpnpStatusBinarySensor(UpnpEntity, BinarySensorEntity): + """Class for UPnP/IGD binary sensors.""" + + _attr_device_class = DEVICE_CLASS_CONNECTIVITY + + def __init__( + self, + coordinator: UpnpDataUpdateCoordinator, + ) -> None: + """Initialize the base sensor.""" + super().__init__(coordinator) + self._attr_name = f"{coordinator.device.name} wan status" + self._attr_unique_id = f"{coordinator.device.udn}_wanstatus" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.get(WANSTATUS) + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.coordinator.data[WANSTATUS] == "Connected" diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 89e1e5c71d0..5df4e267427 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -20,8 +20,7 @@ from .const import ( CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, DOMAIN, - DOMAIN_DEVICES, - LOGGER as _LOGGER, + LOGGER, SSDP_SEARCH_TIMEOUT, ST_IGD_V1, ST_IGD_V2, @@ -43,7 +42,7 @@ async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: @callback def device_discovered(info: Mapping[str, Any]) -> None: - _LOGGER.info( + LOGGER.info( "Device discovered: %s, at: %s", info[ssdp.ATTR_SSDP_USN], info[ssdp.ATTR_SSDP_LOCATION], @@ -103,7 +102,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: Mapping | None = None ) -> Mapping[str, Any]: """Handle a flow start.""" - _LOGGER.debug("async_step_user: user_input: %s", user_input) + LOGGER.debug("async_step_user: user_input: %s", user_input) if user_input is not None: # Ensure wanted device was discovered. @@ -162,12 +161,12 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): configured before, find any device and create a config_entry for it. Otherwise, do nothing. """ - _LOGGER.debug("async_step_import: import_info: %s", import_info) + LOGGER.debug("async_step_import: import_info: %s", import_info) # Landed here via configuration.yaml entry. # Any device already added, then abort. if self._async_current_entries(): - _LOGGER.debug("Already configured, aborting") + LOGGER.debug("Already configured, aborting") return self.async_abort(reason="already_configured") # Discover devices. @@ -176,7 +175,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Ensure anything to add. If not, silently abort. if not discoveries: - _LOGGER.info("No UPnP devices discovered, aborting") + LOGGER.info("No UPnP devices discovered, aborting") return self.async_abort(reason="no_devices_found") # Ensure complete discovery. @@ -187,7 +186,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): or ssdp.ATTR_SSDP_LOCATION not in discovery or ssdp.ATTR_SSDP_USN not in discovery ): - _LOGGER.debug("Incomplete discovery, ignoring") + LOGGER.debug("Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") # Ensure not already configuring/configured. @@ -202,7 +201,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): This flow is triggered by the SSDP component. It will check if the host is already configured and delegate to the import step if not. """ - _LOGGER.debug("async_step_ssdp: discovery_info: %s", discovery_info) + LOGGER.debug("async_step_ssdp: discovery_info: %s", discovery_info) # Ensure complete discovery. if ( @@ -211,7 +210,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): or ssdp.ATTR_SSDP_LOCATION not in discovery_info or ssdp.ATTR_SSDP_USN not in discovery_info ): - _LOGGER.debug("Incomplete discovery, ignoring") + LOGGER.debug("Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") # Ensure not already configuring/configured. @@ -225,7 +224,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): for config_entry in existing_entries: entry_hostname = config_entry.data.get(CONFIG_ENTRY_HOSTNAME) if entry_hostname == hostname: - _LOGGER.debug( + LOGGER.debug( "Found existing config_entry with same hostname, discovery ignored" ) return self.async_abort(reason="discovery_ignored") @@ -244,7 +243,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: Mapping | None = None ) -> Mapping[str, Any]: """Confirm integration via SSDP.""" - _LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input) + LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input) if user_input is None: return self.async_show_form(step_id="ssdp_confirm") @@ -264,7 +263,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): discovery: Mapping, ) -> Mapping[str, Any]: """Create an entry from discovery.""" - _LOGGER.debug( + LOGGER.debug( "_async_create_entry_from_discovery: discovery: %s", discovery, ) @@ -288,13 +287,12 @@ class UpnpOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_init(self, user_input: Mapping = None) -> None: """Manage the options.""" if user_input is not None: - udn = self.config_entry.data[CONFIG_ENTRY_UDN] - coordinator = self.hass.data[DOMAIN][DOMAIN_DEVICES][udn].coordinator + coordinator = self.hass.data[DOMAIN][self.config_entry.entry_id] update_interval_sec = user_input.get( CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ) update_interval = timedelta(seconds=update_interval_sec) - _LOGGER.debug("Updating coordinator, update_interval: %s", update_interval) + LOGGER.debug("Updating coordinator, update_interval: %s", update_interval) coordinator.update_interval = update_interval return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index cbb071bc15e..769e398c5a4 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -18,6 +18,9 @@ PACKETS_SENT = "packets_sent" TIMESTAMP = "timestamp" DATA_PACKETS = "packets" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}" +WANSTATUS = "wan_status" +WANIP = "wan_ip" +UPTIME = "uptime" KIBIBYTE = 1024 UPDATE_INTERVAL = timedelta(seconds=30) CONFIG_ENTRY_SCAN_INTERVAL = "scan_interval" diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 5e6f8ef5023..ca06f501405 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -27,6 +27,9 @@ from .const import ( PACKETS_RECEIVED, PACKETS_SENT, TIMESTAMP, + UPTIME, + WANIP, + WANSTATUS, ) @@ -154,3 +157,18 @@ class Device: PACKETS_RECEIVED: values[2], PACKETS_SENT: values[3], } + + async def async_get_status(self) -> Mapping[str, Any]: + """Get connection status, uptime, and external IP.""" + _LOGGER.debug("Getting status for device: %s", self) + + values = await asyncio.gather( + self._igd_device.async_get_status_info(), + self._igd_device.async_get_external_ip_address(), + ) + + return { + WANSTATUS: values[0][0] if values[0] is not None else None, + UPTIME: values[0][2] if values[0] is not None else None, + WANIP: values[1], + } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 937518c34ac..fc8ba185d3c 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/upnp", "requirements": ["async-upnp-client==0.19.2"], "dependencies": ["network", "ssdp"], - "codeowners": ["@StevenLooman"], + "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 82df1f59469..185d3ecac6d 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,38 +1,25 @@ """Support for UPnP/IGD Sensors.""" from __future__ import annotations -from datetime import timedelta -from typing import Any, Mapping - from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from . import UpnpDataUpdateCoordinator, UpnpEntity from .const import ( BYTES_RECEIVED, BYTES_SENT, - CONFIG_ENTRY_SCAN_INTERVAL, - CONFIG_ENTRY_UDN, DATA_PACKETS, DATA_RATE_PACKETS_PER_SECOND, - DEFAULT_SCAN_INTERVAL, DOMAIN, - DOMAIN_DEVICES, KIBIBYTE, - LOGGER as _LOGGER, + LOGGER, PACKETS_RECEIVED, PACKETS_SENT, TIMESTAMP, ) -from .device import Device SENSOR_TYPES = { BYTES_RECEIVED: { @@ -78,7 +65,7 @@ async def async_setup_platform( hass: HomeAssistant, config, async_add_entities, discovery_info=None ) -> None: """Old way of setting up UPnP/IGD sensors.""" - _LOGGER.debug( + LOGGER.debug( "async_setup_platform: config: %s, discovery: %s", config, discovery_info ) @@ -89,52 +76,36 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" - udn = config_entry.data[CONFIG_ENTRY_UDN] - device: Device = hass.data[DOMAIN][DOMAIN_DEVICES][udn] + coordinator = hass.data[DOMAIN][config_entry.entry_id] - update_interval_sec = config_entry.options.get( - CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - update_interval = timedelta(seconds=update_interval_sec) - _LOGGER.debug("update_interval: %s", update_interval) - _LOGGER.debug("Adding sensors") - coordinator = DataUpdateCoordinator[Mapping[str, Any]]( - hass, - _LOGGER, - name=device.name, - update_method=device.async_get_traffic_data, - update_interval=update_interval, - ) - device.coordinator = coordinator - - await coordinator.async_refresh() + LOGGER.debug("Adding sensors") sensors = [ - RawUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_RECEIVED]), - RawUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_SENT]), - RawUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_RECEIVED]), - RawUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_SENT]), - DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_RECEIVED]), - DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_SENT]), - DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_RECEIVED]), - DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_SENT]), + RawUpnpSensor(coordinator, SENSOR_TYPES[BYTES_RECEIVED]), + RawUpnpSensor(coordinator, SENSOR_TYPES[BYTES_SENT]), + RawUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_RECEIVED]), + RawUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_SENT]), + DerivedUpnpSensor(coordinator, SENSOR_TYPES[BYTES_RECEIVED]), + DerivedUpnpSensor(coordinator, SENSOR_TYPES[BYTES_SENT]), + DerivedUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_RECEIVED]), + DerivedUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_SENT]), ] - async_add_entities(sensors, True) + async_add_entities(sensors) -class UpnpSensor(CoordinatorEntity, SensorEntity): +class UpnpSensor(UpnpEntity, SensorEntity): """Base class for UPnP/IGD sensors.""" def __init__( self, - coordinator: DataUpdateCoordinator[Mapping[str, Any]], - device: Device, - sensor_type: Mapping[str, str], + coordinator: UpnpDataUpdateCoordinator, + sensor_type: dict[str, str], ) -> None: """Initialize the base sensor.""" super().__init__(coordinator) - self._device = device self._sensor_type = sensor_type + self._attr_name = f"{coordinator.device.name} {sensor_type['name']}" + self._attr_unique_id = f"{coordinator.device.udn}_{sensor_type['unique_id']}" @property def icon(self) -> str: @@ -144,37 +115,15 @@ class UpnpSensor(CoordinatorEntity, SensorEntity): @property def available(self) -> bool: """Return if entity is available.""" - device_value_key = self._sensor_type["device_value_key"] - return ( - self.coordinator.last_update_success - and device_value_key in self.coordinator.data + return super().available and self.coordinator.data.get( + self._sensor_type["device_value_key"] ) - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.name} {self._sensor_type['name']}" - - @property - def unique_id(self) -> str: - """Return an unique ID.""" - return f"{self._device.udn}_{self._sensor_type['unique_id']}" - @property def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return self._sensor_type["unit"] - @property - def device_info(self) -> DeviceInfo: - """Get device info.""" - return { - "connections": {(dr.CONNECTION_UPNP, self._device.udn)}, - "name": self._device.name, - "manufacturer": self._device.manufacturer, - "model": self._device.model_name, - } - class RawUpnpSensor(UpnpSensor): """Representation of a UPnP/IGD sensor.""" @@ -192,21 +141,15 @@ class RawUpnpSensor(UpnpSensor): class DerivedUpnpSensor(UpnpSensor): """Representation of a UNIT Sent/Received per second sensor.""" - def __init__(self, coordinator, device, sensor_type) -> None: + def __init__(self, coordinator: UpnpDataUpdateCoordinator, sensor_type) -> None: """Initialize sensor.""" - super().__init__(coordinator, device, sensor_type) + super().__init__(coordinator, sensor_type) self._last_value = None self._last_timestamp = None - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.name} {self._sensor_type['derived_name']}" - - @property - def unique_id(self) -> str: - """Return an unique ID.""" - return f"{self._device.udn}_{self._sensor_type['derived_unique_id']}" + self._attr_name = f"{coordinator.device.name} {sensor_type['derived_name']}" + self._attr_unique_id = ( + f"{coordinator.device.udn}_{sensor_type['derived_unique_id']}" + ) @property def native_unit_of_measurement(self) -> str: diff --git a/tests/components/upnp/mock_upnp_device.py b/tests/components/upnp/mock_upnp_device.py index 78adbc5e220..42c9291f30f 100644 --- a/tests/components/upnp/mock_upnp_device.py +++ b/tests/components/upnp/mock_upnp_device.py @@ -11,6 +11,9 @@ from homeassistant.components.upnp.const import ( PACKETS_RECEIVED, PACKETS_SENT, TIMESTAMP, + UPTIME, + WANIP, + WANSTATUS, ) from homeassistant.components.upnp.device import Device from homeassistant.util import dt @@ -27,7 +30,8 @@ class MockDevice(Device): mock_device_updater = AsyncMock() super().__init__(igd_device, mock_device_updater) self._udn = udn - self.times_polled = 0 + self.traffic_times_polled = 0 + self.status_times_polled = 0 @classmethod async def async_create_device(cls, hass, ssdp_location) -> "MockDevice": @@ -66,7 +70,7 @@ class MockDevice(Device): async def async_get_traffic_data(self) -> Mapping[str, Any]: """Get traffic data.""" - self.times_polled += 1 + self.traffic_times_polled += 1 return { TIMESTAMP: dt.utcnow(), BYTES_RECEIVED: 0, @@ -75,6 +79,15 @@ class MockDevice(Device): PACKETS_SENT: 0, } + async def async_get_status(self) -> Mapping[str, Any]: + """Get connection status, uptime, and external IP.""" + self.status_times_polled += 1 + return { + WANSTATUS: "Connected", + UPTIME: 0, + WANIP: "192.168.0.1", + } + async def async_start(self) -> None: """Start the device updater.""" diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 646bdb143e9..907fa709c84 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -14,7 +14,6 @@ from homeassistant.components.upnp.const import ( CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, DOMAIN, - DOMAIN_DEVICES, ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component @@ -238,15 +237,17 @@ async def test_options_flow(hass: HomeAssistant): config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) is True await hass.async_block_till_done() - mock_device = hass.data[DOMAIN][DOMAIN_DEVICES][TEST_UDN] + mock_device = hass.data[DOMAIN][config_entry.entry_id].device # Reset. - mock_device.times_polled = 0 + mock_device.traffic_times_polled = 0 + mock_device.status_times_polled = 0 # Forward time, ensure single poll after 30 (default) seconds. async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31)) await hass.async_block_till_done() - assert mock_device.times_polled == 1 + assert mock_device.traffic_times_polled == 1 + assert mock_device.status_times_polled == 1 # Options flow with no input results in form. result = await hass.config_entries.options.async_init( @@ -267,15 +268,18 @@ async def test_options_flow(hass: HomeAssistant): # Forward time, ensure single poll after 60 seconds, still from original setting. async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=61)) await hass.async_block_till_done() - assert mock_device.times_polled == 2 + assert mock_device.traffic_times_polled == 2 + assert mock_device.status_times_polled == 2 # Now the updated interval takes effect. # Forward time, ensure single poll after 120 seconds. async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=121)) await hass.async_block_till_done() - assert mock_device.times_polled == 3 + assert mock_device.traffic_times_polled == 3 + assert mock_device.status_times_polled == 3 # Forward time, ensure single poll after 180 seconds. async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=181)) await hass.async_block_till_done() - assert mock_device.times_polled == 4 + assert mock_device.traffic_times_polled == 4 + assert mock_device.status_times_polled == 4 From 71b0f6d095603e2c574a46763765f079d367dc2e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 17 Aug 2021 20:43:27 +0200 Subject: [PATCH 457/903] set common test entity name. (#54697) --- tests/components/modbus/conftest.py | 23 ++- tests/components/modbus/test_binary_sensor.py | 17 +- tests/components/modbus/test_climate.py | 26 ++- tests/components/modbus/test_cover.py | 25 ++- tests/components/modbus/test_fan.py | 44 +++--- tests/components/modbus/test_init.py | 148 +++++++++--------- tests/components/modbus/test_light.py | 42 ++--- tests/components/modbus/test_sensor.py | 47 +++--- tests/components/modbus/test_switch.py | 52 +++--- 9 files changed, 226 insertions(+), 198 deletions(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index a33d0932c1d..4f2c9b2b778 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -7,7 +7,11 @@ from unittest import mock from pymodbus.exceptions import ModbusException import pytest -from homeassistant.components.modbus.const import DEFAULT_HUB, MODBUS_DOMAIN as DOMAIN +from homeassistant.components.modbus.const import ( + CONF_TCP, + DEFAULT_HUB, + MODBUS_DOMAIN as DOMAIN, +) from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -22,6 +26,11 @@ import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, mock_restore_cache TEST_MODBUS_NAME = "modbusTest" +TEST_ENTITY_NAME = "test_entity" +TEST_MODBUS_HOST = "modbusHost" +TEST_PORT_TCP = 5501 +TEST_PORT_SERIAL = "usb01" + _LOGGER = logging.getLogger(__name__) @@ -62,9 +71,9 @@ async def mock_modbus(hass, caplog, request, do_config): config = { DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: "modbusTestHost", - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, **do_config, } @@ -122,9 +131,9 @@ async def base_test( config_modbus = { DOMAIN: { CONF_NAME: DEFAULT_HUB, - CONF_TYPE: "tcp", - CONF_HOST: "modbusTest", - CONF_PORT: 5001, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, }, } diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index dc9a547dc18..fb52ea11090 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -20,10 +20,9 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test +from .conftest import TEST_ENTITY_NAME, ReadResult, base_test -SENSOR_NAME = "test_binary_sensor" -ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" +ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -32,7 +31,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_BINARY_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, } ] @@ -40,7 +39,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_BINARY_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SLAVE: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, @@ -89,8 +88,8 @@ async def test_all_binary_sensor(hass, do_type, regs, expected): """Run test for given config.""" state = await base_test( hass, - {CONF_NAME: SENSOR_NAME, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: do_type}, - SENSOR_NAME, + {CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: do_type}, + TEST_ENTITY_NAME, SENSOR_DOMAIN, CONF_BINARY_SENSORS, None, @@ -108,7 +107,7 @@ async def test_all_binary_sensor(hass, do_type, regs, expected): { CONF_BINARY_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: CALL_TYPE_COIL, } @@ -144,7 +143,7 @@ async def test_service_binary_sensor_update(hass, mock_modbus, mock_ha): { CONF_BINARY_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, } diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 71dbb6aa8a7..16ef18a60ac 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -22,10 +22,9 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test +from .conftest import TEST_ENTITY_NAME, ReadResult, base_test -CLIMATE_NAME = "test_climate" -ENTITY_ID = f"{CLIMATE_DOMAIN}.{CLIMATE_NAME}" +ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -34,7 +33,7 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{CLIMATE_NAME}" { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, @@ -44,7 +43,7 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{CLIMATE_NAME}" { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, @@ -71,18 +70,17 @@ async def test_config_climate(hass, mock_modbus): ) async def test_temperature_climate(hass, regs, expected): """Run test for given config.""" - CLIMATE_NAME = "modbus_test_climate" return state = await base_test( hass, { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_SLAVE: 1, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_COUNT: 2, }, - CLIMATE_NAME, + TEST_ENTITY_NAME, CLIMATE_DOMAIN, CONF_CLIMATES, None, @@ -100,7 +98,7 @@ async def test_temperature_climate(hass, regs, expected): { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, @@ -127,7 +125,7 @@ async def test_service_climate_update(hass, mock_modbus, mock_ha): { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, @@ -142,7 +140,7 @@ async def test_service_climate_update(hass, mock_modbus, mock_ha): { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, @@ -157,7 +155,7 @@ async def test_service_climate_update(hass, mock_modbus, mock_ha): { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, @@ -172,7 +170,7 @@ async def test_service_climate_update(hass, mock_modbus, mock_ha): { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, @@ -214,7 +212,7 @@ test_value.attributes = {ATTR_TEMPERATURE: 37} { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SCAN_INTERVAL: 0, diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index b1add3e3745..266193294c6 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -29,10 +29,9 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test +from .conftest import TEST_ENTITY_NAME, ReadResult, base_test -COVER_NAME = "test_cover" -ENTITY_ID = f"{COVER_DOMAIN}.{COVER_NAME}" +ENTITY_ID = f"{COVER_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -41,7 +40,7 @@ ENTITY_ID = f"{COVER_DOMAIN}.{COVER_NAME}" { CONF_COVERS: [ { - CONF_NAME: COVER_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: CALL_TYPE_COIL, } @@ -50,7 +49,7 @@ ENTITY_ID = f"{COVER_DOMAIN}.{COVER_NAME}" { CONF_COVERS: [ { - CONF_NAME: COVER_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SLAVE: 10, @@ -95,12 +94,12 @@ async def test_coil_cover(hass, regs, expected): state = await base_test( hass, { - CONF_NAME: COVER_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_INPUT_TYPE: CALL_TYPE_COIL, CONF_ADDRESS: 1234, CONF_SLAVE: 1, }, - COVER_NAME, + TEST_ENTITY_NAME, COVER_DOMAIN, CONF_COVERS, None, @@ -142,11 +141,11 @@ async def test_register_cover(hass, regs, expected): state = await base_test( hass, { - CONF_NAME: COVER_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, }, - COVER_NAME, + TEST_ENTITY_NAME, COVER_DOMAIN, CONF_COVERS, None, @@ -164,7 +163,7 @@ async def test_register_cover(hass, regs, expected): { CONF_COVERS: [ { - CONF_NAME: COVER_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, } @@ -201,7 +200,7 @@ async def test_service_cover_update(hass, mock_modbus, mock_ha): { CONF_COVERS: [ { - CONF_NAME: COVER_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_INPUT_TYPE: CALL_TYPE_COIL, CONF_ADDRESS: 1234, CONF_STATE_OPEN: 1, @@ -228,13 +227,13 @@ async def test_restore_state_cover(hass, mock_test_state, mock_modbus): { CONF_COVERS: [ { - CONF_NAME: COVER_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, }, { - CONF_NAME: f"{COVER_NAME}2", + CONF_NAME: f"{TEST_ENTITY_NAME}2", CONF_INPUT_TYPE: CALL_TYPE_COIL, CONF_ADDRESS: 1234, CONF_SCAN_INTERVAL: 0, diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index e0d23ad48db..4aa55473737 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -12,6 +12,7 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_STATE_OFF, CONF_STATE_ON, + CONF_TCP, CONF_VERIFY, CONF_WRITE_TYPE, MODBUS_DOMAIN, @@ -33,10 +34,15 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.setup import async_setup_component -from .conftest import ReadResult, base_test +from .conftest import ( + TEST_ENTITY_NAME, + TEST_MODBUS_HOST, + TEST_PORT_TCP, + ReadResult, + base_test, +) -FAN_NAME = "test_fan" -ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" +ENTITY_ID = f"{FAN_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -45,7 +51,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, } ] @@ -53,7 +59,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, } @@ -62,7 +68,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -79,7 +85,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -96,7 +102,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -113,7 +119,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -165,13 +171,13 @@ async def test_all_fan(hass, call_type, regs, verify, expected): state = await base_test( hass, { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_WRITE_TYPE: call_type, **verify, }, - FAN_NAME, + TEST_ENTITY_NAME, FAN_DOMAIN, CONF_FANS, None, @@ -194,7 +200,7 @@ async def test_all_fan(hass, call_type, regs, verify, expected): { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SCAN_INTERVAL: 0, } @@ -210,21 +216,21 @@ async def test_restore_state_fan(hass, mock_test_state, mock_modbus): async def test_fan_service_turn(hass, caplog, mock_pymodbus): """Run test for service turn_on/turn_off.""" - ENTITY_ID2 = f"{FAN_DOMAIN}.{FAN_NAME}2" + ENTITY_ID2 = f"{FAN_DOMAIN}.{TEST_ENTITY_NAME}2" config = { MODBUS_DOMAIN: { - CONF_TYPE: "tcp", - CONF_HOST: "modbusTestHost", - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, }, { - CONF_NAME: f"{FAN_NAME}2", + CONF_NAME: f"{TEST_ENTITY_NAME}2", CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, @@ -283,7 +289,7 @@ async def test_fan_service_turn(hass, caplog, mock_pymodbus): { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, CONF_VERIFY: {}, diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index b9f6420604f..9400dd56641 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -42,10 +42,14 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, + CONF_RTUOVERTCP, + CONF_SERIAL, CONF_STOPBITS, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_WORD, + CONF_TCP, + CONF_UDP, DATA_TYPE_CUSTOM, DATA_TYPE_INT, DATA_TYPE_STRING, @@ -79,15 +83,17 @@ from homeassistant.const import ( from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import ReadResult +from .conftest import ( + TEST_ENTITY_NAME, + TEST_MODBUS_HOST, + TEST_MODBUS_NAME, + TEST_PORT_SERIAL, + TEST_PORT_TCP, + ReadResult, +) from tests.common import async_fire_time_changed -TEST_SENSOR_NAME = "testSensor" -TEST_ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_SENSOR_NAME}" -TEST_HOST = "modbusTestHost" -TEST_MODBUS_NAME = "modbusTest" - @pytest.fixture async def mock_modbus_with_pymodbus(hass, caplog, do_config, mock_pymodbus): @@ -128,17 +134,17 @@ async def test_number_validator(): "do_config", [ { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 2, CONF_DATA_TYPE: DATA_TYPE_STRING, }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 2, CONF_DATA_TYPE: DATA_TYPE_INT, }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 2, CONF_DATA_TYPE: DATA_TYPE_INT, CONF_SWAP: CONF_SWAP_BYTE, @@ -157,29 +163,29 @@ async def test_ok_struct_validator(do_config): "do_config", [ { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 8, CONF_DATA_TYPE: DATA_TYPE_INT, }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 8, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 8, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_STRUCTURE: "no good", }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 20, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_STRUCTURE: ">f", }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 1, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_STRUCTURE: ">f", @@ -200,60 +206,60 @@ async def test_exception_struct_validator(do_config): "do_config", [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, }, { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, CONF_TIMEOUT: 30, CONF_DELAY: 10, }, { - CONF_TYPE: "udp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_UDP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, }, { - CONF_TYPE: "udp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_UDP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, CONF_TIMEOUT: 30, CONF_DELAY: 10, }, { - CONF_TYPE: "rtuovertcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_RTUOVERTCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, }, { - CONF_TYPE: "rtuovertcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_RTUOVERTCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, CONF_TIMEOUT: 30, CONF_DELAY: 10, }, { - CONF_TYPE: "serial", + CONF_TYPE: CONF_SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", - CONF_PORT: "usb01", + CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, CONF_MSG_WAIT: 100, }, { - CONF_TYPE: "serial", + CONF_TYPE: CONF_SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", - CONF_PORT: "usb01", + CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, CONF_NAME: TEST_MODBUS_NAME, @@ -261,43 +267,43 @@ async def test_exception_struct_validator(do_config): CONF_DELAY: 10, }, { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_DELAY: 5, }, [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, }, { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, - CONF_NAME: TEST_MODBUS_NAME + "2", + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_NAME: f"{TEST_MODBUS_NAME}2", }, { - CONF_TYPE: "serial", + CONF_TYPE: CONF_SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", - CONF_PORT: "usb01", + CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, - CONF_NAME: TEST_MODBUS_NAME + "3", + CONF_NAME: f"{TEST_MODBUS_NAME}3", }, ], { # Special test for scan_interval validator with scan_interval: 0 - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_SENSORS: [ { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 117, CONF_SCAN_INTERVAL: 0, } @@ -320,11 +326,11 @@ SERVICE = "service" [ { CONF_NAME: TEST_MODBUS_NAME, - CONF_TYPE: "serial", + CONF_TYPE: CONF_SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", - CONF_PORT: "usb01", + CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, }, @@ -425,14 +431,14 @@ async def mock_modbus_read_pymodbus( config = { DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, do_group: [ { CONF_INPUT_TYPE: do_type, - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: do_scan_interval, } @@ -482,7 +488,7 @@ async def test_pb_read( """Run test for different read.""" # Check state - entity_id = f"{do_domain}.{TEST_SENSOR_NAME}" + entity_id = f"{do_domain}.{TEST_ENTITY_NAME}" state = hass.states.get(entity_id).state assert hass.states.get(entity_id).state @@ -499,9 +505,9 @@ async def test_pymodbus_constructor_fail(hass, caplog): config = { DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, } ] } @@ -522,9 +528,9 @@ async def test_pymodbus_close_fail(hass, caplog, mock_pymodbus): config = { DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, } ] } @@ -543,19 +549,19 @@ async def test_delay(hass, mock_pymodbus): # We "hijiack" a binary_sensor to make a proper blackbox test. test_delay = 15 test_scan_interval = 5 - entity_id = f"{BINARY_SENSOR_DOMAIN}.{TEST_SENSOR_NAME}" + entity_id = f"{BINARY_SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" config = { DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, CONF_DELAY: test_delay, CONF_BINARY_SENSORS: [ { CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_NAME: f"{TEST_SENSOR_NAME}", + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 52, CONF_SCAN_INTERVAL: test_scan_interval, }, diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 3b3966cdf8a..49bfed3e19a 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -11,6 +11,7 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_STATE_OFF, CONF_STATE_ON, + CONF_TCP, CONF_VERIFY, CONF_WRITE_TYPE, MODBUS_DOMAIN, @@ -33,10 +34,15 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.setup import async_setup_component -from .conftest import ReadResult, base_test +from .conftest import ( + TEST_ENTITY_NAME, + TEST_MODBUS_HOST, + TEST_PORT_TCP, + ReadResult, + base_test, +) -LIGHT_NAME = "test_light" -ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" +ENTITY_ID = f"{LIGHT_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -45,7 +51,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, } ] @@ -53,7 +59,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, } @@ -62,7 +68,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -79,7 +85,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -96,7 +102,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -113,7 +119,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -165,13 +171,13 @@ async def test_all_light(hass, call_type, regs, verify, expected): state = await base_test( hass, { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_WRITE_TYPE: call_type, **verify, }, - LIGHT_NAME, + TEST_ENTITY_NAME, LIGHT_DOMAIN, CONF_LIGHTS, None, @@ -194,7 +200,7 @@ async def test_all_light(hass, call_type, regs, verify, expected): { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SCAN_INTERVAL: 0, } @@ -213,18 +219,18 @@ async def test_light_service_turn(hass, caplog, mock_pymodbus): ENTITY_ID2 = f"{ENTITY_ID}2" config = { MODBUS_DOMAIN: { - CONF_TYPE: "tcp", - CONF_HOST: "modbusTestHost", - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, }, { - CONF_NAME: f"{LIGHT_NAME}2", + CONF_NAME: f"{TEST_ENTITY_NAME}2", CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, @@ -283,7 +289,7 @@ async def test_light_service_turn(hass, caplog, mock_pymodbus): { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, CONF_VERIFY: {}, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index ef784c9edb6..e69a6be41a4 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -35,10 +35,9 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test +from .conftest import TEST_ENTITY_NAME, ReadResult, base_test -SENSOR_NAME = "test_sensor" -ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" +ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -47,7 +46,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, } ] @@ -55,7 +54,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SLAVE: 10, CONF_COUNT: 1, @@ -71,7 +70,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SLAVE: 10, CONF_COUNT: 1, @@ -87,7 +86,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_COUNT: 1, CONF_SWAP: CONF_SWAP_NONE, @@ -97,7 +96,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_COUNT: 1, CONF_SWAP: CONF_SWAP_BYTE, @@ -107,7 +106,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_WORD, @@ -117,7 +116,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_WORD_BYTE, @@ -139,7 +138,7 @@ async def test_config_sensor(hass, mock_modbus): { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_COUNT: 8, CONF_PRECISION: 2, @@ -154,7 +153,7 @@ async def test_config_sensor(hass, mock_modbus): { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_COUNT: 2, CONF_PRECISION: 2, @@ -169,7 +168,7 @@ async def test_config_sensor(hass, mock_modbus): { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_COUNT: 4, @@ -184,7 +183,7 @@ async def test_config_sensor(hass, mock_modbus): { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_COUNT: 4, @@ -193,13 +192,13 @@ async def test_config_sensor(hass, mock_modbus): }, ] }, - "Error in sensor test_sensor. The `structure` field can not be empty", + f"Error in sensor {TEST_ENTITY_NAME}. The `structure` field can not be empty", ), ( { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_COUNT: 4, @@ -214,7 +213,7 @@ async def test_config_sensor(hass, mock_modbus): { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_COUNT: 1, @@ -223,7 +222,7 @@ async def test_config_sensor(hass, mock_modbus): }, ] }, - "Error in sensor test_sensor swap(word) not possible due to the registers count: 1, needed: 2", + f"Error in sensor {TEST_ENTITY_NAME} swap(word) not possible due to the registers count: 1, needed: 2", ), ], ) @@ -508,8 +507,8 @@ async def test_all_sensor(hass, cfg, regs, expected): state = await base_test( hass, - {CONF_NAME: SENSOR_NAME, CONF_ADDRESS: 1234, **cfg}, - SENSOR_NAME, + {CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, **cfg}, + TEST_ENTITY_NAME, SENSOR_DOMAIN, CONF_SENSORS, CONF_REGISTERS, @@ -562,8 +561,8 @@ async def test_struct_sensor(hass, cfg, regs, expected): state = await base_test( hass, - {CONF_NAME: SENSOR_NAME, CONF_ADDRESS: 1234, **cfg}, - SENSOR_NAME, + {CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, **cfg}, + TEST_ENTITY_NAME, SENSOR_DOMAIN, CONF_SENSORS, None, @@ -586,7 +585,7 @@ async def test_struct_sensor(hass, cfg, regs, expected): { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, } @@ -605,7 +604,7 @@ async def test_restore_state_sensor(hass, mock_test_state, mock_modbus): { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, } diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 48c8ca9e15f..3838e7a95d5 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -13,6 +13,7 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_STATE_OFF, CONF_STATE_ON, + CONF_TCP, CONF_VERIFY, CONF_WRITE_TYPE, MODBUS_DOMAIN, @@ -39,12 +40,17 @@ from homeassistant.core import State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import ReadResult, base_test +from .conftest import ( + TEST_ENTITY_NAME, + TEST_MODBUS_HOST, + TEST_PORT_TCP, + ReadResult, + base_test, +) from tests.common import async_fire_time_changed -SWITCH_NAME = "test_switch" -ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" +ENTITY_ID = f"{SWITCH_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -53,7 +59,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, } ] @@ -61,7 +67,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, } @@ -70,7 +76,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -88,7 +94,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -107,7 +113,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -125,7 +131,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -179,13 +185,13 @@ async def test_all_switch(hass, call_type, regs, verify, expected): state = await base_test( hass, { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_WRITE_TYPE: call_type, **verify, }, - SWITCH_NAME, + TEST_ENTITY_NAME, SWITCH_DOMAIN, CONF_SWITCHES, None, @@ -208,7 +214,7 @@ async def test_all_switch(hass, call_type, regs, verify, expected): { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SCAN_INTERVAL: 0, } @@ -224,21 +230,21 @@ async def test_restore_state_switch(hass, mock_test_state, mock_modbus): async def test_switch_service_turn(hass, caplog, mock_pymodbus): """Run test for service turn_on/turn_off.""" - ENTITY_ID2 = f"{SWITCH_DOMAIN}.{SWITCH_NAME}2" + ENTITY_ID2 = f"{SWITCH_DOMAIN}.{TEST_ENTITY_NAME}2" config = { MODBUS_DOMAIN: { - CONF_TYPE: "tcp", - CONF_HOST: "modbusTestHost", - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, }, { - CONF_NAME: f"{SWITCH_NAME}2", + CONF_NAME: f"{TEST_ENTITY_NAME}2", CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, @@ -297,7 +303,7 @@ async def test_switch_service_turn(hass, caplog, mock_pymodbus): { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, CONF_VERIFY: {}, @@ -324,12 +330,12 @@ async def test_delay_switch(hass, mock_pymodbus): config = { MODBUS_DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: "modbusTestHost", - CONF_PORT: 5501, + CONF_TYPE: CONF_TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, CONF_VERIFY: { From 909af30c7c5ddc6ada0be2aefb6f942dee25f2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 17 Aug 2021 22:04:05 +0200 Subject: [PATCH 458/903] Tractive, update library (#54775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/tractive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index 2328c07f905..73ee75a4ac5 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tractive", "requirements": [ - "aiotractive==0.5.1" + "aiotractive==0.5.2" ], "codeowners": [ "@Danielhiversen", diff --git a/requirements_all.txt b/requirements_all.txt index 5a535b86c13..a764e7721bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -246,7 +246,7 @@ aioswitcher==2.0.4 aiosyncthing==0.5.1 # homeassistant.components.tractive -aiotractive==0.5.1 +aiotractive==0.5.2 # homeassistant.components.unifi aiounifi==26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16591436f25..5ba9adafa97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioswitcher==2.0.4 aiosyncthing==0.5.1 # homeassistant.components.tractive -aiotractive==0.5.1 +aiotractive==0.5.2 # homeassistant.components.unifi aiounifi==26 From 4ef04898e92fb78ca8b2f1b4b4fdce43f9b3d031 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 17 Aug 2021 16:38:20 -0400 Subject: [PATCH 459/903] Fix goalzero sensor not using SensorEntity class (#54773) --- homeassistant/components/goalzero/sensor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 31eadd55969..dbb85aa2d48 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -1,7 +1,11 @@ """Support for Goal Zero Yeti Sensors.""" from __future__ import annotations -from homeassistant.components.sensor import ATTR_LAST_RESET, ATTR_STATE_CLASS +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + SensorEntity, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_NAME, @@ -36,7 +40,7 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(sensors, True) -class YetiSensor(YetiEntity): +class YetiSensor(YetiEntity, SensorEntity): """Representation of a Goal Zero Yeti sensor.""" def __init__(self, api, coordinator, name, sensor_name, server_unique_id): From 8eec9498358719718d1ef520a021d8ab30f985f8 Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 17 Aug 2021 22:45:14 +0200 Subject: [PATCH 460/903] Fix connectivity issue in the Youless integration (#54764) --- homeassistant/components/youless/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index d00f0457b85..1ea7bc67ba9 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -3,7 +3,7 @@ "name": "YouLess", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/youless", - "requirements": ["youless-api==0.10"], + "requirements": ["youless-api==0.12"], "codeowners": ["@gjong"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index a764e7721bd..efb249aac12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2434,7 +2434,7 @@ yeelight==0.7.2 yeelightsunflower==0.0.10 # homeassistant.components.youless -youless-api==0.10 +youless-api==0.12 # homeassistant.components.media_extractor youtube_dl==2021.04.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ba9adafa97..7cf5441182e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1351,7 +1351,7 @@ yalexs==1.1.13 yeelight==0.7.2 # homeassistant.components.youless -youless-api==0.10 +youless-api==0.12 # homeassistant.components.onvif zeep[async]==4.0.0 From 3a78f1fce6cbb6b41c465536a4991aa6feff7d02 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Aug 2021 23:05:31 +0200 Subject: [PATCH 461/903] Force STATE_CLASS_TOTAL_INCREASING to reset to 0 (#54751) * Force STATE_CLASS_TOTAL_INCREASING to reset to 0 * Tweak * Correct detection of new cycle * Fix typing --- homeassistant/components/sensor/recorder.py | 8 ++++++-- tests/components/sensor/test_recorder.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 66366934d27..48f80bab5c2 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -308,7 +308,7 @@ def compile_statistics( elif old_state is None and last_reset is None: reset = True elif state_class == STATE_CLASS_TOTAL_INCREASING and ( - old_state is None or fstate < old_state + old_state is None or (new_state is not None and fstate < new_state) ): reset = True @@ -319,7 +319,11 @@ def compile_statistics( # ..and update the starting point new_state = fstate old_last_reset = last_reset - old_state = new_state + # Force a new cycle for STATE_CLASS_TOTAL_INCREASING to start at 0 + if state_class == STATE_CLASS_TOTAL_INCREASING and old_state: + old_state = 0 + else: + old_state = new_state else: new_state = fstate diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 45d81e4b678..d4dee872823 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -383,7 +383,7 @@ def test_compile_hourly_sum_statistics_total_increasing( "min": None, "last_reset": None, "state": approx(factor * seq[5]), - "sum": approx(factor * 40.0), + "sum": approx(factor * 50.0), }, { "statistic_id": "sensor.test1", @@ -393,7 +393,7 @@ def test_compile_hourly_sum_statistics_total_increasing( "min": None, "last_reset": None, "state": approx(factor * seq[8]), - "sum": approx(factor * 70.0), + "sum": approx(factor * 80.0), }, ] } From 6da83b90f7a993f584faeb1af8fc7164320bbea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 18 Aug 2021 00:46:48 +0200 Subject: [PATCH 462/903] Rfxtrx,STATE_CLASS_TOTAL_INCREASING (#54776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/rfxtrx/sensor.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 49a8bbb974c..7ce986d7082 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -38,7 +39,6 @@ from homeassistant.const import ( UV_INDEX, ) from homeassistant.core import callback -from homeassistant.util import dt from . import ( CONF_DATA_BITS, @@ -145,8 +145,7 @@ SENSOR_TYPES = ( RfxtrxSensorEntityDescription( key="Total usage", device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), RfxtrxSensorEntityDescription( @@ -173,14 +172,12 @@ SENSOR_TYPES = ( ), RfxtrxSensorEntityDescription( key="Count", - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Counter value", - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( From 67e9035e4ea06dc86abbffb17e1035504c4e2648 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 17 Aug 2021 16:56:33 -0600 Subject: [PATCH 463/903] Improve myq error handling for opening/closing cover (#54724) --- homeassistant/components/myq/cover.py | 51 ++++++++++----------------- homeassistant/components/myq/light.py | 2 +- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index 8d36db8e0ab..87b8223c477 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -18,6 +18,7 @@ from homeassistant.components.cover import ( CoverEntity, ) from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS @@ -43,14 +44,11 @@ class MyQDevice(CoordinatorEntity, CoverEntity): """Initialize with API object, device id.""" super().__init__(coordinator) self._device = device - - @property - def device_class(self): - """Define this cover as a garage door.""" - device_type = self._device.device_type - if device_type is not None and device_type == MYQ_DEVICE_TYPE_GATE: - return DEVICE_CLASS_GATE - return DEVICE_CLASS_GARAGE + if device.device_type == MYQ_DEVICE_TYPE_GATE: + self._attr_device_class = DEVICE_CLASS_GATE + else: + self._attr_device_class = DEVICE_CLASS_GARAGE + self._attr_unique_id = device.device_id @property def name(self): @@ -60,11 +58,8 @@ class MyQDevice(CoordinatorEntity, CoverEntity): @property def available(self): """Return if the device is online.""" - if not self.coordinator.last_update_success: - return False - # Not all devices report online so assume True if its missing - return self._device.device_json[MYQ_DEVICE_STATE].get( + return super().available and self._device.device_json[MYQ_DEVICE_STATE].get( MYQ_DEVICE_STATE_ONLINE, True ) @@ -93,11 +88,6 @@ class MyQDevice(CoordinatorEntity, CoverEntity): """Flag supported features.""" return SUPPORT_OPEN | SUPPORT_CLOSE - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self._device.device_id - async def async_close_cover(self, **kwargs): """Issue close command to cover.""" if self.is_closing or self.is_closed: @@ -106,23 +96,21 @@ class MyQDevice(CoordinatorEntity, CoverEntity): try: wait_task = await self._device.close(wait_for_state=False) except MyQError as err: - _LOGGER.error( - "Closing of cover %s failed with error: %s", self._device.name, str(err) - ) - - return + raise HomeAssistantError( + f"Closing of cover {self._device.name} failed with error: {err}" + ) from err # Write closing state to HASS self.async_write_ha_state() result = wait_task if isinstance(wait_task, bool) else await wait_task - if not result: - _LOGGER.error("Closing of cover %s failed", self._device.name) - # Write final state to HASS self.async_write_ha_state() + if not result: + raise HomeAssistantError(f"Closing of cover {self._device.name} failed") + async def async_open_cover(self, **kwargs): """Issue open command to cover.""" if self.is_opening or self.is_open: @@ -131,22 +119,21 @@ class MyQDevice(CoordinatorEntity, CoverEntity): try: wait_task = await self._device.open(wait_for_state=False) except MyQError as err: - _LOGGER.error( - "Opening of cover %s failed with error: %s", self._device.name, str(err) - ) - return + raise HomeAssistantError( + f"Opening of cover {self._device.name} failed with error: {err}" + ) from err # Write opening state to HASS self.async_write_ha_state() result = wait_task if isinstance(wait_task, bool) else await wait_task - if not result: - _LOGGER.error("Opening of cover %s failed", self._device.name) - # Write final state to HASS self.async_write_ha_state() + if not result: + raise HomeAssistantError(f"Opening of cover {self._device.name} failed") + @property def device_info(self): """Return the device_info of the device.""" diff --git a/homeassistant/components/myq/light.py b/homeassistant/components/myq/light.py index f26d28fe3a3..98119c2157a 100644 --- a/homeassistant/components/myq/light.py +++ b/homeassistant/components/myq/light.py @@ -90,7 +90,7 @@ class MyQLight(CoordinatorEntity, LightEntity): f"Turning light {self._device.name} off failed with error: {err}" ) from err - # Write opening state to HASS + # Write new state to HASS self.async_write_ha_state() @property From 3bc45eacfc14e52815190ef9cc7e2a3f8d160e29 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 18 Aug 2021 01:29:40 +0200 Subject: [PATCH 464/903] Fix tplink doing I/O in event loop and optimize (#54570) * Optimize tplink i/o * Cache has_emeter reduceing the number of i/o requests on hs300 by 5 * Use the state from the sysinfo dict for non-strips reducing required requests by one * Remove I/O from __init__, read has_emeter from sysinfo * Cleanup __init__ to avoid I/O * Re-use the sysinfo response for has_emeter * Use async_add_executor_job() to execute the synchronous I/O ops. * use the device alias instead of host for coordinator, use executor for unavailable_devices * Remove unnecessary self.hass assignment Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/tplink/__init__.py | 26 ++++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 5c69247eea8..aad934b2600 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -154,7 +154,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for device in unavailable_devices: try: - device.get_sysinfo() + await hass.async_add_executor_job(device.get_sysinfo) except SmartDeviceException: continue _LOGGER.debug( @@ -168,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for switch in switches: try: - await hass.async_add_executor_job(switch.get_sysinfo) + info = await hass.async_add_executor_job(switch.get_sysinfo) except SmartDeviceException: _LOGGER.warning( "Device at '%s' not reachable during setup, will retry later", @@ -179,7 +179,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass_data[COORDINATORS][ switch.context or switch.mac - ] = coordinator = SmartPlugDataUpdateCoordinator(hass, switch) + ] = coordinator = SmartPlugDataUpdateCoordinator(hass, switch, info["alias"]) await coordinator.async_config_entry_first_refresh() if unavailable_devices: @@ -215,16 +215,20 @@ class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): self, hass: HomeAssistant, smartplug: SmartPlug, + alias: str, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.smartplug = smartplug update_interval = timedelta(seconds=30) super().__init__( - hass, _LOGGER, name=smartplug.alias, update_interval=update_interval + hass, + _LOGGER, + name=alias, + update_interval=update_interval, ) - async def _async_update_data(self) -> dict: + def _update_data(self) -> dict: """Fetch all device and sensor data from api.""" try: info = self.smartplug.sys_info @@ -237,9 +241,7 @@ class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): if self.smartplug.context is None: data[CONF_ALIAS] = info["alias"] data[CONF_DEVICE_ID] = info["mac"] - data[CONF_STATE] = ( - self.smartplug.state == self.smartplug.SWITCH_STATE_ON - ) + data[CONF_STATE] = bool(info["relay_state"]) else: plug_from_context = next( c @@ -249,7 +251,9 @@ class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): data[CONF_ALIAS] = plug_from_context["alias"] data[CONF_DEVICE_ID] = self.smartplug.context data[CONF_STATE] = plug_from_context["state"] == 1 - if self.smartplug.has_emeter: + + # Check if the device has emeter + if "ENE" in info["feature"]: emeter_readings = self.smartplug.get_emeter_realtime() data[CONF_EMETER_PARAMS] = { ATTR_CURRENT_POWER_W: round(float(emeter_readings["power"]), 2), @@ -270,3 +274,7 @@ class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): self.name = data[CONF_ALIAS] return data + + async def _async_update_data(self) -> dict: + """Fetch all device and sensor data from api.""" + return await self.hass.async_add_executor_job(self._update_data) From b981e69f951afaae4b45543b0fc78866aedef6ca Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 18 Aug 2021 02:00:10 +0200 Subject: [PATCH 465/903] Update SolarEdge to use new state classes (#54731) --- homeassistant/components/solaredge/const.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 06d8813130e..c9c7136fb94 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -2,7 +2,10 @@ from datetime import timedelta import logging -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -10,7 +13,6 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, ) -from homeassistant.util import dt as dt_util from .models import SolarEdgeSensorEntityDescription @@ -40,8 +42,7 @@ SENSOR_TYPES = [ json_key="lifeTimeData", name="Lifetime energy", icon="mdi:solar-power", - last_reset=dt_util.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), From 0100ffcb8c5b91bc1f8a5eb7a3366bcec9fcc415 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 18 Aug 2021 00:13:44 +0000 Subject: [PATCH 466/903] [ci skip] Translation update --- .../components/airtouch4/translations/ca.json | 19 +++++++++++++ .../components/airtouch4/translations/en.json | 28 ++++++++++--------- .../components/airtouch4/translations/et.json | 19 +++++++++++++ .../components/airtouch4/translations/ru.json | 19 +++++++++++++ .../binary_sensor/translations/ca.json | 8 ++++++ .../binary_sensor/translations/cs.json | 8 ++++++ .../binary_sensor/translations/de.json | 8 ++++++ .../binary_sensor/translations/et.json | 8 ++++++ .../binary_sensor/translations/fr.json | 8 ++++++ .../binary_sensor/translations/hu.json | 8 ++++++ .../binary_sensor/translations/no.json | 8 ++++++ .../binary_sensor/translations/ru.json | 8 ++++++ .../binary_sensor/translations/zh-Hant.json | 8 ++++++ .../components/ifttt/translations/de.json | 2 +- .../components/mutesync/translations/de.json | 2 +- .../components/risco/translations/de.json | 2 +- .../components/sensor/translations/ca.json | 16 +++++++++++ .../components/sensor/translations/de.json | 16 +++++++++++ .../components/sensor/translations/et.json | 16 +++++++++++ .../components/sensor/translations/hu.json | 16 +++++++++++ .../components/sensor/translations/no.json | 16 +++++++++++ .../components/sensor/translations/ru.json | 16 +++++++++++ .../sensor/translations/zh-Hant.json | 16 +++++++++++ .../components/tractive/translations/ca.json | 4 ++- .../components/tractive/translations/cs.json | 3 +- .../components/tractive/translations/de.json | 1 + .../components/tractive/translations/et.json | 4 ++- .../components/tractive/translations/no.json | 4 ++- .../components/tractive/translations/ru.json | 4 ++- .../xiaomi_aqara/translations/de.json | 2 +- .../xiaomi_miio/translations/ca.json | 2 +- .../xiaomi_miio/translations/de.json | 2 +- .../xiaomi_miio/translations/et.json | 2 +- 33 files changed, 278 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/airtouch4/translations/ca.json create mode 100644 homeassistant/components/airtouch4/translations/et.json create mode 100644 homeassistant/components/airtouch4/translations/ru.json diff --git a/homeassistant/components/airtouch4/translations/ca.json b/homeassistant/components/airtouch4/translations/ca.json new file mode 100644 index 00000000000..083c4a0ba87 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "no_units": "No s'han trobat grups AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "title": "Configura els detalls de connexi\u00f3 d'AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/en.json b/homeassistant/components/airtouch4/translations/en.json index 2bde2ea760a..0f86b787249 100644 --- a/homeassistant/components/airtouch4/translations/en.json +++ b/homeassistant/components/airtouch4/translations/en.json @@ -1,17 +1,19 @@ { "config": { - - "error": { - "cannot_connect": "Failed to connect", - "no_units": "Could not find any AirTouch 4 Groups." - }, - "step": { - "user": { - "title": "Setup your AirTouch 4.", - "data": { - "host": "Host" - } + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "no_units": "Could not find any AirTouch 4 Groups." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Setup your AirTouch 4 connection details." + } } - } } - } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/et.json b/homeassistant/components/airtouch4/translations/et.json new file mode 100644 index 00000000000..2b42935b18e --- /dev/null +++ b/homeassistant/components/airtouch4/translations/et.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "no_units": "Ei leidnud \u00fchtegi AirTouch 4 gruppi." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "AirTouch 4 \u00fchenduse \u00fcksikasjade seadistamine." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/ru.json b/homeassistant/components/airtouch4/translations/ru.json new file mode 100644 index 00000000000..cbb7b10de79 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "no_units": "\u0413\u0440\u0443\u043f\u043f\u044b AirTouch 4 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "title": "AirTouch 4" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/ca.json b/homeassistant/components/binary_sensor/translations/ca.json index 9c92a50246a..089f72f51d5 100644 --- a/homeassistant/components/binary_sensor/translations/ca.json +++ b/homeassistant/components/binary_sensor/translations/ca.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} no est\u00e0 detectant cap problema", "is_no_smoke": "{entity_name} no detecta fum", "is_no_sound": "{entity_name} no detecta so", + "is_no_update": "{entity_name} est\u00e0 actualitzat/da", "is_no_vibration": "{entity_name} no detecta vibraci\u00f3", "is_not_bat_low": "Bateria de {entity_name} normal", "is_not_cold": "{entity_name} no est\u00e0 fred", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} est\u00e0 detectant fum", "is_sound": "{entity_name} est\u00e0 detectant so", "is_unsafe": "{entity_name} \u00e9s insegur", + "is_update": "{entity_name} t\u00e9 una actualitzaci\u00f3 disponible", "is_vibration": "{entity_name} est\u00e0 detectant vibraci\u00f3" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} ha deixat de detectar un problema", "no_smoke": "{entity_name} ha deixat de detectar fum", "no_sound": "{entity_name} ha deixat de detectar so", + "no_update": "{entity_name} s'ha actualitzat", "no_vibration": "{entity_name} ha deixat de detectar vibraci\u00f3", "not_bat_low": "Bateria de {entity_name} normal", "not_cold": "{entity_name} es torna no-fred", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} apagat", "turned_on": "{entity_name} enc\u00e8s", "unsafe": "{entity_name} es torna insegur", + "update": "{entity_name} obt\u00e9 una nova actualitzaci\u00f3 disponible", "vibration": "{entity_name} ha comen\u00e7at a detectar vibraci\u00f3" } }, @@ -178,6 +182,10 @@ "off": "Lliure", "on": "Detectat" }, + "update": { + "off": "Actualitzat/da", + "on": "Actualitzaci\u00f3 disponible" + }, "vibration": { "off": "Lliure", "on": "Detectat" diff --git a/homeassistant/components/binary_sensor/translations/cs.json b/homeassistant/components/binary_sensor/translations/cs.json index 90f25332bdb..25b82e54de7 100644 --- a/homeassistant/components/binary_sensor/translations/cs.json +++ b/homeassistant/components/binary_sensor/translations/cs.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} nehl\u00e1s\u00ed probl\u00e9m", "is_no_smoke": "{entity_name} nedetekuje kou\u0159", "is_no_sound": "{entity_name} nedetekuje zvuk", + "is_no_update": "{entity_name} je aktu\u00e1ln\u00ed", "is_no_vibration": "{entity_name} nedetekuje vibrace", "is_not_bat_low": "{entity_name} baterie v norm\u00e1lu", "is_not_cold": "{entity_name} nen\u00ed studen\u00fd", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} detekuje kou\u0159", "is_sound": "{entity_name} detekuje zvuk", "is_unsafe": "{entity_name} nen\u00ed bezpe\u010dno", + "is_update": "{entity_name} m\u00e1 k dispozici aktualizaci", "is_vibration": "{entity_name} detekuje vibrace" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} p\u0159estalo detekovat probl\u00e9m", "no_smoke": "{entity_name} p\u0159estalo detekovat kou\u0159", "no_sound": "{entity_name} p\u0159estalo detekovat zvuk", + "no_update": "{entity_name} se stalo aktu\u00e1ln\u00ed", "no_vibration": "{entity_name} p\u0159estalo detekovat vibrace", "not_bat_low": "{entity_name} baterie v norm\u00e1lu", "not_cold": "{entity_name} p\u0159estal b\u00fdt studen\u00fd", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} vypnuto", "turned_on": "{entity_name} zapnuto", "unsafe": "{entity_name} hl\u00e1s\u00ed ohro\u017een\u00ed", + "update": "{entity_name} m\u00e1 k dispozici aktualizaci", "vibration": "{entity_name} za\u010dalo detekovat vibrace" } }, @@ -178,6 +182,10 @@ "off": "Ticho", "on": "Zachycen zvuk" }, + "update": { + "off": "Aktu\u00e1ln\u00ed", + "on": "Aktualizace k dispozici" + }, "vibration": { "off": "Klid", "on": "Zji\u0161t\u011bny vibrace" diff --git a/homeassistant/components/binary_sensor/translations/de.json b/homeassistant/components/binary_sensor/translations/de.json index a2ef817bedb..21d1eff1ebf 100644 --- a/homeassistant/components/binary_sensor/translations/de.json +++ b/homeassistant/components/binary_sensor/translations/de.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} erkennt kein Problem", "is_no_smoke": "{entity_name} erkennt keinen Rauch", "is_no_sound": "{entity_name} erkennt keine Ger\u00e4usche", + "is_no_update": "{entity_name} ist aktuell", "is_no_vibration": "{entity_name} erkennt keine Vibrationen", "is_not_bat_low": "{entity_name} Batterie ist normal", "is_not_cold": "{entity_name} ist nicht kalt", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} hat Rauch detektiert", "is_sound": "{entity_name} hat Ger\u00e4usche detektiert", "is_unsafe": "{entity_name} ist unsicher", + "is_update": "{entity_name} hat ein Update verf\u00fcgbar", "is_vibration": "{entity_name} erkennt Vibrationen." }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} hat kein Problem mehr erkannt", "no_smoke": "{entity_name} hat keinen Rauch mehr erkannt", "no_sound": "{entity_name} hat keine Ger\u00e4usche mehr erkannt", + "no_update": "{entity_name} wurde auf den neuesten Stand gebracht", "no_vibration": "{entity_name}hat keine Vibrationen mehr erkannt", "not_bat_low": "{entity_name} Batterie normal", "not_cold": "{entity_name} w\u00e4rmte auf", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} ausgeschaltet", "turned_on": "{entity_name} eingeschaltet", "unsafe": "{entity_name} ist unsicher", + "update": "{entity_name} hat ein Update verf\u00fcgbar", "vibration": "{entity_name} detektiert Vibrationen" } }, @@ -178,6 +182,10 @@ "off": "Normal", "on": "Erkannt" }, + "update": { + "off": "Aktuell", + "on": "Update verf\u00fcgbar" + }, "vibration": { "off": "Normal", "on": "Erkannt" diff --git a/homeassistant/components/binary_sensor/translations/et.json b/homeassistant/components/binary_sensor/translations/et.json index 99fbec0b89e..2a0172300c9 100644 --- a/homeassistant/components/binary_sensor/translations/et.json +++ b/homeassistant/components/binary_sensor/translations/et.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} ei leia probleemi", "is_no_smoke": "{entity_name} ei tuvasta suitsu", "is_no_sound": "{entity_name} ei tuvasta heli", + "is_no_update": "{entity_name} on ajakohane", "is_no_vibration": "{entity_name} ei tuvasta vibratsiooni", "is_not_bat_low": "{entity_name} aku on laetud", "is_not_cold": "{entity_name} ei ole k\u00fclm", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} tuvastab suitsu", "is_sound": "{entity_name} tuvastab heli", "is_unsafe": "{entity_name} on ebaturvaline", + "is_update": "{entity_name} on saadaval uuendus", "is_vibration": "{entity_name} tuvastab vibratsiooni" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} l\u00f5petas probleemi tuvastamise", "no_smoke": "{entity_name} l\u00f5petas suitsu tuvastamise", "no_sound": "{entity_name} l\u00f5petas heli tuvastamise", + "no_update": "{entity_name} on uuendatud", "no_vibration": "{entity_name} l\u00f5petas vibratsiooni tuvastamise", "not_bat_low": "{entity_name} aku on laetud", "not_cold": "{entity_name} ei ole enam k\u00fclm", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} l\u00fclitus v\u00e4lja", "turned_on": "{entity_name} l\u00fclitus sisse", "unsafe": "{entity_name} on ebaturvaline", + "update": "{entity_name} sai saadavaloleva uuenduse", "vibration": "{entity_name} registreeris vibratsiooni" } }, @@ -178,6 +182,10 @@ "off": "Puudub", "on": "Tuvastatud" }, + "update": { + "off": "Ajakohane", + "on": "Saadaval on uuendus" + }, "vibration": { "off": "Puudub", "on": "Tuvastatud" diff --git a/homeassistant/components/binary_sensor/translations/fr.json b/homeassistant/components/binary_sensor/translations/fr.json index ede13a68dc9..aa0686c0375 100644 --- a/homeassistant/components/binary_sensor/translations/fr.json +++ b/homeassistant/components/binary_sensor/translations/fr.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} ne d\u00e9tecte pas de probl\u00e8me", "is_no_smoke": "{entity_name} ne d\u00e9tecte pas de fum\u00e9e", "is_no_sound": "{entity_name} ne d\u00e9tecte pas de son", + "is_no_update": "{entity_name} est \u00e0 jour", "is_no_vibration": "{entity_name} ne d\u00e9tecte pas de vibration", "is_not_bat_low": "{entity_name} batterie normale", "is_not_cold": "{entity_name} n'est pas froid", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} d\u00e9tecte de la fum\u00e9e", "is_sound": "{entity_name} d\u00e9tecte du son", "is_unsafe": "{entity_name} est dangereux", + "is_update": "{entity_name} a une mise \u00e0 jour disponible", "is_vibration": "{entity_name} d\u00e9tecte des vibrations" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} a cess\u00e9 de d\u00e9tecter un probl\u00e8me", "no_smoke": "{entity_name} a cess\u00e9 de d\u00e9tecter de la fum\u00e9e", "no_sound": "{entity_name} a cess\u00e9 de d\u00e9tecter du bruit", + "no_update": "{entity_name} a \u00e9t\u00e9 mis \u00e0 jour", "no_vibration": "{entity_name} a cess\u00e9 de d\u00e9tecter des vibrations", "not_bat_low": "{entity_name} batterie normale", "not_cold": "{entity_name} n'est plus froid", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} est d\u00e9sactiv\u00e9", "turned_on": "{entity_name} est activ\u00e9", "unsafe": "{entity_name} est devenu dangereux", + "update": "{entity_name} a une mise \u00e0 jour disponible", "vibration": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter les vibrations" } }, @@ -178,6 +182,10 @@ "off": "Non d\u00e9tect\u00e9", "on": "D\u00e9tect\u00e9" }, + "update": { + "off": "\u00c0 jour", + "on": "Mise \u00e0 jour disponible" + }, "vibration": { "off": "RAS", "on": "D\u00e9tect\u00e9e" diff --git a/homeassistant/components/binary_sensor/translations/hu.json b/homeassistant/components/binary_sensor/translations/hu.json index c4395ca806c..d8befd7ae35 100644 --- a/homeassistant/components/binary_sensor/translations/hu.json +++ b/homeassistant/components/binary_sensor/translations/hu.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} nem \u00e9szlel probl\u00e9m\u00e1t", "is_no_smoke": "{entity_name} nem \u00e9rz\u00e9kel f\u00fcst\u00f6t", "is_no_sound": "{entity_name} nem \u00e9rz\u00e9kel hangot", + "is_no_update": "{entity_name} naprak\u00e9sz", "is_no_vibration": "{entity_name} nem \u00e9rz\u00e9kel rezg\u00e9st", "is_not_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g megfelel\u0151", "is_not_cold": "{entity_name} nem hideg", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} f\u00fcst\u00f6t \u00e9rz\u00e9kel", "is_sound": "{entity_name} hangot \u00e9rz\u00e9kel", "is_unsafe": "{entity_name} nem biztons\u00e1gos", + "is_update": "{entity_name} egy friss\u00edt\u00e9s \u00e1ll rendelkez\u00e9sre", "is_vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} m\u00e1r nem \u00e9szlel probl\u00e9m\u00e1t", "no_smoke": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel f\u00fcst\u00f6t", "no_sound": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel hangot", + "no_update": "{entity_name} naprak\u00e9sz lett", "no_vibration": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel rezg\u00e9st", "not_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g megfelel\u0151", "not_cold": "{entity_name} m\u00e1r nem hideg", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} ki lett kapcsolva", "turned_on": "{entity_name} be lett kapcsolva", "unsafe": "{entity_name} m\u00e1r nem biztons\u00e1gos", + "update": "{entity_name} el\u00e9rhet\u0151 friss\u00edt\u00e9s", "vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" } }, @@ -178,6 +182,10 @@ "off": "Norm\u00e1l", "on": "\u00c9szlelve" }, + "update": { + "off": "Naprak\u00e9sz", + "on": "Friss\u00edt\u00e9s el\u00e9rhet\u0151" + }, "vibration": { "off": "Norm\u00e1l", "on": "\u00c9szlelve" diff --git a/homeassistant/components/binary_sensor/translations/no.json b/homeassistant/components/binary_sensor/translations/no.json index 023fec6cc39..041643f9cc3 100644 --- a/homeassistant/components/binary_sensor/translations/no.json +++ b/homeassistant/components/binary_sensor/translations/no.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} registrerer ikke et problem", "is_no_smoke": "{entity_name} registrerer ikke r\u00f8yk", "is_no_sound": "{entity_name} registrerer ikke lyd", + "is_no_update": "{entity_name} er oppdatert", "is_no_vibration": "{entity_name} registrerer ikke bevegelse", "is_not_bat_low": "{entity_name} batteri er normalt", "is_not_cold": "{entity_name} er ikke kald", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} registrerer r\u00f8yk", "is_sound": "{entity_name} registrerer lyd", "is_unsafe": "{entity_name} er utrygg", + "is_update": "{entity_name} har en tilgjengelig oppdatering", "is_vibration": "{entity_name} registrerer vibrasjon" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} sluttet \u00e5 registrere problem", "no_smoke": "{entity_name} sluttet \u00e5 registrere r\u00f8yk", "no_sound": "{entity_name} sluttet \u00e5 registrere lyd", + "no_update": "{entity_name} ble oppdatert", "no_vibration": "{entity_name} sluttet \u00e5 registrere vibrasjon", "not_bat_low": "{entity_name} batteri normalt", "not_cold": "{entity_name} ble ikke lenger kald", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} sl\u00e5tt av", "turned_on": "{entity_name} sl\u00e5tt p\u00e5", "unsafe": "{entity_name} ble usikker", + "update": "{entity_name} har en oppdatering tilgjengelig", "vibration": "{entity_name} begynte \u00e5 oppdage vibrasjon" } }, @@ -178,6 +182,10 @@ "off": "Klart", "on": "Oppdaget" }, + "update": { + "off": "Oppdatert", + "on": "Oppdatering tilgjengelig" + }, "vibration": { "off": "Klart", "on": "Oppdaget" diff --git a/homeassistant/components/binary_sensor/translations/ru.json b/homeassistant/components/binary_sensor/translations/ru.json index 2db1506b392..c245d2ba15a 100644 --- a/homeassistant/components/binary_sensor/translations/ru.json +++ b/homeassistant/components/binary_sensor/translations/ru.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", "is_no_smoke": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u044b\u043c", "is_no_sound": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0432\u0443\u043a", + "is_no_update": "{entity_name} \u043d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f", "is_no_vibration": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e", "is_not_bat_low": "{entity_name} \u0432 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", "is_not_cold": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u044b\u043c", "is_sound": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0432\u0443\u043a", "is_unsafe": "{entity_name} \u0432 \u043d\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_update": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 {entity_name}", "is_vibration": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", "no_smoke": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u044b\u043c", "no_sound": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0437\u0432\u0443\u043a", + "no_update": "{entity_name} \u043e\u0431\u043d\u043e\u0432\u043b\u044f\u0435\u0442\u0441\u044f", "no_vibration": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e", "not_bat_low": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439 \u0437\u0430\u0440\u044f\u0434", "not_cold": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0430\u0442\u044c\u0441\u044f", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", "unsafe": "{entity_name} \u043d\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u044c", + "update": "\u0421\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0441\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 {entity_name}", "vibration": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" } }, @@ -178,6 +182,10 @@ "off": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d", "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d" }, + "update": { + "off": "\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u043d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f", + "on": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435" + }, "vibration": { "off": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0430", "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0430" diff --git a/homeassistant/components/binary_sensor/translations/zh-Hant.json b/homeassistant/components/binary_sensor/translations/zh-Hant.json index bf50782743e..4733d4d1dcc 100644 --- a/homeassistant/components/binary_sensor/translations/zh-Hant.json +++ b/homeassistant/components/binary_sensor/translations/zh-Hant.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name}\u672a\u5075\u6e2c\u5230\u554f\u984c", "is_no_smoke": "{entity_name}\u672a\u5075\u6e2c\u5230\u7159\u9727", "is_no_sound": "{entity_name}\u672a\u5075\u6e2c\u5230\u8072\u97f3", + "is_no_update": "{entity_name} \u5df2\u6700\u65b0", "is_no_vibration": "{entity_name}\u672a\u5075\u6e2c\u5230\u9707\u52d5", "is_not_bat_low": "{entity_name}\u96fb\u91cf\u6b63\u5e38", "is_not_cold": "{entity_name}\u4e0d\u51b7", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name}\u6b63\u5075\u6e2c\u5230\u7159\u9727", "is_sound": "{entity_name}\u6b63\u5075\u6e2c\u5230\u8072\u97f3", "is_unsafe": "{entity_name}\u4e0d\u5b89\u5168", + "is_update": "{entity_name} \u6709\u66f4\u65b0", "is_vibration": "{entity_name}\u6b63\u5075\u6e2c\u5230\u9707\u52d5" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u554f\u984c", "no_smoke": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u7159\u9727", "no_sound": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u8072\u97f3", + "no_update": "{entity_name} \u5df2\u6700\u65b0", "no_vibration": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u9707\u52d5", "not_bat_low": "{entity_name}\u96fb\u91cf\u6b63\u5e38", "not_cold": "{entity_name}\u5df2\u4e0d\u51b7", @@ -86,6 +89,7 @@ "turned_off": "{entity_name}\u5df2\u95dc\u9589", "turned_on": "{entity_name}\u5df2\u958b\u555f", "unsafe": "{entity_name}\u5df2\u4e0d\u5b89\u5168", + "update": "{entity_name} \u6709\u66f4\u65b0", "vibration": "{entity_name}\u5df2\u5075\u6e2c\u5230\u9707\u52d5" } }, @@ -178,6 +182,10 @@ "off": "\u672a\u89f8\u767c", "on": "\u5df2\u89f8\u767c" }, + "update": { + "off": "\u5df2\u6700\u65b0", + "on": "\u6709\u66f4\u65b0" + }, "vibration": { "off": "\u672a\u5075\u6e2c", "on": "\u5075\u6e2c" diff --git a/homeassistant/components/ifttt/translations/de.json b/homeassistant/components/ifttt/translations/de.json index 5184e89f29a..216511c62f5 100644 --- a/homeassistant/components/ifttt/translations/de.json +++ b/homeassistant/components/ifttt/translations/de.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "Deine Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Webhook-Nachrichten empfangen zu k\u00f6nnen." }, "create_entry": { - "default": "Um Ereignisse an Home Assistant zu senden, musst du die Aktion \"Eine Webanforderung erstellen\" aus dem [IFTTT Webhook Applet]({applet_url}) ausw\u00e4hlen.\n\nF\u00fclle folgende Informationen aus: \n- URL: `{webhook_url}`\n- Methode: POST\n- Inhaltstyp: application/json\n\nIn der Dokumentation ({docs_url}) findest du Informationen zur Konfiguration der Automation eingehender Daten." + "default": "Um Ereignisse an Home Assistant zu senden, musst du die Aktion \"Eine Webanforderung erstellen\" aus dem [IFTTT Webhook Applet]({applet_url}) ausw\u00e4hlen.\n\nF\u00fclle folgende Informationen aus: \n- URL: `{webhook_url}`\n- Methode: POST\n- Inhaltstyp: application/json\n\nIn [der Dokumentation] ({docs_url}) findest du Informationen zur Konfiguration der Automation eingehender Daten." }, "step": { "user": { diff --git a/homeassistant/components/mutesync/translations/de.json b/homeassistant/components/mutesync/translations/de.json index 613cac29b1c..dccab9e8d1e 100644 --- a/homeassistant/components/mutesync/translations/de.json +++ b/homeassistant/components/mutesync/translations/de.json @@ -2,7 +2,7 @@ "config": { "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Aktivieredie Authentifizierung in den Einstellungen von m\u00fctesync > Authentifizierung", + "invalid_auth": "Aktiviere die Authentifizierung in den Einstellungen von m\u00fctesync > Authentifizierung", "unknown": "Unerwarteter Fehler" }, "step": { diff --git a/homeassistant/components/risco/translations/de.json b/homeassistant/components/risco/translations/de.json index a5ebcab51b5..77d842353fc 100644 --- a/homeassistant/components/risco/translations/de.json +++ b/homeassistant/components/risco/translations/de.json @@ -44,7 +44,7 @@ "B": "Gruppe B", "C": "Gruppe C", "D": "Gruppe D", - "arm": "Aktiv, abwesend", + "arm": "Aktiv (abwesend)", "partial_arm": "Teilweise aktiv (STAY)" }, "description": "W\u00e4hle aus, welchen Zustand dein Home Assistant-Alarm f\u00fcr jeden von Risco gemeldeten Zustand melden soll", diff --git a/homeassistant/components/sensor/translations/ca.json b/homeassistant/components/sensor/translations/ca.json index f0df998170d..9303635ca60 100644 --- a/homeassistant/components/sensor/translations/ca.json +++ b/homeassistant/components/sensor/translations/ca.json @@ -9,10 +9,18 @@ "is_gas": "Gas actual de {entity_name}", "is_humidity": "Humitat actual de {entity_name}", "is_illuminance": "Il\u00b7luminaci\u00f3 actual de {entity_name}", + "is_nitrogen_dioxide": "Concentraci\u00f3 actual de di\u00f2xid de nitrogen de {entity_name}", + "is_nitrogen_monoxide": "Concentraci\u00f3 actual de mon\u00f2xid de nitrogen de {entity_name}", + "is_nitrous_oxide": "Concentraci\u00f3 actual d'\u00f2xid nitr\u00f3s de {entity_name}", + "is_ozone": "Concentraci\u00f3 actual d'oz\u00f3 de {entity_name}", + "is_pm1": "Concentraci\u00f3 actual de PM1 de {entity_name}", + "is_pm10": "Concentraci\u00f3 actual de PM10 de {entity_name}", + "is_pm25": "Concentraci\u00f3 actual de PM2.5 de {entity_name}", "is_power": "Pot\u00e8ncia actual de {entity_name}", "is_power_factor": "Factor de pot\u00e8ncia actual de {entity_name}", "is_pressure": "Pressi\u00f3 actual de {entity_name}", "is_signal_strength": "Pot\u00e8ncia de senyal actual de {entity_name}", + "is_sulphur_dioxide": "Concentraci\u00f3 actual de di\u00f2xid de sofre de {entity_name}", "is_temperature": "Temperatura actual de {entity_name}", "is_value": "Valor actual de {entity_name}", "is_voltage": "Voltatge actual de {entity_name}" @@ -26,10 +34,18 @@ "gas": "Canvia el gas de {entity_name}", "humidity": "Canvia la humitat de {entity_name}", "illuminance": "Canvia la il\u00b7luminaci\u00f3 de {entity_name}", + "nitrogen_dioxide": "Canvia la concentraci\u00f3 de di\u00f2xid de nitrogen de {entity_name}", + "nitrogen_monoxide": "Canvia la concentraci\u00f3 de mon\u00f2xid de nitrogen de {entity_name}", + "nitrous_oxide": "Canvia la concentraci\u00f3 d'\u00f2xid nitr\u00f3s de {entity_name}", + "ozone": "Canvia la concentraci\u00f3 d'oz\u00f3 de {entity_name}", + "pm1": "Canvia la concentraci\u00f3 de PM1 de {entity_name}", + "pm10": "Canvia la concentraci\u00f3 de PM10 de {entity_name}", + "pm25": "Canvia la concentraci\u00f3 de PM2.5 de {entity_name}", "power": "Canvia la pot\u00e8ncia de {entity_name}", "power_factor": "Canvia el factor de pot\u00e8ncia de {entity_name}", "pressure": "Canvia la pressi\u00f3 de {entity_name}", "signal_strength": "Canvia la pot\u00e8ncia de senyal de {entity_name}", + "sulphur_dioxide": "Canvia la concentraci\u00f3 de di\u00f2xid de sofre de {entity_name}", "temperature": "Canvia la temperatura de {entity_name}", "value": "Canvia el valor de {entity_name}", "voltage": "Canvia el voltatge de {entity_name}" diff --git a/homeassistant/components/sensor/translations/de.json b/homeassistant/components/sensor/translations/de.json index c65959b8210..ed6b678480f 100644 --- a/homeassistant/components/sensor/translations/de.json +++ b/homeassistant/components/sensor/translations/de.json @@ -9,10 +9,18 @@ "is_gas": "Aktuelles {entity_name} Gas", "is_humidity": "{entity_name} Feuchtigkeit", "is_illuminance": "Aktuelle {entity_name} Helligkeit", + "is_nitrogen_dioxide": "Aktuelle Stickstoffdioxid-Konzentration von {entity_name}", + "is_nitrogen_monoxide": "Aktuelle Stickstoffmonoxidkonzentration von {entity_name}", + "is_nitrous_oxide": "Aktuelle Lachgaskonzentration von {entity_name}", + "is_ozone": "Aktuelle Ozonkonzentration von {entity_name}", + "is_pm1": "Aktuelle PM1-Konzentrationswert von {entity_name}", + "is_pm10": "Aktuelle PM10-Konzentrationswert von {entity_name}", + "is_pm25": "Aktuelle PM2.5-Konzentration von {entity_name}", "is_power": "Aktuelle {entity_name} Leistung", "is_power_factor": "Aktueller Leistungsfaktor f\u00fcr {entity_name}", "is_pressure": "{entity_name} Druck", "is_signal_strength": "Aktuelle {entity_name} Signalst\u00e4rke", + "is_sulphur_dioxide": "Aktuelle Schwefeldioxid-Konzentration von {entity_name}", "is_temperature": "Aktuelle {entity_name} Temperatur", "is_value": "Aktueller {entity_name} Wert", "is_voltage": "Aktuelle Spannung von {entity_name}" @@ -26,10 +34,18 @@ "gas": "{entity_name} Gas\u00e4nderungen", "humidity": "{entity_name} Feuchtigkeits\u00e4nderungen", "illuminance": "{entity_name} Helligkeits\u00e4nderungen", + "nitrogen_dioxide": "\u00c4nderung der Stickstoffdioxidkonzentration bei {entity_name}", + "nitrogen_monoxide": "\u00c4nderung der Stickstoffmonoxid-Konzentration bei {entity_name}", + "nitrous_oxide": "\u00c4nderung der Lachgaskonzentration bei {entity_name}", + "ozone": "\u00c4nderung der Ozonkonzentration bei {entity_name}", + "pm1": "\u00c4nderung der PM1-Konzentration bei {entity_name}", + "pm10": "\u00c4nderung der PM10-Konzentration bei {entity_name}", + "pm25": "\u00c4nderung der PM2,5-Konzentration bei {entity_name}", "power": "{entity_name} Leistungs\u00e4nderungen", "power_factor": "{entity_name} Leistungsfaktor\u00e4nderung", "pressure": "{entity_name} Druck\u00e4nderungen", "signal_strength": "{entity_name} Signalst\u00e4rke\u00e4nderungen", + "sulphur_dioxide": "\u00c4nderung der Schwefeldioxidkonzentration bei {entity_name}", "temperature": "{entity_name} Temperatur\u00e4nderungen", "value": "{entity_name} Wert\u00e4nderungen", "voltage": "{entity_name} Spannungs\u00e4nderungen" diff --git a/homeassistant/components/sensor/translations/et.json b/homeassistant/components/sensor/translations/et.json index 839f505f6aa..f36391e1e44 100644 --- a/homeassistant/components/sensor/translations/et.json +++ b/homeassistant/components/sensor/translations/et.json @@ -9,10 +9,18 @@ "is_gas": "Praegune {entity_name} gaas", "is_humidity": "Praegune {entity_name} niiskus", "is_illuminance": "Praegune {entity_name} valgustatus", + "is_nitrogen_dioxide": "Praegune {entity_name} l\u00e4mmastikdioksiidi kontsentratsioonitase", + "is_nitrogen_monoxide": "Praegune {entity_name} l\u00e4mmastikmonooksiidi kontsentratsioonitase", + "is_nitrous_oxide": "Praegune {entity_name} dil\u00e4mmastikoksiidi kontsentratsioonitase", + "is_ozone": "Praegune osoonisisalduse tase {entity_name}", + "is_pm1": "Praegune {entity_name} PM1 kontsentratsioonitase", + "is_pm10": "Praegune {entity_name} PM10 kontsentratsioonitase", + "is_pm25": "Praegune {entity_name} PM2.5 kontsentratsioonitase", "is_power": "Praegune {entity_name} toide (v\u00f5imsus)", "is_power_factor": "Praegune {entity_name} v\u00f5imsusfaktor", "is_pressure": "Praegune {entity_name} r\u00f5hk", "is_signal_strength": "Praegune {entity_name} signaali tugevus", + "is_sulphur_dioxide": "Praegune v\u00e4\u00e4veldioksiidi kontsentratsioonitase {entity_name}", "is_temperature": "Praegune {entity_name} temperatuur", "is_value": "Praegune {entity_name} v\u00e4\u00e4rtus", "is_voltage": "Praegune {entity_name}pinge" @@ -26,10 +34,18 @@ "gas": "{entity_name} gaasivahetus", "humidity": "{entity_name} niiskus muutub", "illuminance": "{entity_name} valgustustugevus muutub", + "nitrogen_dioxide": "{entity_name} l\u00e4mmastikdioksiidi kontsentratsiooni muutused", + "nitrogen_monoxide": "{entity_name} l\u00e4mmastikmonooksiidi kontsentratsiooni muutused", + "nitrous_oxide": "{entity_name} l\u00e4mmastikoksiidi kontsentratsiooni muutused", + "ozone": "{entity_name} osooni kontsentratsiooni muutused", + "pm1": "{entity_name} PM1 kontsentratsiooni muutused", + "pm10": "{entity_name} PM10 kontsentratsiooni muutused", + "pm25": "{entity_name} PM2.5 kontsentratsiooni muutused", "power": "{entity_name} energiare\u017eiimi muutub", "power_factor": "{entity_name} v\u00f5imsus muutub", "pressure": "{entity_name} r\u00f5hk muutub", "signal_strength": "{entity_name} signaalitugevus muutub", + "sulphur_dioxide": "{entity_name} v\u00e4\u00e4veldioksiidi kontsentratsiooni muutused", "temperature": "{entity_name} temperatuur muutub", "value": "{entity_name} v\u00e4\u00e4rtus muutub", "voltage": "{entity_name} pingemuutub" diff --git a/homeassistant/components/sensor/translations/hu.json b/homeassistant/components/sensor/translations/hu.json index 1e2aba465cc..58ecdea0f24 100644 --- a/homeassistant/components/sensor/translations/hu.json +++ b/homeassistant/components/sensor/translations/hu.json @@ -9,10 +9,18 @@ "is_gas": "Jelenlegi {entity_name} g\u00e1z", "is_humidity": "{entity_name} aktu\u00e1lis p\u00e1ratartalma", "is_illuminance": "{entity_name} aktu\u00e1lis megvil\u00e1g\u00edt\u00e1sa", + "is_nitrogen_dioxide": "Jelenlegi {entity_name} nitrog\u00e9n-dioxid-koncentr\u00e1ci\u00f3 szint", + "is_nitrogen_monoxide": "Jelenlegi {entity_name} nitrog\u00e9n-monoxid-koncentr\u00e1ci\u00f3 szint", + "is_nitrous_oxide": "Jelenlegi {entity_name} dinitrog\u00e9n-oxid-koncentr\u00e1ci\u00f3 szint", + "is_ozone": "Jelenlegi {entity_name} \u00f3zonkoncentr\u00e1ci\u00f3 szint", + "is_pm1": "Jelenlegi {entity_name} PM1 koncentr\u00e1ci\u00f3 szintje", + "is_pm10": "Jelenlegi {entity_name} PM10 koncentr\u00e1ci\u00f3 szintje", + "is_pm25": "Jelenlegi {entity_name} PM2.5 koncentr\u00e1ci\u00f3 szintje", "is_power": "{entity_name} aktu\u00e1lis teljes\u00edtm\u00e9nye", "is_power_factor": "A jelenlegi {entity_name} teljes\u00edtm\u00e9nyt\u00e9nyez\u0151", "is_pressure": "{entity_name} aktu\u00e1lis nyom\u00e1sa", "is_signal_strength": "{entity_name} aktu\u00e1lis jeler\u0151ss\u00e9ge", + "is_sulphur_dioxide": "A {entity_name} k\u00e9n-dioxid koncentr\u00e1ci\u00f3 jelenlegi szintje", "is_temperature": "{entity_name} aktu\u00e1lis h\u0151m\u00e9rs\u00e9klete", "is_value": "{entity_name} aktu\u00e1lis \u00e9rt\u00e9ke", "is_voltage": "A jelenlegi {entity_name} fesz\u00fclts\u00e9g" @@ -26,10 +34,18 @@ "gas": "{entity_name} g\u00e1z v\u00e1ltoz\u00e1sok", "humidity": "{entity_name} p\u00e1ratartalma v\u00e1ltozik", "illuminance": "{entity_name} megvil\u00e1g\u00edt\u00e1sa v\u00e1ltozik", + "nitrogen_dioxide": "{entity_name} nitrog\u00e9n-dioxid koncentr\u00e1ci\u00f3 v\u00e1ltozik", + "nitrogen_monoxide": "{entity_name} nitrog\u00e9n-monoxid koncentr\u00e1ci\u00f3 v\u00e1ltozik", + "nitrous_oxide": "{entity_name} dinitrog\u00e9n-oxid koncentr\u00e1ci\u00f3ja v\u00e1ltozik", + "ozone": "{entity_name} \u00f3zonkoncentr\u00e1ci\u00f3 v\u00e1ltozik", + "pm1": "{entity_name} PM1 koncentr\u00e1ci\u00f3 v\u00e1ltozik", + "pm10": "{entity_name} PM10 koncentr\u00e1ci\u00f3 v\u00e1ltozik", + "pm25": "{entity_name} PM2.5 koncentr\u00e1ci\u00f3 v\u00e1ltozik", "power": "{entity_name} teljes\u00edtm\u00e9nye v\u00e1ltozik", "power_factor": "{entity_name} teljes\u00edtm\u00e9nyt\u00e9nyez\u0151 megv\u00e1ltozik", "pressure": "{entity_name} nyom\u00e1sa v\u00e1ltozik", "signal_strength": "{entity_name} jeler\u0151ss\u00e9ge v\u00e1ltozik", + "sulphur_dioxide": "{entity_name} k\u00e9n-dioxid koncentr\u00e1ci\u00f3v\u00e1ltoz\u00e1s", "temperature": "{entity_name} h\u0151m\u00e9rs\u00e9klete v\u00e1ltozik", "value": "{entity_name} \u00e9rt\u00e9ke v\u00e1ltozik", "voltage": "{entity_name} fesz\u00fclts\u00e9ge v\u00e1ltozik" diff --git a/homeassistant/components/sensor/translations/no.json b/homeassistant/components/sensor/translations/no.json index c9c9542b92b..9af00949510 100644 --- a/homeassistant/components/sensor/translations/no.json +++ b/homeassistant/components/sensor/translations/no.json @@ -9,10 +9,18 @@ "is_gas": "Gjeldende {entity_name} gass", "is_humidity": "Gjeldende {entity_name} fuktighet", "is_illuminance": "Gjeldende {entity_name} belysningsstyrke", + "is_nitrogen_dioxide": "Gjeldende konsentrasjonsniv\u00e5 for {entity_name}", + "is_nitrogen_monoxide": "Gjeldende {entity_name} nitrogenmonoksidkonsentrasjonsniv\u00e5", + "is_nitrous_oxide": "Gjeldende {entity_name} lystgasskonsentrasjonsniv\u00e5", + "is_ozone": "Gjeldende {entity_name} ozonkonsentrasjonsniv\u00e5", + "is_pm1": "Gjeldende {entity_name} PM1 konsentrasjonsniv\u00e5", + "is_pm10": "Gjeldende konsentrasjonsniv\u00e5 for {entity_name}", + "is_pm25": "Gjeldende {entity_name} PM2.5 konsentrasjonsniv\u00e5", "is_power": "Gjeldende {entity_name}-effekt", "is_power_factor": "Gjeldende {entity_name} effektfaktor", "is_pressure": "Gjeldende {entity_name} trykk", "is_signal_strength": "Gjeldende {entity_name} signalstyrke", + "is_sulphur_dioxide": "Gjeldende konsentrasjonsniv\u00e5 for svoveldioksid for {entity_name}", "is_temperature": "Gjeldende {entity_name} temperatur", "is_value": "Gjeldende {entity_name} verdi", "is_voltage": "Gjeldende {entity_name} spenning" @@ -26,10 +34,18 @@ "gas": "{entity_name} gass endres", "humidity": "{entity_name} fuktighets endringer", "illuminance": "{entity_name} belysningsstyrke endringer", + "nitrogen_dioxide": "{entity_name} nitrogendioksidkonsentrasjonsendringer", + "nitrogen_monoxide": "{entity_name} nitrogenmonoksidkonsentrasjonsendringer", + "nitrous_oxide": "{entity_name} endringer i nitrogenoksidskonsentrasjonen", + "ozone": "{entity_name} ozonkonsentrasjonsendringer", + "pm1": "{entity_name} PM1 -konsentrasjon endres", + "pm10": "{entity_name} PM10 -konsentrasjon endres", + "pm25": "{entity_name} PM2.5 konsentrasjon endres", "power": "{entity_name} effektendringer", "power_factor": "{entity_name} effektfaktorendringer", "pressure": "{entity_name} trykk endringer", "signal_strength": "{entity_name} signalstyrkeendringer", + "sulphur_dioxide": "{entity_name} svoveldioksidkonsentrasjon endres", "temperature": "{entity_name} temperaturendringer", "value": "{entity_name} verdi endringer", "voltage": "{entity_name} spenningsendringer" diff --git a/homeassistant/components/sensor/translations/ru.json b/homeassistant/components/sensor/translations/ru.json index 930459c4fc5..641ec453c51 100644 --- a/homeassistant/components/sensor/translations/ru.json +++ b/homeassistant/components/sensor/translations/ru.json @@ -9,10 +9,18 @@ "is_gas": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_humidity": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_illuminance": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "is_nitrogen_dioxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0430\u0437\u043e\u0442\u0430", + "is_nitrogen_monoxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043c\u043e\u043d\u043e\u043e\u043a\u0441\u0438\u0434\u0430 \u0430\u0437\u043e\u0442\u0430", + "is_nitrous_oxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0437\u0430\u043a\u0438\u0441\u0438 \u0430\u0437\u043e\u0442\u0430", + "is_ozone": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u0437\u043e\u043d\u0430", + "is_pm1": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM1", + "is_pm10": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM10", + "is_pm25": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM2.5", "is_power": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_power_factor": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u044d\u0444\u0444\u0438\u0446\u0438\u0435\u043d\u0442\u0430 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", "is_pressure": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_signal_strength": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "is_sulphur_dioxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0441\u0435\u0440\u044b", "is_temperature": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_value": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_voltage": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" @@ -26,10 +34,18 @@ "gas": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435 \u0433\u0430\u0437\u0430", "humidity": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "illuminance": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "nitrogen_dioxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0430\u0437\u043e\u0442\u0430", + "nitrogen_monoxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043c\u043e\u043d\u043e\u043e\u043a\u0441\u0438\u0434\u0430 \u0430\u0437\u043e\u0442\u0430", + "nitrous_oxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0437\u0430\u043a\u0438\u0441\u0438 \u0430\u0437\u043e\u0442\u0430", + "ozone": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u0437\u043e\u043d\u0430", + "pm1": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM1", + "pm10": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM10", + "pm25": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM2.5", "power": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "power_factor": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u043a\u043e\u044d\u0444\u0444\u0438\u0446\u0438\u0435\u043d\u0442 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", "pressure": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "signal_strength": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "sulphur_dioxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0441\u0435\u0440\u044b", "temperature": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "value": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "voltage": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" diff --git a/homeassistant/components/sensor/translations/zh-Hant.json b/homeassistant/components/sensor/translations/zh-Hant.json index b22ba82f3a4..52ab5878ba3 100644 --- a/homeassistant/components/sensor/translations/zh-Hant.json +++ b/homeassistant/components/sensor/translations/zh-Hant.json @@ -9,10 +9,18 @@ "is_gas": "\u76ee\u524d{entity_name}\u6c23\u9ad4", "is_humidity": "\u76ee\u524d{entity_name}\u6fd5\u5ea6", "is_illuminance": "\u76ee\u524d{entity_name}\u7167\u5ea6", + "is_nitrogen_dioxide": "\u76ee\u524d {entity_name} \u4e8c\u6c27\u5316\u6c2e\u6fc3\u5ea6\u72c0\u614b", + "is_nitrogen_monoxide": "\u76ee\u524d {entity_name} \u4e00\u6c27\u5316\u6c2e\u6fc3\u5ea6\u72c0\u614b", + "is_nitrous_oxide": "\u76ee\u524d {entity_name} \u4e00\u6c27\u5316\u4e8c\u6c2e\u6fc3\u5ea6\u72c0\u614b", + "is_ozone": "\u76ee\u524d {entity_name} \u81ed\u6c27\u6fc3\u5ea6\u72c0\u614b", + "is_pm1": "\u76ee\u524d {entity_name} PM1 \u6fc3\u5ea6\u72c0\u614b", + "is_pm10": "\u76ee\u524d {entity_name} PM10 \u6fc3\u5ea6\u72c0\u614b", + "is_pm25": "\u76ee\u524d {entity_name} PM2.5 \u6fc3\u5ea6\u72c0\u614b", "is_power": "\u76ee\u524d{entity_name}\u96fb\u529b", "is_power_factor": "\u76ee\u524d{entity_name}\u529f\u7387\u56e0\u6578", "is_pressure": "\u76ee\u524d{entity_name}\u58d3\u529b", "is_signal_strength": "\u76ee\u524d{entity_name}\u8a0a\u865f\u5f37\u5ea6", + "is_sulphur_dioxide": "\u76ee\u524d {entity_name} \u4e8c\u6c27\u5316\u786b\u6fc3\u5ea6\u72c0\u614b", "is_temperature": "\u76ee\u524d{entity_name}\u6eab\u5ea6", "is_value": "\u76ee\u524d{entity_name}\u503c", "is_voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3" @@ -26,10 +34,18 @@ "gas": "{entity_name}\u6c23\u9ad4\u8b8a\u66f4", "humidity": "{entity_name}\u6fd5\u5ea6\u8b8a\u66f4", "illuminance": "{entity_name}\u7167\u5ea6\u8b8a\u66f4", + "nitrogen_dioxide": "{entity_name} \u4e8c\u6c27\u5316\u6c2e\u6fc3\u5ea6\u8b8a\u5316", + "nitrogen_monoxide": "{entity_name} \u4e00\u6c27\u5316\u6c2e\u6fc3\u5ea6\u8b8a\u5316", + "nitrous_oxide": "{entity_name} \u4e00\u6c27\u5316\u4e8c\u6c2e\u6fc3\u5ea6\u8b8a\u5316", + "ozone": "{entity_name} \u81ed\u6c27\u6fc3\u5ea6\u8b8a\u5316", + "pm1": "{entity_name} PM1 \u6fc3\u5ea6\u8b8a\u5316", + "pm10": "{entity_name} PM10 \u6fc3\u5ea6\u8b8a\u5316", + "pm25": "{entity_name} PM2.5 \u6fc3\u5ea6\u8b8a\u5316", "power": "{entity_name}\u96fb\u529b\u8b8a\u66f4", "power_factor": "\u76ee\u524d{entity_name}\u529f\u7387\u56e0\u6578\u8b8a\u66f4", "pressure": "{entity_name}\u58d3\u529b\u8b8a\u66f4", "signal_strength": "{entity_name}\u8a0a\u865f\u5f37\u5ea6\u8b8a\u66f4", + "sulphur_dioxide": "{entity_name} \u4e8c\u6c27\u5316\u786b\u6fc3\u5ea6\u8b8a\u5316", "temperature": "{entity_name}\u6eab\u5ea6\u8b8a\u66f4", "value": "{entity_name}\u503c\u8b8a\u66f4", "voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3\u8b8a\u66f4" diff --git a/homeassistant/components/tractive/translations/ca.json b/homeassistant/components/tractive/translations/ca.json index 4854e13a199..0641dd2737b 100644 --- a/homeassistant/components/tractive/translations/ca.json +++ b/homeassistant/components/tractive/translations/ca.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "El dispositiu ja est\u00e0 configurat" + "already_configured": "El dispositiu ja est\u00e0 configurat", + "reauth_failed_existing": "No s'ha pogut actualitzar l'entrada de configuraci\u00f3, elimina la integraci\u00f3 i torna-la a instal\u00b7lar.", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", diff --git a/homeassistant/components/tractive/translations/cs.json b/homeassistant/components/tractive/translations/cs.json index de52bfbd7a8..3ad489e1f5e 100644 --- a/homeassistant/components/tractive/translations/cs.json +++ b/homeassistant/components/tractive/translations/cs.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", diff --git a/homeassistant/components/tractive/translations/de.json b/homeassistant/components/tractive/translations/de.json index fbb3411a6c5..cad80fd36a8 100644 --- a/homeassistant/components/tractive/translations/de.json +++ b/homeassistant/components/tractive/translations/de.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_failed_existing": "Der Konfigurationseintrag konnte nicht aktualisiert werden. Bitte entferne die Integration und richte sie erneut ein.", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { diff --git a/homeassistant/components/tractive/translations/et.json b/homeassistant/components/tractive/translations/et.json index 7e9ab892ed4..67adf622ebe 100644 --- a/homeassistant/components/tractive/translations/et.json +++ b/homeassistant/components/tractive/translations/et.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_failed_existing": "Seadekirjet ei \u00f5nnestunud uuendada, eemalda sidumine ja seadista see uuesti.", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "invalid_auth": "Tuvastamine nurjus", diff --git a/homeassistant/components/tractive/translations/no.json b/homeassistant/components/tractive/translations/no.json index 3ae73c02103..a768b453848 100644 --- a/homeassistant/components/tractive/translations/no.json +++ b/homeassistant/components/tractive/translations/no.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Enheten er allerede konfigurert" + "already_configured": "Enheten er allerede konfigurert", + "reauth_failed_existing": "Kunne ikke oppdatere konfigurasjonsoppf\u00f8ringen. Fjern integrasjonen og sett den opp igjen.", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/tractive/translations/ru.json b/homeassistant/components/tractive/translations/ru.json index 155e3a99ba5..89042b79b5e 100644 --- a/homeassistant/components/tractive/translations/ru.json +++ b/homeassistant/components/tractive/translations/ru.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "reauth_failed_existing": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438, \u0443\u0434\u0430\u043b\u0438\u0442\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0435\u0451 \u0441\u043d\u043e\u0432\u0430.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", diff --git a/homeassistant/components/xiaomi_aqara/translations/de.json b/homeassistant/components/xiaomi_aqara/translations/de.json index bc87f461c33..469fa14bcc1 100644 --- a/homeassistant/components/xiaomi_aqara/translations/de.json +++ b/homeassistant/components/xiaomi_aqara/translations/de.json @@ -33,7 +33,7 @@ "data": { "host": "IP-Adresse (optional)", "interface": "Die zu verwendende Netzwerkschnittstelle", - "mac": "MAC-Adresse" + "mac": "MAC-Adresse (optional)" }, "description": "Stelle eine Verbindung zu deinem Xiaomi Aqara Gateway her. Wenn die IP- und MAC-Adressen leer bleiben, wird die automatische Erkennung verwendet", "title": "Xiaomi Aqara Gateway" diff --git a/homeassistant/components/xiaomi_miio/translations/ca.json b/homeassistant/components/xiaomi_miio/translations/ca.json index ff0a24170f6..7e9d7d5c7eb 100644 --- a/homeassistant/components/xiaomi_miio/translations/ca.json +++ b/homeassistant/components/xiaomi_miio/translations/ca.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "Ha fallat la connexi\u00f3", "cloud_credentials_incomplete": "Credencials del n\u00favol incompletes, introdueix el nom d'usuari, la contrasenya i el pa\u00eds", - "cloud_login_error": "No s'ha pogut iniciar sessi\u00f3 a Xioami Miio Cloud, comprova les credencials.", + "cloud_login_error": "No s'ha pogut iniciar sessi\u00f3 a Xiaomi Miio Cloud, comprova les credencials.", "cloud_no_devices": "No s'han trobat dispositius en aquest compte al n\u00favol de Xiaomi Miio.", "no_device_selected": "No hi ha cap dispositiu seleccionat, selecciona'n un.", "unknown_device": "No es reconeix el model del dispositiu, no es pot configurar el dispositiu mitjan\u00e7ant el flux de configuraci\u00f3." diff --git a/homeassistant/components/xiaomi_miio/translations/de.json b/homeassistant/components/xiaomi_miio/translations/de.json index 17363b347c0..24e639e3a23 100644 --- a/homeassistant/components/xiaomi_miio/translations/de.json +++ b/homeassistant/components/xiaomi_miio/translations/de.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "cloud_credentials_incomplete": "Cloud-Anmeldeinformationen unvollst\u00e4ndig, bitte Benutzernamen, Passwort und Land eingeben", - "cloud_login_error": "Konnte sich nicht bei Xioami Miio Cloud anmelden, \u00fcberpr\u00fcfe die Anmeldedaten.", + "cloud_login_error": "Die Anmeldung bei Xiaomi Miio Cloud ist fehlgeschlagen, \u00fcberpr\u00fcfe die Anmeldedaten.", "cloud_no_devices": "Keine Ger\u00e4te in diesem Xiaomi Miio Cloud-Konto gefunden.", "no_device_selected": "Kein Ger\u00e4t ausgew\u00e4hlt, bitte w\u00e4hle ein Ger\u00e4t aus.", "unknown_device": "Das Ger\u00e4temodell ist nicht bekannt und das Ger\u00e4t kann nicht mithilfe des Assistenten eingerichtet werden." diff --git a/homeassistant/components/xiaomi_miio/translations/et.json b/homeassistant/components/xiaomi_miio/translations/et.json index 92d8ffe048f..4eb326d7f08 100644 --- a/homeassistant/components/xiaomi_miio/translations/et.json +++ b/homeassistant/components/xiaomi_miio/translations/et.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "\u00dchendus nurjus", "cloud_credentials_incomplete": "Pilve mandaat on poolik, palun t\u00e4ida kasutajanimi, salas\u00f5na ja riik", - "cloud_login_error": "Xioami Miio Cloudi ei saanud sisse logida, kontrolli mandaati.", + "cloud_login_error": "Xiaomi Miio Cloudi ei saanud sisse logida, kontrolli mandaati.", "cloud_no_devices": "Xiaomi Miio pilvekontolt ei leitud \u00fchtegi seadet.", "no_device_selected": "Seadmeid pole valitud, vali \u00fcks seade.", "unknown_device": "Seadme mudel pole teada, seadet ei saa seadistamisvoo abil seadistada." From bd550c4559850b5d819e5a9ada1106cddc2cf943 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 18 Aug 2021 02:40:06 +0200 Subject: [PATCH 467/903] Use AQI, PM1, PM25, PM10 device classes in Airly (#54742) --- homeassistant/components/airly/const.py | 11 ++++++++--- tests/components/airly/test_sensor.py | 12 ++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index e6b87db6f15..79004abbe41 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -6,7 +6,11 @@ from typing import Final from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, @@ -49,26 +53,27 @@ NO_AIRLY_SENSORS: Final = "There are no Airly sensors in this area yet." SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_CAQI, + device_class=DEVICE_CLASS_AQI, name=ATTR_API_CAQI, native_unit_of_measurement="CAQI", ), AirlySensorEntityDescription( key=ATTR_API_PM1, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM1, name=ATTR_API_PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), AirlySensorEntityDescription( key=ATTR_API_PM25, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM25, name="PM2.5", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), AirlySensorEntityDescription( key=ATTR_API_PM10, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM10, name=ATTR_API_PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index c566702a5b4..cd17c692176 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -7,10 +7,13 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, @@ -38,6 +41,7 @@ async def test_sensor(hass, aioclient_mock): assert state.state == "23" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "CAQI" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_AQI entry = registry.async_get("sensor.home_caqi") assert entry @@ -63,7 +67,7 @@ async def test_sensor(hass, aioclient_mock): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM1 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT entry = registry.async_get("sensor.home_pm1") @@ -78,7 +82,7 @@ async def test_sensor(hass, aioclient_mock): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT entry = registry.async_get("sensor.home_pm2_5") @@ -93,7 +97,7 @@ async def test_sensor(hass, aioclient_mock): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT entry = registry.async_get("sensor.home_pm10") From 10058ea3f01540cb446fcd067022460ad35dfdac Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 18 Aug 2021 05:35:05 +0200 Subject: [PATCH 468/903] Use new device classes in GIOS integration (#54743) * Use new device classes * Clean up --- homeassistant/components/gios/const.py | 19 ++++++++++++++- homeassistant/components/gios/sensor.py | 1 - tests/components/gios/test_sensor.py | 32 +++++++++++++------------ 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index fb96a08ab5b..4f19b0d8a68 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -5,7 +5,16 @@ from datetime import timedelta from typing import Final from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT -from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CO, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_SULPHUR_DIOXIDE, +) from .model import GiosSensorEntityDescription @@ -36,47 +45,55 @@ SENSOR_TYPES: Final[tuple[GiosSensorEntityDescription, ...]] = ( GiosSensorEntityDescription( key=ATTR_AQI, name="AQI", + device_class=DEVICE_CLASS_AQI, value=None, ), GiosSensorEntityDescription( key=ATTR_C6H6, name="C6H6", + icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_CO, name="CO", + device_class=DEVICE_CLASS_CO, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_NO2, name="NO2", + device_class=DEVICE_CLASS_NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_O3, name="O3", + device_class=DEVICE_CLASS_OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_PM10, name="PM10", + device_class=DEVICE_CLASS_PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_PM25, name="PM2.5", + device_class=DEVICE_CLASS_PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_SO2, name="SO2", + device_class=DEVICE_CLASS_SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index c58f08965ec..9ba5e5410b0 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -86,7 +86,6 @@ class GiosSensor(CoordinatorEntity, SensorEntity): "manufacturer": MANUFACTURER, "entry_type": "service", } - self._attr_icon = "mdi:blur" self._attr_name = f"{name} {description.name}" self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" self._attrs: dict[str, Any] = { diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 2da3d8e1e8c..adf151f4819 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -18,9 +18,17 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CO, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_SULPHUR_DIOXIDE, STATE_UNAVAILABLE, ) from homeassistant.helpers import entity_registry as er @@ -45,7 +53,7 @@ async def test_sensor(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_ICON) == "mdi:molecule" assert state.attributes.get(ATTR_INDEX) == "bardzo dobry" entry = registry.async_get("sensor.home_c6h6") @@ -57,12 +65,12 @@ async def test_sensor(hass): assert state.state == "252" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CO assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "dobry" entry = registry.async_get("sensor.home_co") @@ -74,12 +82,12 @@ async def test_sensor(hass): assert state.state == "7" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_NITROGEN_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "dobry" entry = registry.async_get("sensor.home_no2") @@ -91,12 +99,12 @@ async def test_sensor(hass): assert state.state == "96" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_OZONE assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "dobry" entry = registry.async_get("sensor.home_o3") @@ -108,12 +116,12 @@ async def test_sensor(hass): assert state.state == "17" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "dobry" entry = registry.async_get("sensor.home_pm10") @@ -125,12 +133,12 @@ async def test_sensor(hass): assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "dobry" entry = registry.async_get("sensor.home_pm2_5") @@ -142,12 +150,12 @@ async def test_sensor(hass): assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SULPHUR_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "bardzo dobry" entry = registry.async_get("sensor.home_so2") @@ -159,9 +167,9 @@ async def test_sensor(hass): assert state.state == "dobry" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_AQI assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get("sensor.home_aqi") assert entry @@ -225,7 +233,7 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_ICON) == "mdi:molecule" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_c6h6") @@ -242,7 +250,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_co") @@ -259,7 +266,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_no2") @@ -276,7 +282,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_o3") @@ -293,7 +298,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_pm10") @@ -310,7 +314,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_pm2_5") @@ -327,7 +330,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_so2") From d7c1e7c7dcfe1a72bf03f0ee708cbafa3c96b40a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Aug 2021 22:41:01 -0500 Subject: [PATCH 469/903] Adjust yeelight homekit model (#54783) --- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/generated/zeroconf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 7b78f540289..3528b096c67 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -10,6 +10,6 @@ "hostname": "yeelink-*" }], "homekit": { - "models": ["YLDP*"] + "models": ["YLD*"] } } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 536485f7f55..d973698a34b 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -262,7 +262,7 @@ HOMEKIT = { "Touch HD": "rainmachine", "Welcome": "netatmo", "Wemo": "wemo", - "YLDP*": "yeelight", + "YLD*": "yeelight", "iSmartGate": "gogogate2", "iZone": "izone", "tado": "tado" From 87496ae75c6cb049a1a7d75fdcc518300eb799cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Aug 2021 22:41:22 -0500 Subject: [PATCH 470/903] Fix HomeKit cover creation with tilt position, open/close, no set position (#54727) --- homeassistant/components/homekit/accessories.py | 7 ++++++- tests/components/homekit/test_get_accessories.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index c3fac44486c..ec6ef670f44 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -134,10 +134,15 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 and features & cover.SUPPORT_SET_POSITION ): a_type = "Window" - elif features & (cover.SUPPORT_SET_POSITION | cover.SUPPORT_SET_TILT_POSITION): + elif features & cover.SUPPORT_SET_POSITION: a_type = "WindowCovering" elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): a_type = "WindowCoveringBasic" + elif features & cover.SUPPORT_SET_TILT_POSITION: + # WindowCovering and WindowCoveringBasic both support tilt + # only WindowCovering can handle the covers that are missing + # SUPPORT_SET_POSITION, SUPPORT_OPEN, and SUPPORT_CLOSE + a_type = "WindowCovering" elif state.domain == "fan": a_type = "Fan" diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 1b220153195..af98f6a45f9 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -149,6 +149,18 @@ def test_types(type_name, entity_id, state, attrs, config): "open", {ATTR_SUPPORTED_FEATURES: (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE)}, ), + ( + "WindowCoveringBasic", + "cover.open_window", + "open", + { + ATTR_SUPPORTED_FEATURES: ( + cover.SUPPORT_OPEN + | cover.SUPPORT_CLOSE + | cover.SUPPORT_SET_TILT_POSITION + ) + }, + ), ], ) def test_type_covers(type_name, entity_id, state, attrs): From c65f769130753b42c3551bcdc441c2cebfa80398 Mon Sep 17 00:00:00 2001 From: Christopher Kochan <5183896+crkochan@users.noreply.github.com> Date: Wed, 18 Aug 2021 01:29:02 -0500 Subject: [PATCH 471/903] Update sense_energy to version 0.9.2 (#54787) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 419a34db98c..bb3ac2082f8 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -2,7 +2,7 @@ "domain": "emulated_kasa", "name": "Emulated Kasa", "documentation": "https://www.home-assistant.io/integrations/emulated_kasa", - "requirements": ["sense_energy==0.9.0"], + "requirements": ["sense_energy==0.9.2"], "codeowners": ["@kbickar"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 0bde2f7a7a7..16cecd1cd97 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -2,7 +2,7 @@ "domain": "sense", "name": "Sense", "documentation": "https://www.home-assistant.io/integrations/sense", - "requirements": ["sense_energy==0.9.0"], + "requirements": ["sense_energy==0.9.2"], "codeowners": ["@kbickar"], "config_flow": true, "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index efb249aac12..0c2674bcb9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2100,7 +2100,7 @@ sense-hat==2.2.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.9.0 +sense_energy==0.9.2 # homeassistant.components.sentry sentry-sdk==1.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7cf5441182e..4575c0c82ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1163,7 +1163,7 @@ screenlogicpy==0.4.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.9.0 +sense_energy==0.9.2 # homeassistant.components.sentry sentry-sdk==1.3.0 From 85d9890447968241b00c3d44277f175d3538234d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Aug 2021 08:59:13 +0200 Subject: [PATCH 472/903] Bump dessant/lock-threads from 2.1.1 to 2.1.2 (#54791) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 62c7299c2b8..96fc69e3b68 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -9,7 +9,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v2.1.1 + - uses: dessant/lock-threads@v2.1.2 with: github-token: ${{ github.token }} issue-lock-inactive-days: "30" From e1926caeb91345441ba133ef7768eb887d2e060d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 10:03:27 +0200 Subject: [PATCH 473/903] Remove STATE_CLASS_TOTAL and last_reset from sensor (#54755) * Remove STATE_CLASS_TOTAL * Update mill sensor * Update tests * Kill last_reset * Return ATTR_LAST_RESET to utility_meter * Update energy cost sensor * Restore last_reset for backwards compatibility * Re-add and update deprecation warning * Update tests * Fix utility_meter * Update EnergyCostSensor * Tweak * Fix rebase mistake * Fix test --- homeassistant/components/energy/sensor.py | 37 +++-- homeassistant/components/mill/sensor.py | 26 +--- homeassistant/components/recorder/models.py | 2 - .../components/recorder/statistics.py | 2 - homeassistant/components/sensor/__init__.py | 18 +-- homeassistant/components/sensor/recorder.py | 41 ++---- .../components/utility_meter/sensor.py | 13 +- tests/components/energy/test_sensor.py | 41 +++--- tests/components/history/test_init.py | 1 - tests/components/recorder/test_statistics.py | 3 - tests/components/sensor/test_init.py | 9 +- tests/components/sensor/test_recorder.py | 126 ++---------------- 12 files changed, 68 insertions(+), 251 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index fd36611acaf..497c762add9 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -6,9 +6,8 @@ import logging from typing import Any, Final, Literal, TypeVar, cast from homeassistant.components.sensor import ( - ATTR_LAST_RESET, DEVICE_CLASS_MONETARY, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -17,11 +16,10 @@ from homeassistant.const import ( ENERGY_WATT_HOUR, VOLUME_CUBIC_METERS, ) -from homeassistant.core import HomeAssistant, State, callback, split_entity_id +from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from .const import DOMAIN from .data import EnergyManager, async_get_manager @@ -203,16 +201,15 @@ class EnergyCostSensor(SensorEntity): f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" ) self._attr_device_class = DEVICE_CLASS_MONETARY - self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING self._config = config - self._last_energy_sensor_state: State | None = None + self._last_energy_sensor_state: StateType | None = None self._cur_value = 0.0 - def _reset(self, energy_state: State) -> None: + def _reset(self, energy_state: StateType) -> None: """Reset the cost sensor.""" self._attr_native_value = 0.0 self._cur_value = 0.0 - self._attr_last_reset = dt_util.utcnow() self._last_energy_sensor_state = energy_state self.async_write_ha_state() @@ -223,7 +220,7 @@ class EnergyCostSensor(SensorEntity): cast(str, self._config[self._adapter.entity_energy_key]) ) - if energy_state is None or ATTR_LAST_RESET not in energy_state.attributes: + if energy_state is None: return try: @@ -259,7 +256,7 @@ class EnergyCostSensor(SensorEntity): if self._last_energy_sensor_state is None: # Initialize as it's the first time all required entities are in place. - self._reset(energy_state) + self._reset(energy_state.state) return energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -280,19 +277,15 @@ class EnergyCostSensor(SensorEntity): ) return - if ( - energy_state.attributes[ATTR_LAST_RESET] - != self._last_energy_sensor_state.attributes[ATTR_LAST_RESET] - ): + if energy < float(self._last_energy_sensor_state): # Energy meter was reset, reset cost sensor too - self._reset(energy_state) - else: - # Update with newly incurred cost - old_energy_value = float(self._last_energy_sensor_state.state) - self._cur_value += (energy - old_energy_value) * energy_price - self._attr_native_value = round(self._cur_value, 2) + self._reset(0) + # Update with newly incurred cost + old_energy_value = float(self._last_energy_sensor_state) + self._cur_value += (energy - old_energy_value) * energy_price + self._attr_native_value = round(self._cur_value, 2) - self._last_energy_sensor_state = energy_state + self._last_energy_sensor_state = energy_state.state async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 5241f95abdb..ce7704ad1be 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -2,11 +2,10 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, - STATE_CLASS_TOTAL, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ENERGY_KILO_WATT_HOUR -from homeassistant.util import dt as dt_util from .const import CONSUMPTION_TODAY, CONSUMPTION_YEAR, DOMAIN, MANUFACTURER @@ -29,7 +28,7 @@ class MillHeaterEnergySensor(SensorEntity): _attr_device_class = DEVICE_CLASS_ENERGY _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - _attr_state_class = STATE_CLASS_TOTAL + _attr_state_class = STATE_CLASS_TOTAL_INCREASING def __init__(self, heater, mill_data_connection, sensor_type): """Initialize the sensor.""" @@ -45,16 +44,6 @@ class MillHeaterEnergySensor(SensorEntity): "manufacturer": MANUFACTURER, "model": f"generation {1 if heater.is_gen1 else 2}", } - if self._sensor_type == CONSUMPTION_TODAY: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) - ) - elif self._sensor_type == CONSUMPTION_YEAR: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace( - month=1, day=1, hour=0, minute=0, second=0, microsecond=0 - ) - ) async def async_update(self): """Retrieve latest state.""" @@ -71,15 +60,4 @@ class MillHeaterEnergySensor(SensorEntity): self._attr_native_value = _state return - if self.state is not None and _state < self.state: - if self._sensor_type == CONSUMPTION_TODAY: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) - ) - elif self._sensor_type == CONSUMPTION_YEAR: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace( - month=1, day=1, hour=0, minute=0, second=0, microsecond=0 - ) - ) self._attr_native_value = _state diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index ff64deb60cd..fe75ba1cb50 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -218,7 +218,6 @@ class StatisticData(TypedDict, total=False): mean: float min: float max: float - last_reset: datetime | None state: float sum: float @@ -242,7 +241,6 @@ class Statistics(Base): # type: ignore mean = Column(Float()) min = Column(Float()) max = Column(Float()) - last_reset = Column(DATETIME_TYPE) state = Column(Float()) sum = Column(Float()) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index b91e4d160df..6017f050419 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -43,7 +43,6 @@ QUERY_STATISTICS = [ Statistics.mean, Statistics.min, Statistics.max, - Statistics.last_reset, Statistics.state, Statistics.sum, ] @@ -375,7 +374,6 @@ def _sorted_statistics_to_dict( "mean": convert(db_state.mean, units), "min": convert(db_state.min, units), "max": convert(db_state.max, units), - "last_reset": _process_timestamp_to_utc_isoformat(db_state.last_reset), "state": convert(db_state.state, units), "sum": convert(db_state.sum, units), } diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 950af5a1375..94fb08c66b1 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -51,7 +51,7 @@ from homeassistant.helpers.typing import ConfigType, StateType _LOGGER: Final = logging.getLogger(__name__) -ATTR_LAST_RESET: Final = "last_reset" +ATTR_LAST_RESET: Final = "last_reset" # Deprecated, to be removed in 2021.11 ATTR_STATE_CLASS: Final = "state_class" DOMAIN: Final = "sensor" @@ -91,14 +91,11 @@ DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) # The state represents a measurement in present time STATE_CLASS_MEASUREMENT: Final = "measurement" -# The state represents a total amount, e.g. a value of a stock portfolio -STATE_CLASS_TOTAL: Final = "total" # The state represents a monotonically increasing total, e.g. an amount of consumed gas STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" STATE_CLASSES: Final[list[str]] = [ STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, ] @@ -132,7 +129,7 @@ class SensorEntityDescription(EntityDescription): """A class that describes sensor entities.""" state_class: str | None = None - last_reset: datetime | None = None + last_reset: datetime | None = None # Deprecated, to be removed in 2021.11 native_unit_of_measurement: str | None = None @@ -140,7 +137,7 @@ class SensorEntity(Entity): """Base class for sensor entities.""" entity_description: SensorEntityDescription - _attr_last_reset: datetime | None + _attr_last_reset: datetime | None # Deprecated, to be removed in 2021.11 _attr_native_unit_of_measurement: str | None _attr_native_value: StateType = None _attr_state_class: str | None @@ -157,7 +154,7 @@ class SensorEntity(Entity): return None @property - def last_reset(self) -> datetime | None: + def last_reset(self) -> datetime | None: # Deprecated, to be removed in 2021.11 """Return the time when the sensor was last reset, if any.""" if hasattr(self, "_attr_last_reset"): return self._attr_last_reset @@ -187,10 +184,9 @@ class SensorEntity(Entity): report_issue = self._suggest_report_issue() _LOGGER.warning( "Entity %s (%s) with state_class %s has set last_reset. Setting " - "last_reset for entities with state_class other than 'total' is " - "deprecated and will be removed from Home Assistant Core 2021.10. " - "Please update your configuration if state_class is manually " - "configured, otherwise %s", + "last_reset is deprecated and will be unsupported from Home " + "Assistant Core 2021.11. Please update your configuration if " + "state_class is manually configured, otherwise %s", self.entity_id, type(self), self.state_class, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 48f80bab5c2..2cbca09c09d 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -17,7 +17,6 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, STATE_CLASSES, ) @@ -43,7 +42,6 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, State -import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util import homeassistant.util.temperature as temperature_util import homeassistant.util.volume as volume_util @@ -53,11 +51,6 @@ from . import ATTR_LAST_RESET, DOMAIN _LOGGER = logging.getLogger(__name__) DEVICE_CLASS_OR_UNIT_STATISTICS = { - STATE_CLASS_TOTAL: { - DEVICE_CLASS_ENERGY: {"sum"}, - DEVICE_CLASS_GAS: {"sum"}, - DEVICE_CLASS_MONETARY: {"sum"}, - }, STATE_CLASS_MEASUREMENT: { DEVICE_CLASS_BATTERY: {"mean", "min", "max"}, DEVICE_CLASS_HUMIDITY: {"mean", "min", "max"}, @@ -65,7 +58,7 @@ DEVICE_CLASS_OR_UNIT_STATISTICS = { DEVICE_CLASS_PRESSURE: {"mean", "min", "max"}, DEVICE_CLASS_TEMPERATURE: {"mean", "min", "max"}, PERCENTAGE: {"mean", "min", "max"}, - # Deprecated, support will be removed in Home Assistant 2021.10 + # Deprecated, support will be removed in Home Assistant 2021.11 DEVICE_CLASS_ENERGY: {"sum"}, DEVICE_CLASS_GAS: {"sum"}, DEVICE_CLASS_MONETARY: {"sum"}, @@ -73,6 +66,7 @@ DEVICE_CLASS_OR_UNIT_STATISTICS = { STATE_CLASS_TOTAL_INCREASING: { DEVICE_CLASS_ENERGY: {"sum"}, DEVICE_CLASS_GAS: {"sum"}, + DEVICE_CLASS_MONETARY: {"sum"}, }, } @@ -279,13 +273,11 @@ def compile_statistics( stat["mean"] = _time_weighted_average(fstates, start, end) if "sum" in wanted_statistics: - last_reset = old_last_reset = None new_state = old_state = None _sum = 0 last_stats = statistics.get_last_statistics(hass, 1, entity_id) if entity_id in last_stats: # We have compiled history for this sensor before, use that as a starting point - last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] new_state = old_state = last_stats[entity_id][0]["state"] _sum = last_stats[entity_id][0]["sum"] @@ -299,13 +291,7 @@ def compile_statistics( continue reset = False - if ( - state_class != STATE_CLASS_TOTAL_INCREASING - and (last_reset := state.attributes.get("last_reset")) - != old_last_reset - ): - reset = True - elif old_state is None and last_reset is None: + if old_state is None: reset = True elif state_class == STATE_CLASS_TOTAL_INCREASING and ( old_state is None or (new_state is not None and fstate < new_state) @@ -318,21 +304,14 @@ def compile_statistics( _sum += new_state - old_state # ..and update the starting point new_state = fstate - old_last_reset = last_reset - # Force a new cycle for STATE_CLASS_TOTAL_INCREASING to start at 0 - if state_class == STATE_CLASS_TOTAL_INCREASING and old_state: - old_state = 0 + # Force a new cycle to start at 0 + if old_state is not None: + old_state = 0.0 else: old_state = new_state else: new_state = fstate - # Deprecated, will be removed in Home Assistant 2021.10 - if last_reset is None and state_class == STATE_CLASS_MEASUREMENT: - # No valid updates - result.pop(entity_id) - continue - if new_state is None or old_state is None: # No valid updates result.pop(entity_id) @@ -340,8 +319,6 @@ def compile_statistics( # Update the sum with the last state _sum += new_state - old_state - if last_reset is not None: - stat["last_reset"] = dt_util.parse_datetime(last_reset) stat["sum"] = _sum stat["state"] = new_state @@ -365,7 +342,11 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - state = hass.states.get(entity_id) assert state - if "sum" in provided_statistics and ATTR_LAST_RESET not in state.attributes: + if ( + "sum" in provided_statistics + and ATTR_LAST_RESET not in state.attributes + and state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + ): continue native_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 1ff201aaceb..84533efdcf5 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -5,11 +5,7 @@ import logging import voluptuous as vol -from homeassistant.components.sensor import ( - ATTR_LAST_RESET, - STATE_CLASS_MEASUREMENT, - SensorEntity, -) +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, @@ -58,6 +54,7 @@ ATTR_SOURCE_ID = "source" ATTR_STATUS = "status" ATTR_PERIOD = "meter_period" ATTR_LAST_PERIOD = "last_period" +ATTR_LAST_RESET = "last_reset" ATTR_TARIFF = "tariff" DEVICE_CLASS_MAP = { @@ -352,6 +349,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): ATTR_SOURCE_ID: self._sensor_source_id, ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, ATTR_LAST_PERIOD: self._last_period, + ATTR_LAST_RESET: self._last_reset.isoformat(), } if self._period is not None: state_attr[ATTR_PERIOD] = self._period @@ -363,8 +361,3 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): def icon(self): """Return the icon to use in the frontend, if any.""" return ICON - - @property - def last_reset(self): - """Return the time when the sensor was last reset.""" - return self._last_reset diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 1e89c05fbd6..ea183ec52f4 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -7,9 +7,8 @@ import pytest from homeassistant.components.energy import data from homeassistant.components.sensor import ( - ATTR_LAST_RESET, ATTR_STATE_CLASS, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.components.sensor.recorder import compile_statistics from homeassistant.const import ( @@ -131,14 +130,13 @@ async def test_cost_sensor_price_entity( } now = dt_util.utcnow() - last_reset = dt_util.utc_from_timestamp(0).isoformat() # Optionally initialize dependent entities if initial_energy is not None: hass.states.async_set( usage_sensor_entity_id, initial_energy, - {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, ) hass.states.async_set("sensor.energy_price", "1") @@ -148,9 +146,7 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - if initial_cost != "unknown": - assert state.attributes[ATTR_LAST_RESET] == now.isoformat() - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -160,7 +156,6 @@ async def test_cost_sensor_price_entity( usage_sensor_entity_id, "0", { - "last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, ) @@ -169,8 +164,7 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes[ATTR_LAST_RESET] == now.isoformat() - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -182,7 +176,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "10", - {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) @@ -206,7 +200,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "14.5", - {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) @@ -218,32 +212,31 @@ async def test_cost_sensor_price_entity( assert cost_sensor_entity_id in statistics assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 - # Energy sensor is reset, with start point at 4kWh - last_reset = (now + timedelta(seconds=1)).isoformat() + # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point hass.states.async_set( usage_sensor_entity_id, "4", - {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) - assert state.state == "0.0" # 0 EUR + (4-4) kWh * 2 EUR/kWh = 0 EUR + assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR # Energy use bumped to 10 kWh hass.states.async_set( usage_sensor_entity_id, "10", - {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) - assert state.state == "12.0" # 0 EUR + (10-4) kWh * 2 EUR/kWh = 12 EUR + assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR # Check generated statistics await async_wait_recording_done_without_instance(hass) statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) assert cost_sensor_entity_id in statistics - assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 31.0 + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 39.0 async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: @@ -272,12 +265,11 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: } now = dt_util.utcnow() - last_reset = dt_util.utc_from_timestamp(0).isoformat() hass.states.async_set( "sensor.energy_consumption", 10000, - {"last_reset": last_reset, "unit_of_measurement": ENERGY_WATT_HOUR}, + {"unit_of_measurement": ENERGY_WATT_HOUR}, ) with patch("homeassistant.util.dt.utcnow", return_value=now): @@ -290,7 +282,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: hass.states.async_set( "sensor.energy_consumption", 20000, - {"last_reset": last_reset, "unit_of_measurement": ENERGY_WATT_HOUR}, + {"unit_of_measurement": ENERGY_WATT_HOUR}, ) await hass.async_block_till_done() @@ -318,12 +310,11 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: } now = dt_util.utcnow() - last_reset = dt_util.utc_from_timestamp(0).isoformat() hass.states.async_set( "sensor.gas_consumption", 100, - {"last_reset": last_reset, "unit_of_measurement": VOLUME_CUBIC_METERS}, + {"unit_of_measurement": VOLUME_CUBIC_METERS}, ) with patch("homeassistant.util.dt.utcnow", return_value=now): @@ -336,7 +327,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: hass.states.async_set( "sensor.gas_consumption", 200, - {"last_reset": last_reset, "unit_of_measurement": VOLUME_CUBIC_METERS}, + {"unit_of_measurement": VOLUME_CUBIC_METERS}, ) await hass.async_block_till_done() diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 7909d8f0239..8de44843626 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -911,7 +911,6 @@ async def test_statistics_during_period( "mean": approx(value), "min": approx(value), "max": approx(value), - "last_reset": None, "state": None, "sum": None, } diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 0468cc26a23..83995b0c0ac 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -44,7 +44,6 @@ def test_compile_hourly_statistics(hass_recorder): "mean": approx(14.915254237288135), "min": approx(10.0), "max": approx(20.0), - "last_reset": None, "state": None, "sum": None, } @@ -54,7 +53,6 @@ def test_compile_hourly_statistics(hass_recorder): "mean": approx(20.0), "min": approx(20.0), "max": approx(20.0), - "last_reset": None, "state": None, "sum": None, } @@ -127,7 +125,6 @@ def test_rename_entity(hass_recorder): "mean": approx(14.915254237288135), "min": approx(10.0), "max": approx(20.0), - "last_reset": None, "state": None, "sum": None, } diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 793bcaf4f99..5ff2cad9edc 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -44,9 +44,8 @@ async def test_deprecated_last_reset(hass, caplog, enable_custom_integrations): assert ( "Entity sensor.test () " - "with state_class measurement has set last_reset. Setting last_reset for " - "entities with state_class other than 'total' is deprecated and will be " - "removed from Home Assistant Core 2021.10. Please update your configuration if " - "state_class is manually configured, otherwise report it to the custom " - "component author." + "with state_class measurement has set last_reset. Setting last_reset is " + "deprecated and will be unsupported from Home Assistant Core 2021.11. Please " + "update your configuration if state_class is manually configured, otherwise " + "report it to the custom component author." ) in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index d4dee872823..3a2572f8141 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -95,7 +95,6 @@ def test_compile_hourly_statistics( "mean": approx(mean), "min": approx(min), "max": approx(max), - "last_reset": None, "state": None, "sum": None, } @@ -145,7 +144,6 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "mean": approx(16.440677966101696), "min": approx(10.0), "max": approx(30.0), - "last_reset": None, "state": None, "sum": None, } @@ -154,7 +152,6 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes assert "Error while processing event StatisticsTask" not in caplog.text -@pytest.mark.parametrize("state_class", ["measurement", "total"]) @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -167,7 +164,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes ], ) def test_compile_hourly_sum_statistics_amount( - hass_recorder, caplog, state_class, device_class, unit, native_unit, factor + hass_recorder, caplog, device_class, unit, native_unit, factor ): """Test compiling hourly statistics.""" zero = dt_util.utcnow() @@ -176,7 +173,7 @@ def test_compile_hourly_sum_statistics_amount( setup_component(hass, "sensor", {}) attributes = { "device_class": device_class, - "state_class": state_class, + "state_class": "measurement", "unit_of_measurement": unit, "last_reset": None, } @@ -209,7 +206,6 @@ def test_compile_hourly_sum_statistics_amount( "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), }, @@ -219,89 +215,6 @@ def test_compile_hourly_sum_statistics_amount( "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(four), - "state": approx(factor * seq[5]), - "sum": approx(factor * 10.0), - }, - { - "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), - "max": None, - "mean": None, - "min": None, - "last_reset": process_timestamp_to_utc_isoformat(four), - "state": approx(factor * seq[8]), - "sum": approx(factor * 40.0), - }, - ] - } - assert "Error while processing event StatisticsTask" not in caplog.text - - -@pytest.mark.parametrize( - "device_class,unit,native_unit,factor", - [ - ("energy", "kWh", "kWh", 1), - ("energy", "Wh", "kWh", 1 / 1000), - ("monetary", "EUR", "EUR", 1), - ("monetary", "SEK", "SEK", 1), - ("gas", "m³", "m³", 1), - ("gas", "ft³", "m³", 0.0283168466), - ], -) -def test_compile_hourly_sum_statistics_total_no_reset( - hass_recorder, caplog, device_class, unit, native_unit, factor -): - """Test compiling hourly statistics.""" - zero = dt_util.utcnow() - hass = hass_recorder() - recorder = hass.data[DATA_INSTANCE] - setup_component(hass, "sensor", {}) - attributes = { - "device_class": device_class, - "state_class": "total", - "unit_of_measurement": unit, - } - seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] - - four, eight, states = record_meter_states( - hass, zero, "sensor.test1", attributes, seq - ) - hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution - ) - assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - - recorder.do_adhoc_statistics(period="hourly", start=zero) - wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) - wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) - wait_recording_done(hass) - statistic_ids = list_statistic_ids(hass) - assert statistic_ids == [ - {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} - ] - stats = statistics_during_period(hass, zero) - assert stats == { - "sensor.test1": [ - { - "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), - "max": None, - "mean": None, - "min": None, - "last_reset": None, - "state": approx(factor * seq[2]), - "sum": approx(factor * 10.0), - }, - { - "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), - "max": None, - "mean": None, - "min": None, - "last_reset": None, "state": approx(factor * seq[5]), "sum": approx(factor * 30.0), }, @@ -311,7 +224,6 @@ def test_compile_hourly_sum_statistics_total_no_reset( "max": None, "mean": None, "min": None, - "last_reset": None, "state": approx(factor * seq[8]), "sum": approx(factor * 60.0), }, @@ -371,7 +283,6 @@ def test_compile_hourly_sum_statistics_total_increasing( "max": None, "mean": None, "min": None, - "last_reset": None, "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), }, @@ -381,7 +292,6 @@ def test_compile_hourly_sum_statistics_total_increasing( "max": None, "mean": None, "min": None, - "last_reset": None, "state": approx(factor * seq[5]), "sum": approx(factor * 50.0), }, @@ -391,7 +301,6 @@ def test_compile_hourly_sum_statistics_total_increasing( "max": None, "mean": None, "min": None, - "last_reset": None, "state": approx(factor * seq[8]), "sum": approx(factor * 80.0), }, @@ -458,7 +367,6 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(20.0), "sum": approx(10.0), }, @@ -468,9 +376,8 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(10.0), + "sum": approx(30.0), }, { "statistic_id": "sensor.test1", @@ -478,9 +385,8 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(40.0), + "sum": approx(60.0), }, ] } @@ -541,7 +447,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(20.0), "sum": approx(10.0), }, @@ -551,9 +456,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(10.0), + "sum": approx(30.0), }, { "statistic_id": "sensor.test1", @@ -561,9 +465,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(40.0), + "sum": approx(60.0), }, ], "sensor.test2": [ @@ -573,7 +476,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(130.0), "sum": approx(20.0), }, @@ -583,9 +485,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(45.0), - "sum": approx(-95.0), + "sum": approx(-65.0), }, { "statistic_id": "sensor.test2", @@ -593,9 +494,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(75.0), - "sum": approx(-65.0), + "sum": approx(-35.0), }, ], "sensor.test3": [ @@ -605,7 +505,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(5.0 / 1000), "sum": approx(5.0 / 1000), }, @@ -615,9 +514,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(50.0 / 1000), - "sum": approx(30.0 / 1000), + "sum": approx(50.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -625,9 +523,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(90.0 / 1000), - "sum": approx(70.0 / 1000), + "sum": approx(90.0 / 1000), }, ], } @@ -678,7 +575,6 @@ def test_compile_hourly_statistics_unchanged( "mean": approx(value), "min": approx(value), "max": approx(value), - "last_reset": None, "state": None, "sum": None, } @@ -710,7 +606,6 @@ def test_compile_hourly_statistics_partially_unavailable(hass_recorder, caplog): "mean": approx(21.1864406779661), "min": approx(10.0), "max": approx(25.0), - "last_reset": None, "state": None, "sum": None, } @@ -767,7 +662,6 @@ def test_compile_hourly_statistics_unavailable( "mean": approx(value), "min": approx(value), "max": approx(value), - "last_reset": None, "state": None, "sum": None, } From 73d03bdf1d34a40cca52372f109769243ec40190 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 18 Aug 2021 10:14:03 +0200 Subject: [PATCH 474/903] Add Gas device class to DSMR Reader (#54748) --- homeassistant/components/dsmr_reader/definitions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 6edf2972aa4..533b2f0dd38 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CURRENCY_EURO, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_POWER, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, @@ -201,14 +202,14 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/delivered", name="Gas usage", - icon="mdi:fire", + device_class=DEVICE_CLASS_GAS, native_unit_of_measurement=VOLUME_CUBIC_METERS, state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", name="Current gas usage", - icon="mdi:fire", + device_class=DEVICE_CLASS_GAS, native_unit_of_measurement=VOLUME_CUBIC_METERS, state_class=STATE_CLASS_MEASUREMENT, ), From 102af02d8a235d27b3eef9e9893b23e480e6c4c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 18 Aug 2021 11:21:39 +0200 Subject: [PATCH 475/903] Tibber data coordinator (#53619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tibber data coordinator Signed-off-by: Daniel Hjelseth Høyer * Fix comments Signed-off-by: Daniel Hjelseth Høyer * Fix comments Signed-off-by: Daniel Hjelseth Høyer * Fix comments Signed-off-by: Daniel Hjelseth Høyer * Remove whitespace Co-authored-by: Martin Hjelmare --- homeassistant/components/tibber/sensor.py | 199 +++++++++++----------- 1 file changed, 99 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 15bf2a2017e..080da3fca13 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -26,17 +26,15 @@ from homeassistant.const import ( ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, + EVENT_HOMEASSISTANT_STOP, PERCENTAGE, POWER_WATT, SIGNAL_STRENGTH_DECIBELS, ) from homeassistant.core import callback from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import update_coordinator from homeassistant.helpers.device_registry import async_get as async_get_dev_reg -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) from homeassistant.helpers.entity_registry import async_get as async_get_entity_reg from homeassistant.util import Throttle, dt as dt_util @@ -66,40 +64,40 @@ class TibberSensorEntityDescription(SensorEntityDescription): reset_type: ResetType | None = None -RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { - "averagePower": TibberSensorEntityDescription( +RT_SENSORS: tuple[TibberSensorEntityDescription, ...] = ( + TibberSensorEntityDescription( key="averagePower", name="average power", device_class=DEVICE_CLASS_POWER, native_unit_of_measurement=POWER_WATT, ), - "power": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="power", name="power", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=POWER_WATT, ), - "powerProduction": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="powerProduction", name="power production", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=POWER_WATT, ), - "minPower": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="minPower", name="min power", device_class=DEVICE_CLASS_POWER, native_unit_of_measurement=POWER_WATT, ), - "maxPower": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="maxPower", name="max power", device_class=DEVICE_CLASS_POWER, native_unit_of_measurement=POWER_WATT, ), - "accumulatedConsumption": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="accumulatedConsumption", name="accumulated consumption", device_class=DEVICE_CLASS_ENERGY, @@ -107,7 +105,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.DAILY, ), - "accumulatedConsumptionLastHour": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="accumulatedConsumptionLastHour", name="accumulated consumption current hour", device_class=DEVICE_CLASS_ENERGY, @@ -115,7 +113,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.HOURLY, ), - "accumulatedProduction": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="accumulatedProduction", name="accumulated production", device_class=DEVICE_CLASS_ENERGY, @@ -123,7 +121,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.DAILY, ), - "accumulatedProductionLastHour": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="accumulatedProductionLastHour", name="accumulated production current hour", device_class=DEVICE_CLASS_ENERGY, @@ -131,7 +129,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.HOURLY, ), - "lastMeterConsumption": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="lastMeterConsumption", name="last meter consumption", device_class=DEVICE_CLASS_ENERGY, @@ -139,7 +137,7 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.NEVER, ), - "lastMeterProduction": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="lastMeterProduction", name="last meter production", device_class=DEVICE_CLASS_ENERGY, @@ -147,77 +145,77 @@ RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.NEVER, ), - "voltagePhase1": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="voltagePhase1", name="voltage phase1", device_class=DEVICE_CLASS_VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), - "voltagePhase2": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="voltagePhase2", name="voltage phase2", device_class=DEVICE_CLASS_VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), - "voltagePhase3": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="voltagePhase3", name="voltage phase3", device_class=DEVICE_CLASS_VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), - "currentL1": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="currentL1", name="current L1", device_class=DEVICE_CLASS_CURRENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), - "currentL2": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="currentL2", name="current L2", device_class=DEVICE_CLASS_CURRENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), - "currentL3": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="currentL3", name="current L3", device_class=DEVICE_CLASS_CURRENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), - "signalStrength": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="signalStrength", name="signal strength", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=STATE_CLASS_MEASUREMENT, ), - "accumulatedReward": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="accumulatedReward", name="accumulated reward", device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.DAILY, ), - "accumulatedCost": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="accumulatedCost", name="accumulated cost", device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_MEASUREMENT, reset_type=ResetType.DAILY, ), - "powerFactor": TibberSensorEntityDescription( + TibberSensorEntityDescription( key="powerFactor", name="power factor", device_class=DEVICE_CLASS_POWER_FACTOR, native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), -} +) async def async_setup_entry(hass, entry, async_add_entities): @@ -243,7 +241,9 @@ async def async_setup_entry(hass, entry, async_add_entities): entities.append(TibberSensorElPrice(home)) if home.has_real_time_consumption: await home.rt_subscribe( - TibberRtDataHandler(async_add_entities, home, hass).async_callback + TibberRtDataCoordinator( + async_add_entities, home, hass + ).async_set_updated_data ) # migrate @@ -273,27 +273,23 @@ async def async_setup_entry(hass, entry, async_add_entities): class TibberSensor(SensorEntity): """Representation of a generic Tibber sensor.""" - def __init__(self, tibber_home): + def __init__(self, *args, tibber_home, **kwargs): """Initialize the sensor.""" + super().__init__(*args, **kwargs) self._tibber_home = tibber_home self._home_name = tibber_home.info["viewer"]["home"]["appNickname"] - self._device_name = None if self._home_name is None: self._home_name = tibber_home.info["viewer"]["home"]["address"].get( "address1", "" ) + self._device_name = None self._model = None - @property - def device_id(self): - """Return the ID of the physical device this sensor is part of.""" - return self._tibber_home.home_id - @property def device_info(self): """Return the device_info of the device.""" device_info = { - "identifiers": {(TIBBER_DOMAIN, self.device_id)}, + "identifiers": {(TIBBER_DOMAIN, self._tibber_home.home_id)}, "name": self._device_name, "manufacturer": MANUFACTURER, } @@ -307,7 +303,7 @@ class TibberSensorElPrice(TibberSensor): def __init__(self, tibber_home): """Initialize the sensor.""" - super().__init__(tibber_home) + super().__init__(tibber_home=tibber_home) self._last_updated = None self._spread_load_constant = randrange(5000) @@ -377,10 +373,9 @@ class TibberSensorElPrice(TibberSensor): ]["estimatedAnnualConsumption"] -class TibberSensorRT(TibberSensor): +class TibberSensorRT(TibberSensor, update_coordinator.CoordinatorEntity): """Representation of a Tibber sensor for real time consumption.""" - _attr_should_poll = False entity_description: TibberSensorEntityDescription def __init__( @@ -388,9 +383,10 @@ class TibberSensorRT(TibberSensor): tibber_home, description: TibberSensorEntityDescription, initial_state, + coordinator: TibberRtDataCoordinator, ): """Initialize the sensor.""" - super().__init__(tibber_home) + super().__init__(coordinator=coordinator, tibber_home=tibber_home) self.entity_description = description self._model = "Tibber Pulse" self._device_name = f"{self._model} {self._home_name}" @@ -399,7 +395,7 @@ class TibberSensorRT(TibberSensor): self._attr_native_value = initial_state self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.name}" - if description.name in ("accumulated cost", "accumulated reward"): + if description.key in ("accumulatedCost", "accumulatedReward"): self._attr_native_unit_of_measurement = tibber_home.currency if description.reset_type == ResetType.NEVER: self._attr_last_reset = dt_util.utc_from_timestamp(0) @@ -414,43 +410,35 @@ class TibberSensorRT(TibberSensor): else: self._attr_last_reset = None - async def async_added_to_hass(self): - """Start listen for real time data.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_UPDATE_ENTITY.format(self.unique_id), - self._set_state, - ) - ) - @property def available(self): """Return True if entity is available.""" return self._tibber_home.rt_subscription_running @callback - def _set_state(self, state, timestamp): - """Set sensor state.""" - if ( - state < self._attr_native_value - and self.entity_description.reset_type == ResetType.DAILY - ): - self._attr_last_reset = dt_util.as_utc( - timestamp.replace(hour=0, minute=0, second=0, microsecond=0) - ) - if ( - state < self._attr_native_value - and self.entity_description.reset_type == ResetType.HOURLY - ): - self._attr_last_reset = dt_util.as_utc( - timestamp.replace(minute=0, second=0, microsecond=0) - ) + def _handle_coordinator_update(self) -> None: + if not (live_measurement := self.coordinator.get_live_measurement()): # type: ignore[attr-defined] + return + state = live_measurement.get(self.entity_description.key) + if state is None: + return + timestamp = dt_util.parse_datetime(live_measurement["timestamp"]) + if timestamp is not None and state < self.state: + if self.entity_description.reset_type == ResetType.DAILY: + self._attr_last_reset = dt_util.as_utc( + timestamp.replace(hour=0, minute=0, second=0, microsecond=0) + ) + elif self.entity_description.reset_type == ResetType.HOURLY: + self._attr_last_reset = dt_util.as_utc( + timestamp.replace(minute=0, second=0, microsecond=0) + ) + if self.entity_description.key == "powerFactor": + state *= 100.0 self._attr_native_value = state self.async_write_ha_state() -class TibberRtDataHandler: +class TibberRtDataCoordinator(update_coordinator.DataUpdateCoordinator): """Handle Tibber realtime data.""" def __init__(self, async_add_entities, tibber_home, hass): @@ -458,42 +446,53 @@ class TibberRtDataHandler: self._async_add_entities = async_add_entities self._tibber_home = tibber_home self.hass = hass - self._entities = {} + self._added_sensors = set() + super().__init__( + hass, + _LOGGER, + name=tibber_home.info["viewer"]["home"]["address"].get( + "address1", "Tibber" + ), + ) - async def async_callback(self, payload): - """Handle received data.""" - errors = payload.get("errors") - if errors: - _LOGGER.error(errors[0]) - return - data = payload.get("data") - if data is None: - return - live_measurement = data.get("liveMeasurement") - if live_measurement is None: + self._async_remove_device_updates_handler = self.async_add_listener( + self._add_sensors + ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + + @callback + def _handle_ha_stop(self, _event) -> None: + """Handle Home Assistant stopping.""" + self._async_remove_device_updates_handler() + + @callback + def _add_sensors(self): + """Add sensor.""" + if not (live_measurement := self.get_live_measurement()): return - timestamp = dt_util.parse_datetime(live_measurement.pop("timestamp")) new_entities = [] - for sensor_type, state in live_measurement.items(): - if state is None or sensor_type not in RT_SENSOR_MAP: + for sensor_description in RT_SENSORS: + if sensor_description.key in self._added_sensors: continue - if sensor_type == "powerFactor": - state *= 100.0 - if sensor_type in self._entities: - async_dispatcher_send( - self.hass, - SIGNAL_UPDATE_ENTITY.format(self._entities[sensor_type]), - state, - timestamp, - ) - else: - entity = TibberSensorRT( - self._tibber_home, - RT_SENSOR_MAP[sensor_type], - state, - ) - new_entities.append(entity) - self._entities[sensor_type] = entity.unique_id + state = live_measurement.get(sensor_description.key) + if state is None: + continue + entity = TibberSensorRT( + self._tibber_home, + sensor_description, + state, + self, + ) + new_entities.append(entity) + self._added_sensors.add(sensor_description.key) if new_entities: self._async_add_entities(new_entities) + + def get_live_measurement(self): + """Get live measurement data.""" + errors = self.data.get("errors") + if errors: + _LOGGER.error(errors[0]) + return None + return self.data.get("data", {}).get("liveMeasurement") From c937a235e15107deb94787e6910e97718abad7c6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 18 Aug 2021 11:24:15 +0200 Subject: [PATCH 476/903] Add select platform for Xiaomi Miio fans (#54702) * Add select platform for Xiaomi Miio purifiers * Add missing condition for AirFresh * Suggested change * Remove fan_set_led_brightness from services.yaml * Remove zhimi.airpurifier.v3 --- .../components/xiaomi_miio/__init__.py | 2 +- homeassistant/components/xiaomi_miio/const.py | 11 +-- homeassistant/components/xiaomi_miio/fan.py | 65 +--------------- .../components/xiaomi_miio/select.py | 75 +++++++++++++++++-- .../components/xiaomi_miio/services.yaml | 18 ----- 5 files changed, 73 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 122c42c6589..9d854607213 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -46,7 +46,7 @@ _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] SWITCH_PLATFORMS = ["switch"] -FAN_PLATFORMS = ["fan", "sensor"] +FAN_PLATFORMS = ["fan", "select", "sensor"] HUMIDIFIER_PLATFORMS = [ "binary_sensor", "humidifier", diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index de1c0bcf007..184629fa2fb 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -159,10 +159,8 @@ SERVICE_SET_BUZZER_OFF = "fan_set_buzzer_off" SERVICE_SET_FAN_LED_ON = "fan_set_led_on" SERVICE_SET_FAN_LED_OFF = "fan_set_led_off" SERVICE_SET_FAN_LED = "fan_set_led" -SERVICE_SET_LED_BRIGHTNESS = "set_led_brightness" SERVICE_SET_CHILD_LOCK_ON = "fan_set_child_lock_on" SERVICE_SET_CHILD_LOCK_OFF = "fan_set_child_lock_off" -SERVICE_SET_LED_BRIGHTNESS = "fan_set_led_brightness" SERVICE_SET_FAVORITE_LEVEL = "fan_set_favorite_level" SERVICE_SET_FAN_LEVEL = "fan_set_fan_level" SERVICE_SET_AUTO_DETECT_ON = "fan_set_auto_detect_on" @@ -226,7 +224,6 @@ FEATURE_FLAGS_AIRPURIFIER = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED - | FEATURE_SET_LED_BRIGHTNESS | FEATURE_SET_FAVORITE_LEVEL | FEATURE_SET_LEARN_MODE | FEATURE_RESET_FILTER @@ -261,7 +258,6 @@ FEATURE_FLAGS_AIRPURIFIER_3 = ( | FEATURE_SET_LED | FEATURE_SET_FAVORITE_LEVEL | FEATURE_SET_FAN_LEVEL - | FEATURE_SET_LED_BRIGHTNESS ) FEATURE_FLAGS_AIRPURIFIER_V3 = ( @@ -269,10 +265,7 @@ FEATURE_FLAGS_AIRPURIFIER_V3 = ( ) FEATURE_FLAGS_AIRHUMIDIFIER = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED_BRIGHTNESS - | FEATURE_SET_TARGET_HUMIDITY + FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_TARGET_HUMIDITY ) FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB = FEATURE_FLAGS_AIRHUMIDIFIER | FEATURE_SET_DRY @@ -284,7 +277,6 @@ FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ = ( FEATURE_FLAGS_AIRHUMIDIFIER_CA4 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED_BRIGHTNESS | FEATURE_SET_TARGET_HUMIDITY | FEATURE_SET_DRY | FEATURE_SET_MOTOR_SPEED @@ -295,7 +287,6 @@ FEATURE_FLAGS_AIRFRESH = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED - | FEATURE_SET_LED_BRIGHTNESS | FEATURE_RESET_FILTER | FEATURE_SET_EXTRA_FEATURES ) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 5b3418c83f5..35c3765d985 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -4,18 +4,9 @@ from enum import Enum import logging import math -from miio.airfresh import ( - LedBrightness as AirfreshLedBrightness, - OperationMode as AirfreshOperationMode, -) -from miio.airpurifier import ( - LedBrightness as AirpurifierLedBrightness, - OperationMode as AirpurifierOperationMode, -) -from miio.airpurifier_miot import ( - LedBrightness as AirpurifierMiotLedBrightness, - OperationMode as AirpurifierMiotOperationMode, -) +from miio.airfresh import OperationMode as AirfreshOperationMode +from miio.airpurifier import OperationMode as AirpurifierOperationMode +from miio.airpurifier_miot import OperationMode as AirpurifierMiotOperationMode import voluptuous as vol from homeassistant.components.fan import ( @@ -52,7 +43,6 @@ from .const import ( FEATURE_SET_FAVORITE_LEVEL, FEATURE_SET_LEARN_MODE, FEATURE_SET_LED, - FEATURE_SET_LED_BRIGHTNESS, FEATURE_SET_VOLUME, KEY_COORDINATOR, KEY_DEVICE, @@ -77,7 +67,6 @@ from .const import ( SERVICE_SET_FAVORITE_LEVEL, SERVICE_SET_LEARN_MODE_OFF, SERVICE_SET_LEARN_MODE_ON, - SERVICE_SET_LED_BRIGHTNESS, SERVICE_SET_VOLUME, ) from .device import XiaomiCoordinatedMiioEntity @@ -107,7 +96,6 @@ ATTR_FAVORITE_LEVEL = "favorite_level" ATTR_BUZZER = "buzzer" ATTR_CHILD_LOCK = "child_lock" ATTR_LED = "led" -ATTR_LED_BRIGHTNESS = "led_brightness" ATTR_BRIGHTNESS = "brightness" ATTR_LEVEL = "level" ATTR_FAN_LEVEL = "fan_level" @@ -142,7 +130,6 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER = { ATTR_AUTO_DETECT: "auto_detect", ATTR_USE_TIME: "use_time", ATTR_BUZZER: "buzzer", - ATTR_LED_BRIGHTNESS: "led_brightness", ATTR_SLEEP_MODE: "sleep_mode", } @@ -172,7 +159,6 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_3 = { ATTR_LED: "led", ATTR_USE_TIME: "use_time", ATTR_BUZZER: "buzzer", - ATTR_LED_BRIGHTNESS: "led_brightness", ATTR_FAN_LEVEL: "fan_level", } @@ -195,7 +181,6 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { AVAILABLE_ATTRIBUTES_AIRFRESH = { ATTR_MODE: "mode", ATTR_LED: "led", - ATTR_LED_BRIGHTNESS: "led_brightness", ATTR_BUZZER: "buzzer", ATTR_CHILD_LOCK: "child_lock", ATTR_USE_TIME: "use_time", @@ -236,7 +221,6 @@ FEATURE_FLAGS_AIRPURIFIER = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED - | FEATURE_SET_LED_BRIGHTNESS | FEATURE_SET_FAVORITE_LEVEL | FEATURE_SET_LEARN_MODE | FEATURE_RESET_FILTER @@ -271,7 +255,6 @@ FEATURE_FLAGS_AIRPURIFIER_3 = ( | FEATURE_SET_LED | FEATURE_SET_FAVORITE_LEVEL | FEATURE_SET_FAN_LEVEL - | FEATURE_SET_LED_BRIGHTNESS ) FEATURE_FLAGS_AIRPURIFIER_V3 = ( @@ -282,17 +265,12 @@ FEATURE_FLAGS_AIRFRESH = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED - | FEATURE_SET_LED_BRIGHTNESS | FEATURE_RESET_FILTER | FEATURE_SET_EXTRA_FEATURES ) AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) -SERVICE_SCHEMA_LED_BRIGHTNESS = AIRPURIFIER_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_BRIGHTNESS): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=2))} -) - SERVICE_SCHEMA_FAVORITE_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend( {vol.Required(ATTR_LEVEL): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=17))} ) @@ -321,10 +299,6 @@ SERVICE_TO_METHOD = { SERVICE_SET_LEARN_MODE_ON: {"method": "async_set_learn_mode_on"}, SERVICE_SET_LEARN_MODE_OFF: {"method": "async_set_learn_mode_off"}, SERVICE_RESET_FILTER: {"method": "async_reset_filter"}, - SERVICE_SET_LED_BRIGHTNESS: { - "method": "async_set_led_brightness", - "schema": SERVICE_SCHEMA_LED_BRIGHTNESS, - }, SERVICE_SET_FAVORITE_LEVEL: { "method": "async_set_favorite_level", "schema": SERVICE_SCHEMA_FAVORITE_LEVEL, @@ -792,17 +766,6 @@ class XiaomiAirPurifier(XiaomiGenericDevice): False, ) - async def async_set_led_brightness(self, brightness: int = 2): - """Set the led brightness.""" - if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: - return - - await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirpurifierLedBrightness(brightness), - ) - async def async_set_favorite_level(self, level: int = 1): """Set the favorite level.""" if self._device_features & FEATURE_SET_FAVORITE_LEVEL == 0: @@ -987,17 +950,6 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): self._mode = AirpurifierMiotOperationMode[speed.title()].value self.async_write_ha_state() - async def async_set_led_brightness(self, brightness: int = 2): - """Set the led brightness.""" - if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: - return - - await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirpurifierMiotLedBrightness(brightness), - ) - class XiaomiAirFresh(XiaomiGenericDevice): """Representation of a Xiaomi Air Fresh.""" @@ -1134,17 +1086,6 @@ class XiaomiAirFresh(XiaomiGenericDevice): False, ) - async def async_set_led_brightness(self, brightness: int = 2): - """Set the led brightness.""" - if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: - return - - await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirfreshLedBrightness(brightness), - ) - async def async_set_extra_features(self, features: int = 1): """Set the extra features.""" if self._device_features & FEATURE_SET_EXTRA_FEATURES == 0: diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 63fa4e069bf..9cb57e5d3d8 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -4,8 +4,11 @@ from __future__ import annotations from dataclasses import dataclass from enum import Enum +from miio.airfresh import LedBrightness as AirfreshLedBrightness from miio.airhumidifier import LedBrightness as AirhumidifierLedBrightness from miio.airhumidifier_miot import LedBrightness as AirhumidifierMiotLedBrightness +from miio.airpurifier import LedBrightness as AirpurifierLedBrightness +from miio.airpurifier_miot import LedBrightness as AirpurifierMiotLedBrightness from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import callback @@ -18,8 +21,12 @@ from .const import ( FEATURE_SET_LED_BRIGHTNESS, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRFRESH_VA2, + MODEL_AIRPURIFIER_M1, + MODEL_AIRPURIFIER_M2, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, + MODELS_PURIFIER_MIOT, ) from .device import XiaomiCoordinatedMiioEntity @@ -27,10 +34,10 @@ ATTR_LED_BRIGHTNESS = "led_brightness" LED_BRIGHTNESS_MAP = {"Bright": 0, "Dim": 1, "Off": 2} -LED_BRIGHTNESS_MAP_MIOT = {"Bright": 2, "Dim": 1, "Off": 0} +LED_BRIGHTNESS_MAP_HUMIDIFIER_MIOT = {"Bright": 2, "Dim": 1, "Off": 0} LED_BRIGHTNESS_REVERSE_MAP = {val: key for key, val in LED_BRIGHTNESS_MAP.items()} -LED_BRIGHTNESS_REVERSE_MAP_MIOT = { - val: key for key, val in LED_BRIGHTNESS_MAP_MIOT.items() +LED_BRIGHTNESS_REVERSE_MAP_HUMIDIFIER_MIOT = { + val: key for key, val in LED_BRIGHTNESS_MAP_HUMIDIFIER_MIOT.items() } @@ -65,6 +72,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entity_class = XiaomiAirHumidifierSelector elif model in MODELS_HUMIDIFIER_MIOT: entity_class = XiaomiAirHumidifierMiotSelector + elif model in [MODEL_AIRPURIFIER_M1, MODEL_AIRPURIFIER_M2]: + entity_class = XiaomiAirPurifierSelector + elif model in MODELS_PURIFIER_MIOT: + entity_class = XiaomiAirPurifierMiotSelector + elif model == MODEL_AIRFRESH_VA2: + entity_class = XiaomiAirFreshSelector else: return @@ -150,14 +163,62 @@ class XiaomiAirHumidifierMiotSelector(XiaomiAirHumidifierSelector): @property def led_brightness(self): """Return the current led brightness.""" - return LED_BRIGHTNESS_REVERSE_MAP_MIOT.get(self._current_led_brightness) + return LED_BRIGHTNESS_REVERSE_MAP_HUMIDIFIER_MIOT.get( + self._current_led_brightness + ) - async def async_set_led_brightness(self, brightness: str): + async def async_set_led_brightness(self, brightness: str) -> None: """Set the led brightness.""" if await self._try_command( "Setting the led brightness of the miio device failed.", self._device.set_led_brightness, - AirhumidifierMiotLedBrightness(LED_BRIGHTNESS_MAP_MIOT[brightness]), + AirhumidifierMiotLedBrightness( + LED_BRIGHTNESS_MAP_HUMIDIFIER_MIOT[brightness] + ), ): - self._current_led_brightness = LED_BRIGHTNESS_MAP_MIOT[brightness] + self._current_led_brightness = LED_BRIGHTNESS_MAP_HUMIDIFIER_MIOT[ + brightness + ] + self.async_write_ha_state() + + +class XiaomiAirPurifierSelector(XiaomiAirHumidifierSelector): + """Representation of a Xiaomi Air Purifier (MIIO protocol) selector.""" + + async def async_set_led_brightness(self, brightness: str) -> None: + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + AirpurifierLedBrightness(LED_BRIGHTNESS_MAP[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] + self.async_write_ha_state() + + +class XiaomiAirPurifierMiotSelector(XiaomiAirHumidifierSelector): + """Representation of a Xiaomi Air Purifier (MiOT protocol) selector.""" + + async def async_set_led_brightness(self, brightness: str) -> None: + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + AirpurifierMiotLedBrightness(LED_BRIGHTNESS_MAP[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] + self.async_write_ha_state() + + +class XiaomiAirFreshSelector(XiaomiAirHumidifierSelector): + """Representation of a Xiaomi Air Fresh selector.""" + + async def async_set_led_brightness(self, brightness: str) -> None: + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + AirfreshLedBrightness(LED_BRIGHTNESS_MAP[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/services.yaml b/homeassistant/components/xiaomi_miio/services.yaml index 4c153292d7e..43300f8381a 100644 --- a/homeassistant/components/xiaomi_miio/services.yaml +++ b/homeassistant/components/xiaomi_miio/services.yaml @@ -101,24 +101,6 @@ fan_set_fan_level: min: 1 max: 3 -fan_set_led_brightness: - name: Fan set LED brightness - description: Set the led brightness. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - brightness: - description: Brightness (0 = Bright, 1 = Dim, 2 = Off) - required: true - selector: - number: - min: 0 - max: 2 - fan_set_auto_detect_on: name: Fan set auto detect on description: Turn the auto detect on. From 62015f5495683d8daa3bf0dab74101788cdad13e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Aug 2021 05:13:59 -0500 Subject: [PATCH 477/903] Bump async-upnp-client to 0.20.0, adapt to breaking changes (#54782) --- .../components/dlna_dmr/manifest.json | 2 +- homeassistant/components/ssdp/__init__.py | 26 ++++++------------- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ssdp/test_init.py | 6 ++--- 8 files changed, 17 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 1975128a8cc..67d9713628a 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dlna_dmr", "name": "DLNA Digital Media Renderer", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.19.2"], + "requirements": ["async-upnp-client==0.20.0"], "dependencies": ["network"], "codeowners": [], "iot_class": "local_push" diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 96bf47d920d..4d21fdb6aab 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -223,25 +223,17 @@ class Scanner: return sources - @core_callback - def async_scan(self, *_: Any) -> None: - """Scan for new entries.""" + async def async_scan(self, *_: Any) -> None: + """Scan for new entries using ssdp default and broadcast target.""" for listener in self._ssdp_listeners: listener.async_search() - - self.async_scan_broadcast() - - @core_callback - def async_scan_broadcast(self, *_: Any) -> None: - """Scan for new entries using broadcast target.""" - # Some sonos devices only seem to respond if we send to the broadcast - # address. This matches pysonos' behavior - # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 - for listener in self._ssdp_listeners: try: IPv4Address(listener.source_ip) except ValueError: continue + # Some sonos devices only seem to respond if we send to the broadcast + # address. This matches pysonos' behavior + # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 listener.async_search((str(IPV4_BROADCAST), SSDP_PORT)) async def async_start(self) -> None: @@ -251,7 +243,9 @@ class Scanner: for source_ip in await self._async_build_source_set(): self._ssdp_listeners.append( SSDPListener( - async_callback=self._async_process_entry, source_ip=source_ip + async_connect_callback=self.async_scan, + async_callback=self._async_process_entry, + source_ip=source_ip, ) ) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) @@ -277,10 +271,6 @@ class Scanner: self.hass, self.async_scan, SCAN_INTERVAL ) - # Trigger a broadcast-scan. Regular scan is implicitly triggered - # by SSDPListener. - self.async_scan_broadcast() - @core_callback def _async_get_matching_callbacks( self, headers: Mapping[str, str] diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index ef4b92b4a14..746e90c7388 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ssdp", "requirements": [ "defusedxml==0.7.1", - "async-upnp-client==0.19.2" + "async-upnp-client==0.20.0" ], "dependencies": ["network"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index fc8ba185d3c..5f38a827ec7 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.19.2"], + "requirements": ["async-upnp-client==0.20.0"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3729b393470..5a0ecee3512 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.19.2 +async-upnp-client==0.20.0 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.4.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0c2674bcb9a..a42251e3882 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -314,7 +314,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp -async-upnp-client==0.19.2 +async-upnp-client==0.20.0 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4575c0c82ab..a31c4616da9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -205,7 +205,7 @@ arcam-fmj==0.7.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp -async-upnp-client==0.19.2 +async-upnp-client==0.20.0 # homeassistant.components.aurora auroranoaa==0.0.2 diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 94cf8a58908..2c5dc74db44 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -305,8 +305,8 @@ async def test_start_stop_scanner( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() assert async_start_mock.call_count == 1 - # Next is 3, as async_upnp_client triggers 1 SSDPListener._async_on_connect - assert async_search_mock.call_count == 3 + # Next is 2, as async_upnp_client triggers 1 SSDPListener._async_on_connect + assert async_search_mock.call_count == 2 assert async_stop_mock.call_count == 0 hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) @@ -314,7 +314,7 @@ async def test_start_stop_scanner( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() assert async_start_mock.call_count == 1 - assert async_search_mock.call_count == 3 + assert async_search_mock.call_count == 2 assert async_stop_mock.call_count == 1 From cbff6a603d15559eb186c7f4cc4fd32c90799b97 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 12:15:01 +0200 Subject: [PATCH 478/903] Remove unused last_reset from Toon (#54798) --- homeassistant/components/toon/sensor.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 8d298c4a865..4522e34943c 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -1,11 +1,7 @@ """Support for Toon sensors.""" from __future__ import annotations -from homeassistant.components.sensor import ( - ATTR_LAST_RESET, - ATTR_STATE_CLASS, - SensorEntity, -) +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -127,7 +123,6 @@ class ToonSensor(ToonEntity, SensorEntity): ATTR_DEFAULT_ENABLED, True ) self._attr_icon = sensor.get(ATTR_ICON) - self._attr_last_reset = sensor.get(ATTR_LAST_RESET) self._attr_name = sensor[ATTR_NAME] self._attr_state_class = sensor.get(ATTR_STATE_CLASS) self._attr_native_unit_of_measurement = sensor[ATTR_UNIT_OF_MEASUREMENT] From d1057a70048499c0bc1eec98fbc7b63e646cb218 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 12:17:25 +0200 Subject: [PATCH 479/903] Remove last_reset and update state class for Atome energy (#54801) --- homeassistant/components/atome/sensor.py | 37 +++--------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py index 498e760924a..59d193ec8e2 100644 --- a/homeassistant/components/atome/sensor.py +++ b/homeassistant/components/atome/sensor.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -20,7 +21,7 @@ from homeassistant.const import ( POWER_WATT, ) import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -88,16 +89,12 @@ class AtomeData: self._is_connected = None self._day_usage = None self._day_price = None - self._day_last_reset = None self._week_usage = None self._week_price = None - self._week_last_reset = None self._month_usage = None self._month_price = None - self._month_last_reset = None self._year_usage = None self._year_price = None - self._year_last_reset = None @property def live_power(self): @@ -142,11 +139,6 @@ class AtomeData: """Return latest daily usage value.""" return self._day_price - @property - def day_last_reset(self): - """Return latest daily last reset.""" - return self._day_last_reset - @Throttle(DAILY_SCAN_INTERVAL) def update_day_usage(self): """Return current daily power usage.""" @@ -154,7 +146,6 @@ class AtomeData: values = self.atome_client.get_consumption(DAILY_TYPE) self._day_usage = values["total"] / 1000 self._day_price = values["price"] - self._day_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome daily data. Got: %d", self._day_usage) except KeyError as error: @@ -170,11 +161,6 @@ class AtomeData: """Return latest weekly usage value.""" return self._week_price - @property - def week_last_reset(self): - """Return latest weekly last reset value.""" - return self._week_last_reset - @Throttle(WEEKLY_SCAN_INTERVAL) def update_week_usage(self): """Return current weekly power usage.""" @@ -182,7 +168,6 @@ class AtomeData: values = self.atome_client.get_consumption(WEEKLY_TYPE) self._week_usage = values["total"] / 1000 self._week_price = values["price"] - self._week_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome weekly data. Got: %d", self._week_usage) except KeyError as error: @@ -198,11 +183,6 @@ class AtomeData: """Return latest monthly usage value.""" return self._month_price - @property - def month_last_reset(self): - """Return latest monthly last reset value.""" - return self._month_last_reset - @Throttle(MONTHLY_SCAN_INTERVAL) def update_month_usage(self): """Return current monthly power usage.""" @@ -210,7 +190,6 @@ class AtomeData: values = self.atome_client.get_consumption(MONTHLY_TYPE) self._month_usage = values["total"] / 1000 self._month_price = values["price"] - self._month_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome monthly data. Got: %d", self._month_usage) except KeyError as error: @@ -226,11 +205,6 @@ class AtomeData: """Return latest yearly usage value.""" return self._year_price - @property - def year_last_reset(self): - """Return latest yearly last reset value.""" - return self._year_last_reset - @Throttle(YEARLY_SCAN_INTERVAL) def update_year_usage(self): """Return current yearly power usage.""" @@ -238,7 +212,6 @@ class AtomeData: values = self.atome_client.get_consumption(YEARLY_TYPE) self._year_usage = values["total"] / 1000 self._year_price = values["price"] - self._year_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome yearly data. Got: %d", self._year_usage) except KeyError as error: @@ -254,14 +227,15 @@ class AtomeSensor(SensorEntity): self._data = data self._sensor_type = sensor_type - self._attr_state_class = STATE_CLASS_MEASUREMENT if sensor_type == LIVE_TYPE: self._attr_device_class = DEVICE_CLASS_POWER self._attr_native_unit_of_measurement = POWER_WATT + self._attr_state_class = STATE_CLASS_MEASUREMENT else: self._attr_device_class = DEVICE_CLASS_ENERGY self._attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING def update(self): """Update device state.""" @@ -276,9 +250,6 @@ class AtomeSensor(SensorEntity): } else: self._attr_native_value = getattr(self._data, f"{self._sensor_type}_usage") - self._attr_last_reset = dt_util.as_utc( - getattr(self._data, f"{self._sensor_type}_last_reset") - ) self._attr_extra_state_attributes = { "price": getattr(self._data, f"{self._sensor_type}_price") } From bf494b569757fb73c06e31b8ea3f072ea6b3b725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 18 Aug 2021 12:31:43 +0200 Subject: [PATCH 480/903] Remove distro from updater requirements (#54804) --- homeassistant/components/updater/manifest.json | 1 - homeassistant/package_constraints.txt | 1 - requirements_all.txt | 3 --- requirements_test_all.txt | 3 --- 4 files changed, 8 deletions(-) diff --git a/homeassistant/components/updater/manifest.json b/homeassistant/components/updater/manifest.json index 9996d2bb1f0..db225bbf242 100644 --- a/homeassistant/components/updater/manifest.json +++ b/homeassistant/components/updater/manifest.json @@ -2,7 +2,6 @@ "domain": "updater", "name": "Updater", "documentation": "https://www.home-assistant.io/integrations/updater", - "requirements": ["distro==1.5.0"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "cloud_polling" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5a0ecee3512..213503a92c5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -14,7 +14,6 @@ certifi>=2020.12.5 ciso8601==2.1.3 cryptography==3.3.2 defusedxml==0.7.1 -distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.46.0 home-assistant-frontend==20210813.0 diff --git a/requirements_all.txt b/requirements_all.txt index a42251e3882..0eb8046c327 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -521,9 +521,6 @@ discogs_client==2.3.0 # homeassistant.components.discord discord.py==1.7.2 -# homeassistant.components.updater -distro==1.5.0 - # homeassistant.components.digitalloggers dlipower==0.7.165 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a31c4616da9..bd478a18538 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -302,9 +302,6 @@ devolo-home-control-api==0.17.4 # homeassistant.components.directv directv==0.4.0 -# homeassistant.components.updater -distro==1.5.0 - # homeassistant.components.doorbird doorbirdpy==2.1.0 From 16cb50bddff9788671df4514ad98d634d820e0ee Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Aug 2021 12:44:35 +0200 Subject: [PATCH 481/903] Ensure device entry in Renault integration (#54797) * Ensure device registry is set even when there are no entities * Fix isort * Use async_get for accessing registry --- .../components/renault/renault_hub.py | 25 ++++++++++- tests/components/renault/__init__.py | 22 +++++++++ tests/components/renault/test_sensor.py | 45 ++++++------------- 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 07770ad3769..b7a9b40e2c9 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -11,7 +11,15 @@ 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, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL @@ -49,11 +57,16 @@ class RenaultHub: self._account = await self._client.get_api_account(account_id) vehicles = await self._account.get_vehicles() + device_registry = dr.async_get(self._hass) if vehicles.vehicleLinks: await asyncio.gather( *( self.async_initialise_vehicle( - vehicle_link, self._account, scan_interval + vehicle_link, + self._account, + scan_interval, + config_entry, + device_registry, ) for vehicle_link in vehicles.vehicleLinks ) @@ -64,6 +77,8 @@ class RenaultHub: vehicle_link: KamereonVehiclesLink, renault_account: RenaultAccount, scan_interval: timedelta, + config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Set up proxy.""" assert vehicle_link.vin is not None @@ -76,6 +91,14 @@ class RenaultHub: scan_interval=scan_interval, ) await vehicle.async_initialise() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers=vehicle.device_info[ATTR_IDENTIFIERS], + manufacturer=vehicle.device_info[ATTR_MANUFACTURER], + name=vehicle.device_info[ATTR_NAME], + model=vehicle.device_info[ATTR_MODEL], + sw_version=vehicle.device_info[ATTR_SW_VERSION], + ) self._vehicles[vehicle_link.vin] = vehicle async def get_account_ids(self) -> list[str]: diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index da72da05d5d..9191851c777 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -9,8 +9,16 @@ from renault_api.renault_account import RenaultAccount from homeassistant.components.renault.const import DOMAIN from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import DeviceRegistry from .const import MOCK_CONFIG, MOCK_VEHICLES @@ -218,3 +226,17 @@ async def setup_renault_integration_vehicle_with_side_effect( await hass.async_block_till_done() return config_entry + + +def check_device_registry( + device_registry: DeviceRegistry, expected_device: dict[str, Any] +) -> None: + """Ensure that the expected_device is correctly registered.""" + assert len(device_registry.devices) == 1 + registry_entry = device_registry.async_get_device(expected_device[ATTR_IDENTIFIERS]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device[ATTR_IDENTIFIERS] + assert registry_entry.manufacturer == expected_device[ATTR_MANUFACTURER] + assert registry_entry.name == expected_device[ATTR_NAME] + assert registry_entry.model == expected_device[ATTR_MODEL] + assert registry_entry.sw_version == expected_device[ATTR_SW_VERSION] diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 42a75012b38..41fceccb56c 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.setup import async_setup_component from . import ( + check_device_registry, setup_renault_integration_vehicle, setup_renault_integration_vehicle_with_no_data, setup_renault_integration_vehicle_with_side_effect, @@ -30,15 +31,7 @@ async def test_sensors(hass, vehicle_type): await hass.async_block_till_done() mock_vehicle = MOCK_VEHICLES[vehicle_type] - assert len(device_registry.devices) == 1 - expected_device = mock_vehicle["expected_device"] - registry_entry = device_registry.async_get_device(expected_device["identifiers"]) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device["identifiers"] - assert registry_entry.manufacturer == expected_device["manufacturer"] - assert registry_entry.name == expected_device["name"] - assert registry_entry.model == expected_device["model"] - assert registry_entry.sw_version == expected_device["sw_version"] + check_device_registry(device_registry, mock_vehicle["expected_device"]) expected_entities = mock_vehicle[SENSOR_DOMAIN] assert len(entity_registry.entities) == len(expected_entities) @@ -65,15 +58,7 @@ async def test_sensor_empty(hass, vehicle_type): await hass.async_block_till_done() mock_vehicle = MOCK_VEHICLES[vehicle_type] - assert len(device_registry.devices) == 1 - expected_device = mock_vehicle["expected_device"] - registry_entry = device_registry.async_get_device(expected_device["identifiers"]) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device["identifiers"] - assert registry_entry.manufacturer == expected_device["manufacturer"] - assert registry_entry.name == expected_device["name"] - assert registry_entry.model == expected_device["model"] - assert registry_entry.sw_version == expected_device["sw_version"] + check_device_registry(device_registry, mock_vehicle["expected_device"]) expected_entities = mock_vehicle[SENSOR_DOMAIN] assert len(entity_registry.entities) == len(expected_entities) @@ -107,15 +92,7 @@ async def test_sensor_errors(hass, vehicle_type): await hass.async_block_till_done() mock_vehicle = MOCK_VEHICLES[vehicle_type] - assert len(device_registry.devices) == 1 - expected_device = mock_vehicle["expected_device"] - registry_entry = device_registry.async_get_device(expected_device["identifiers"]) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device["identifiers"] - assert registry_entry.manufacturer == expected_device["manufacturer"] - assert registry_entry.name == expected_device["name"] - assert registry_entry.model == expected_device["model"] - assert registry_entry.sw_version == expected_device["sw_version"] + check_device_registry(device_registry, mock_vehicle["expected_device"]) expected_entities = mock_vehicle[SENSOR_DOMAIN] assert len(entity_registry.entities) == len(expected_entities) @@ -136,6 +113,7 @@ async def test_sensor_access_denied(hass): entity_registry = mock_registry(hass) device_registry = mock_device_registry(hass) + vehicle_type = "zoe_40" access_denied_exception = exceptions.AccessDeniedException( "err.func.403", "Access is denied for this resource", @@ -143,11 +121,13 @@ async def test_sensor_access_denied(hass): with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): await setup_renault_integration_vehicle_with_side_effect( - hass, "zoe_40", access_denied_exception + hass, vehicle_type, access_denied_exception ) await hass.async_block_till_done() - assert len(device_registry.devices) == 0 + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + assert len(entity_registry.entities) == 0 @@ -157,6 +137,7 @@ async def test_sensor_not_supported(hass): entity_registry = mock_registry(hass) device_registry = mock_device_registry(hass) + vehicle_type = "zoe_40" not_supported_exception = exceptions.NotSupportedException( "err.tech.501", "This feature is not technically supported by this gateway", @@ -164,9 +145,11 @@ async def test_sensor_not_supported(hass): with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): await setup_renault_integration_vehicle_with_side_effect( - hass, "zoe_40", not_supported_exception + hass, vehicle_type, not_supported_exception ) await hass.async_block_till_done() - assert len(device_registry.devices) == 0 + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + assert len(entity_registry.entities) == 0 From bafbbc6563e674259865c496504dcb126dd90363 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 18 Aug 2021 12:56:54 +0200 Subject: [PATCH 482/903] Adjust modbus constants names (#54792) * Follow up. --- homeassistant/components/modbus/__init__.py | 12 +++--- homeassistant/components/modbus/const.py | 9 +++-- homeassistant/components/modbus/modbus.py | 22 +++++------ tests/components/modbus/conftest.py | 6 +-- tests/components/modbus/test_fan.py | 4 +- tests/components/modbus/test_init.py | 44 ++++++++++----------- tests/components/modbus/test_light.py | 4 +- tests/components/modbus/test_switch.py | 6 +-- 8 files changed, 54 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 8e7d1e48e1a..12e2273bf88 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -73,9 +73,7 @@ from .const import ( CONF_RETRIES, CONF_RETRY_ON_EMPTY, CONF_REVERSE_ORDER, - CONF_RTUOVERTCP, CONF_SCALE, - CONF_SERIAL, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OFF, @@ -92,8 +90,6 @@ from .const import ( CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, - CONF_TCP, - CONF_UDP, CONF_VERIFY, CONF_WRITE_TYPE, DATA_TYPE_CUSTOM, @@ -114,6 +110,10 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_TEMP_UNIT, MODBUS_DOMAIN as DOMAIN, + RTUOVERTCP, + SERIAL, + TCP, + UDP, ) from .modbus import ModbusHub, async_modbus_setup from .validators import number_validator, scan_interval_validator, struct_validator @@ -304,7 +304,7 @@ MODBUS_SCHEMA = vol.Schema( SERIAL_SCHEMA = MODBUS_SCHEMA.extend( { - vol.Required(CONF_TYPE): CONF_SERIAL, + vol.Required(CONF_TYPE): SERIAL, vol.Required(CONF_BAUDRATE): cv.positive_int, vol.Required(CONF_BYTESIZE): vol.Any(5, 6, 7, 8), vol.Required(CONF_METHOD): vol.Any("rtu", "ascii"), @@ -318,7 +318,7 @@ ETHERNET_SCHEMA = MODBUS_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port, - vol.Required(CONF_TYPE): vol.Any(CONF_TCP, CONF_UDP, CONF_RTUOVERTCP), + vol.Required(CONF_TYPE): vol.Any(TCP, UDP, RTUOVERTCP), } ) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index ef6d7c3fc32..01e0fdd5e13 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -39,9 +39,7 @@ CONF_RETRIES = "retries" CONF_RETRY_ON_EMPTY = "retry_on_empty" CONF_REVERSE_ORDER = "reverse_order" CONF_PRECISION = "precision" -CONF_RTUOVERTCP = "rtuovertcp" CONF_SCALE = "scale" -CONF_SERIAL = "serial" CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OFF = "state_off" @@ -58,13 +56,16 @@ CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" -CONF_TCP = "tcp" -CONF_UDP = "udp" CONF_VERIFY = "verify" CONF_VERIFY_REGISTER = "verify_register" CONF_VERIFY_STATE = "verify_state" CONF_WRITE_TYPE = "write_type" +RTUOVERTCP = "rtuovertcp" +SERIAL = "serial" +TCP = "tcp" +UDP = "udp" + # service call attributes ATTR_ADDRESS = "address" ATTR_HUB = "hub" diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index e2f1295220f..7cab51f7fe6 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -45,16 +45,16 @@ from .const import ( CONF_PARITY, CONF_RETRIES, CONF_RETRY_ON_EMPTY, - CONF_RTUOVERTCP, - CONF_SERIAL, CONF_STOPBITS, - CONF_TCP, - CONF_UDP, DEFAULT_HUB, MODBUS_DOMAIN as DOMAIN, PLATFORMS, + RTUOVERTCP, + SERIAL, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, + TCP, + UDP, ) _LOGGER = logging.getLogger(__name__) @@ -203,10 +203,10 @@ class ModbusHub: self._config_delay = client_config[CONF_DELAY] self._pb_call = {} self._pb_class = { - CONF_SERIAL: ModbusSerialClient, - CONF_TCP: ModbusTcpClient, - CONF_UDP: ModbusUdpClient, - CONF_RTUOVERTCP: ModbusTcpClient, + SERIAL: ModbusSerialClient, + TCP: ModbusTcpClient, + UDP: ModbusUdpClient, + RTUOVERTCP: ModbusTcpClient, } self._pb_params = { "port": client_config[CONF_PORT], @@ -215,7 +215,7 @@ class ModbusHub: "retries": client_config[CONF_RETRIES], "retry_on_empty": client_config[CONF_RETRY_ON_EMPTY], } - if self._config_type == CONF_SERIAL: + if self._config_type == SERIAL: # serial configuration self._pb_params.update( { @@ -229,13 +229,13 @@ class ModbusHub: else: # network configuration self._pb_params["host"] = client_config[CONF_HOST] - if self._config_type == CONF_RTUOVERTCP: + if self._config_type == RTUOVERTCP: self._pb_params["framer"] = ModbusRtuFramer Defaults.Timeout = client_config[CONF_TIMEOUT] if CONF_MSG_WAIT in client_config: self._msg_wait = client_config[CONF_MSG_WAIT] / 1000 - elif self._config_type == CONF_SERIAL: + elif self._config_type == SERIAL: self._msg_wait = 30 / 1000 else: self._msg_wait = 0 diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 4f2c9b2b778..35688d2f608 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -8,9 +8,9 @@ from pymodbus.exceptions import ModbusException import pytest from homeassistant.components.modbus.const import ( - CONF_TCP, DEFAULT_HUB, MODBUS_DOMAIN as DOMAIN, + TCP, ) from homeassistant.const import ( CONF_HOST, @@ -71,7 +71,7 @@ async def mock_modbus(hass, caplog, request, do_config): config = { DOMAIN: [ { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, @@ -131,7 +131,7 @@ async def base_test( config_modbus = { DOMAIN: { CONF_NAME: DEFAULT_HUB, - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, }, diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 4aa55473737..fb65f737d27 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -12,10 +12,10 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_STATE_OFF, CONF_STATE_ON, - CONF_TCP, CONF_VERIFY, CONF_WRITE_TYPE, MODBUS_DOMAIN, + TCP, ) from homeassistant.const import ( CONF_ADDRESS, @@ -219,7 +219,7 @@ async def test_fan_service_turn(hass, caplog, mock_pymodbus): ENTITY_ID2 = f"{FAN_DOMAIN}.{TEST_ENTITY_NAME}2" config = { MODBUS_DOMAIN: { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_FANS: [ diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 9400dd56641..3eb1beb460f 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -42,21 +42,21 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, - CONF_RTUOVERTCP, - CONF_SERIAL, CONF_STOPBITS, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_WORD, - CONF_TCP, - CONF_UDP, DATA_TYPE_CUSTOM, DATA_TYPE_INT, DATA_TYPE_STRING, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, + RTUOVERTCP, + SERIAL, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, + TCP, + UDP, ) from homeassistant.components.modbus.validators import ( number_validator, @@ -206,12 +206,12 @@ async def test_exception_struct_validator(do_config): "do_config", [ { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, }, { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, @@ -219,12 +219,12 @@ async def test_exception_struct_validator(do_config): CONF_DELAY: 10, }, { - CONF_TYPE: CONF_UDP, + CONF_TYPE: UDP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, }, { - CONF_TYPE: CONF_UDP, + CONF_TYPE: UDP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, @@ -232,12 +232,12 @@ async def test_exception_struct_validator(do_config): CONF_DELAY: 10, }, { - CONF_TYPE: CONF_RTUOVERTCP, + CONF_TYPE: RTUOVERTCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, }, { - CONF_TYPE: CONF_RTUOVERTCP, + CONF_TYPE: RTUOVERTCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, @@ -245,7 +245,7 @@ async def test_exception_struct_validator(do_config): CONF_DELAY: 10, }, { - CONF_TYPE: CONF_SERIAL, + CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", @@ -255,7 +255,7 @@ async def test_exception_struct_validator(do_config): CONF_MSG_WAIT: 100, }, { - CONF_TYPE: CONF_SERIAL, + CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", @@ -267,26 +267,26 @@ async def test_exception_struct_validator(do_config): CONF_DELAY: 10, }, { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_DELAY: 5, }, [ { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, }, { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_NAME: f"{TEST_MODBUS_NAME}2", }, { - CONF_TYPE: CONF_SERIAL, + CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", @@ -298,7 +298,7 @@ async def test_exception_struct_validator(do_config): ], { # Special test for scan_interval validator with scan_interval: 0 - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_SENSORS: [ @@ -326,7 +326,7 @@ SERVICE = "service" [ { CONF_NAME: TEST_MODBUS_NAME, - CONF_TYPE: CONF_SERIAL, + CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", @@ -431,7 +431,7 @@ async def mock_modbus_read_pymodbus( config = { DOMAIN: [ { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, @@ -505,7 +505,7 @@ async def test_pymodbus_constructor_fail(hass, caplog): config = { DOMAIN: [ { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, } @@ -528,7 +528,7 @@ async def test_pymodbus_close_fail(hass, caplog, mock_pymodbus): config = { DOMAIN: [ { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, } @@ -553,7 +553,7 @@ async def test_delay(hass, mock_pymodbus): config = { DOMAIN: [ { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 49bfed3e19a..f679883e908 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -11,10 +11,10 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_STATE_OFF, CONF_STATE_ON, - CONF_TCP, CONF_VERIFY, CONF_WRITE_TYPE, MODBUS_DOMAIN, + TCP, ) from homeassistant.const import ( CONF_ADDRESS, @@ -219,7 +219,7 @@ async def test_light_service_turn(hass, caplog, mock_pymodbus): ENTITY_ID2 = f"{ENTITY_ID}2" config = { MODBUS_DOMAIN: { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_LIGHTS: [ diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 3838e7a95d5..302189001c5 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -13,10 +13,10 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_STATE_OFF, CONF_STATE_ON, - CONF_TCP, CONF_VERIFY, CONF_WRITE_TYPE, MODBUS_DOMAIN, + TCP, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( @@ -233,7 +233,7 @@ async def test_switch_service_turn(hass, caplog, mock_pymodbus): ENTITY_ID2 = f"{SWITCH_DOMAIN}.{TEST_ENTITY_NAME}2" config = { MODBUS_DOMAIN: { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_SWITCHES: [ @@ -330,7 +330,7 @@ async def test_delay_switch(hass, mock_pymodbus): config = { MODBUS_DOMAIN: [ { - CONF_TYPE: CONF_TCP, + CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_SWITCHES: [ From 1280a38e0f827534bd50fffc2bdfbdfff42575d2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 13:12:37 +0200 Subject: [PATCH 483/903] Remove last_reset attribute from fritz sensors (#54806) --- homeassistant/components/fritz/sensor.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index cbbaa40aaa6..e3d366e83fd 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -8,7 +8,11 @@ from typing import Callable, TypedDict from fritzconnection.core.exceptions import FritzConnectionException from fritzconnection.lib.fritzstatus import FritzStatus -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DATA_GIGABYTES, @@ -134,7 +138,6 @@ class SensorData(TypedDict, total=False): name: str device_class: str | None state_class: str | None - last_reset: bool unit_of_measurement: str | None icon: str | None state_provider: Callable @@ -185,16 +188,14 @@ SENSOR_DATA = { ), "gb_sent": SensorData( name="GB sent", - state_class=STATE_CLASS_MEASUREMENT, - last_reset=True, + state_class=STATE_CLASS_TOTAL_INCREASING, unit_of_measurement=DATA_GIGABYTES, icon="mdi:upload", state_provider=_retrieve_gb_sent_state, ), "gb_received": SensorData( name="GB received", - state_class=STATE_CLASS_MEASUREMENT, - last_reset=True, + state_class=STATE_CLASS_TOTAL_INCREASING, unit_of_measurement=DATA_GIGABYTES, icon="mdi:download", state_provider=_retrieve_gb_received_state, @@ -284,7 +285,6 @@ class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): """Init FRITZ!Box connectivity class.""" self._sensor_data: SensorData = SENSOR_DATA[sensor_type] self._last_device_value: str | None = None - self._last_wan_value: str | None = None self._attr_available = True self._attr_device_class = self._sensor_data.get("device_class") self._attr_icon = self._sensor_data.get("icon") @@ -316,12 +316,3 @@ class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): self._attr_native_value = self._last_device_value = self._state_provider( status, self._last_device_value ) - - if self._sensor_data.get("last_reset") is True: - self._last_wan_value = _retrieve_connection_uptime_state( - status, self._last_wan_value - ) - self._attr_last_reset = datetime.datetime.strptime( - self._last_wan_value, - "%Y-%m-%dT%H:%M:%S%z", - ) From dcb2a211e54680a19b4e27206b113ea64f2c2230 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 13:13:35 +0200 Subject: [PATCH 484/903] Remove last_reset attribute and set state class to total_increasing for Shelly energy sensors (#54800) --- homeassistant/components/shelly/const.py | 3 -- homeassistant/components/shelly/entity.py | 1 - homeassistant/components/shelly/sensor.py | 58 +++-------------------- 3 files changed, 7 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index ea6b9320cb1..5646086285d 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -109,6 +109,3 @@ KELVIN_MIN_VALUE_WHITE: Final = 2700 KELVIN_MIN_VALUE_COLOR: Final = 3000 UPTIME_DEVIATION: Final = 5 - -LAST_RESET_UPTIME: Final = "uptime" -LAST_RESET_NEVER: Final = "never" diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 0d23f5abffc..743dd07414e 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -179,7 +179,6 @@ class BlockAttributeDescription: # Callable (settings, block), return true if entity should be removed removal_condition: Callable[[dict, aioshelly.Block], bool] | None = None extra_state_attributes: Callable[[aioshelly.Block], dict | None] | None = None - last_reset: str | None = None @dataclass diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index e3af10571d5..13cf56d3b3d 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1,12 +1,8 @@ """Sensor for Shelly.""" from __future__ import annotations -from datetime import timedelta -import logging from typing import Final, cast -import aioshelly - from homeassistant.components import sensor from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry @@ -24,10 +20,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.util import dt -from . import ShellyDeviceWrapper -from .const import LAST_RESET_NEVER, LAST_RESET_UPTIME, SHAIR_MAX_WORK_HOURS +from .const import SHAIR_MAX_WORK_HOURS from .entity import ( BlockAttributeDescription, RestAttributeDescription, @@ -39,8 +33,6 @@ from .entity import ( ) from .utils import get_device_uptime, temperature_unit -_LOGGER: Final = logging.getLogger(__name__) - SENSORS: Final = { ("device", "battery"): BlockAttributeDescription( name="Battery", @@ -119,49 +111,43 @@ SENSORS: Final = { unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=LAST_RESET_UPTIME, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), ("emeter", "energy"): BlockAttributeDescription( name="Energy", unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=LAST_RESET_NEVER, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), ("emeter", "energyReturned"): BlockAttributeDescription( name="Energy Returned", unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=LAST_RESET_NEVER, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), ("light", "energy"): BlockAttributeDescription( name="Energy", unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, default_enabled=False, - last_reset=LAST_RESET_UPTIME, ), ("relay", "energy"): BlockAttributeDescription( name="Energy", unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=LAST_RESET_UPTIME, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), ("roller", "rollerEnergy"): BlockAttributeDescription( name="Energy", unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=LAST_RESET_UPTIME, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), ("sensor", "concentration"): BlockAttributeDescription( name="Gas Concentration", @@ -261,39 +247,9 @@ async def async_setup_entry( class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): """Represent a shelly sensor.""" - def __init__( - self, - wrapper: ShellyDeviceWrapper, - block: aioshelly.Block, - attribute: str, - description: BlockAttributeDescription, - ) -> None: - """Initialize sensor.""" - super().__init__(wrapper, block, attribute, description) - self._last_value: float | None = None - - if description.last_reset == LAST_RESET_NEVER: - self._attr_last_reset = dt.utc_from_timestamp(0) - elif description.last_reset == LAST_RESET_UPTIME: - self._attr_last_reset = ( - dt.utcnow() - timedelta(seconds=wrapper.device.status["uptime"]) - ).replace(second=0, microsecond=0) - @property def native_value(self) -> StateType: """Return value of sensor.""" - if ( - self.description.last_reset == LAST_RESET_UPTIME - and self.attribute_value is not None - ): - value = cast(float, self.attribute_value) - - if self._last_value and self._last_value > value: - self._attr_last_reset = dt.utcnow().replace(second=0, microsecond=0) - _LOGGER.info("Energy reset detected for entity %s", self.name) - - self._last_value = value - return self.attribute_value @property From 939fde0a500f599fac9f9c5cd2befa8b75f45475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 18 Aug 2021 14:22:05 +0300 Subject: [PATCH 485/903] ConfigType and async_setup/setup type hint improvements (#54739) --- homeassistant/components/analytics/__init__.py | 3 ++- homeassistant/components/arcam_fmj/__init__.py | 2 +- homeassistant/components/bmw_connected_drive/__init__.py | 3 ++- homeassistant/components/coronavirus/__init__.py | 3 ++- homeassistant/components/dhcp/__init__.py | 3 ++- homeassistant/components/doorbird/__init__.py | 3 ++- homeassistant/components/dynalite/__init__.py | 3 ++- homeassistant/components/emulated_kasa/__init__.py | 3 ++- homeassistant/components/fan/__init__.py | 3 ++- homeassistant/components/firmata/__init__.py | 3 ++- homeassistant/components/google_assistant/__init__.py | 5 ++--- homeassistant/components/google_pubsub/__init__.py | 4 ++-- homeassistant/components/habitica/__init__.py | 3 ++- homeassistant/components/hdmi_cec/__init__.py | 3 ++- homeassistant/components/heos/__init__.py | 2 +- homeassistant/components/home_connect/__init__.py | 3 ++- homeassistant/components/home_plus_control/__init__.py | 3 ++- homeassistant/components/homeassistant/__init__.py | 3 ++- homeassistant/components/homekit/__init__.py | 3 ++- homeassistant/components/huawei_lte/__init__.py | 2 +- homeassistant/components/image/__init__.py | 3 ++- homeassistant/components/intent/__init__.py | 3 ++- homeassistant/components/izone/__init__.py | 2 +- homeassistant/components/juicenet/__init__.py | 3 ++- homeassistant/components/konnected/__init__.py | 3 ++- homeassistant/components/lovelace/__init__.py | 2 +- homeassistant/components/lyric/__init__.py | 3 ++- homeassistant/components/media_source/__init__.py | 3 ++- homeassistant/components/melcloud/__init__.py | 3 ++- homeassistant/components/mobile_app/__init__.py | 2 +- homeassistant/components/nest/__init__.py | 3 ++- homeassistant/components/netatmo/__init__.py | 3 ++- homeassistant/components/nfandroidtv/__init__.py | 3 ++- homeassistant/components/nzbget/__init__.py | 3 ++- homeassistant/components/onvif/__init__.py | 3 ++- homeassistant/components/persistent_notification/__init__.py | 3 ++- homeassistant/components/person/__init__.py | 2 +- homeassistant/components/plum_lightpad/__init__.py | 3 ++- homeassistant/components/proxmoxve/__init__.py | 3 ++- homeassistant/components/pvpc_hourly_pricing/__init__.py | 3 ++- homeassistant/components/rest/__init__.py | 3 ++- homeassistant/components/safe_mode/__init__.py | 3 ++- homeassistant/components/screenlogic/__init__.py | 3 ++- homeassistant/components/search/__init__.py | 3 ++- homeassistant/components/smappee/__init__.py | 3 ++- homeassistant/components/smarthab/__init__.py | 3 ++- homeassistant/components/smartthings/__init__.py | 2 +- homeassistant/components/songpal/__init__.py | 4 ++-- homeassistant/components/stt/__init__.py | 3 ++- homeassistant/components/surepetcare/__init__.py | 3 ++- homeassistant/components/switcher_kis/__init__.py | 3 ++- homeassistant/components/system_health/__init__.py | 2 +- homeassistant/components/tradfri/__init__.py | 2 +- homeassistant/components/upnp/__init__.py | 2 +- homeassistant/components/vera/__init__.py | 3 ++- homeassistant/components/xbox/__init__.py | 3 ++- homeassistant/components/yeelight/__init__.py | 3 ++- homeassistant/components/zeroconf/__init__.py | 3 ++- homeassistant/components/zone/__init__.py | 3 ++- homeassistant/components/zwave_js/__init__.py | 3 ++- homeassistant/config.py | 2 +- .../templates/config_flow_oauth2/integration/__init__.py | 5 ++--- .../scaffold/templates/integration/integration/__init__.py | 5 ++--- 63 files changed, 114 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index d41970a79de..944acc6ef9d 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -5,12 +5,13 @@ from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.typing import ConfigType from .analytics import Analytics from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA -async def async_setup(hass: HomeAssistant, _): +async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: """Set up the analytics integration.""" analytics = Analytics(hass) diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index c1df4fc0587..d28de3b92aa 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -36,7 +36,7 @@ async def _await_cancel(task): await task -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the component.""" hass.data[DOMAIN_DATA_ENTRIES] = {} hass.data[DOMAIN_DATA_TASKS] = {} diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 17e57b5d09c..85a5c9cd02f 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -23,6 +23,7 @@ from homeassistant.helpers import device_registry, discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_utc_time_change +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -79,7 +80,7 @@ _SERVICE_MAP = { UNDO_UPDATE_LISTENER = "undo_update_listener" -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the BMW Connected Drive component from configuration.yaml.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][DATA_HASS_CONFIG] = config diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py index c855137fcbf..d130e131c8b 100644 --- a/homeassistant/components/coronavirus/__init__.py +++ b/homeassistant/components/coronavirus/__init__.py @@ -8,13 +8,14 @@ import coronavirus from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, entity_registry, update_coordinator +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN PLATFORMS = ["sensor"] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Coronavirus component.""" # Make sure coordinator is initialized. await get_coordinator(hass) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 7003038593b..1a49667bad8 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -41,6 +41,7 @@ from homeassistant.helpers.event import ( async_track_state_added_domain, async_track_time_interval, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_dhcp from homeassistant.util.network import is_invalid, is_link_local, is_loopback @@ -58,7 +59,7 @@ SCAN_INTERVAL = timedelta(minutes=60) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the dhcp component.""" async def _initialize(_): diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index d5964d5aea0..07366ad1a9a 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import get_url +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, slugify from .const import ( @@ -58,7 +59,7 @@ DEVICE_SCHEMA = vol.Schema( CONFIG_SCHEMA = cv.deprecated(DOMAIN) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the DoorBird component.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 7dc3d86afe6..49e742519fd 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, C from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow from .bridge import DynaliteBridge @@ -179,7 +180,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" conf = config.get(DOMAIN) LOGGER.debug("Setting up dynalite component config = %s", conf) diff --git a/homeassistant/components/emulated_kasa/__init__.py b/homeassistant/components/emulated_kasa/__init__.py index b9dc79e25cc..d513669cd00 100644 --- a/homeassistant/components/emulated_kasa/__init__.py +++ b/homeassistant/components/emulated_kasa/__init__.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.template import Template, is_template_string +from homeassistant.helpers.typing import ConfigType from .const import CONF_POWER, CONF_POWER_ENTITY, DOMAIN @@ -48,7 +49,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the emulated_kasa component.""" conf = config.get(DOMAIN) if not conf: diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 1d0caa3231b..a05505e8112 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -25,6 +25,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.percentage import ( ordered_list_item_to_percentage, @@ -124,7 +125,7 @@ def is_on(hass, entity_id: str) -> bool: return state.state == STATE_ON -async def async_setup(hass, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Expose fan control via statemachine and services.""" component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL diff --git a/homeassistant/components/firmata/__init__.py b/homeassistant/components/firmata/__init__.py index 24b6420e8a5..d98866f900b 100644 --- a/homeassistant/components/firmata/__init__.py +++ b/homeassistant/components/firmata/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .board import FirmataBoard from .const import ( @@ -122,7 +123,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Firmata domain.""" # Delete specific entries that no longer exist in the config if hass.config_entries.async_entries(DOMAIN): diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 13516783233..1e0c0a06114 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -2,14 +2,13 @@ from __future__ import annotations import logging -from typing import Any import voluptuous as vol -# Typing imports from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_ALIASES, @@ -91,7 +90,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, yaml_config: dict[str, Any]): +async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: """Activate Google Actions component.""" if DOMAIN not in yaml_config: return True diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index d583bc5aac0..1de7e98d776 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -5,7 +5,6 @@ import datetime import json import logging import os -from typing import Any from google.cloud import pubsub_v1 import voluptuous as vol @@ -14,6 +13,7 @@ from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UN from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, yaml_config: dict[str, Any]): +def setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: """Activate Google Pub/Sub component.""" config = yaml_config[DOMAIN] project_id = config[CONF_PROJECT_ID] diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index efb82a9f1aa..1d1536d1679 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( 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 ( ATTR_ARGS, @@ -83,7 +84,7 @@ SERVICE_API_CALL_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Habitica service.""" configs = config.get(DOMAIN, []) diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 9d4fa286fd6..87391634251 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -45,6 +45,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery, event import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType DOMAIN = "hdmi_cec" @@ -186,7 +187,7 @@ def parse_mapping(mapping, parents=None): yield (val, pad_physical_address(cur)) -def setup(hass: HomeAssistant, base_config): # noqa: C901 +def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 """Set up the CEC capability.""" # Parse configuration into a dict of device name to physical address diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 7490c1e5be1..35520927e97 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -43,7 +43,7 @@ MIN_UPDATE_SOURCES = timedelta(seconds=1) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HEOS component.""" if DOMAIN not in config: return True diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index f8a9157dca2..1fc446af401 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from . import api, config_flow @@ -34,7 +35,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["binary_sensor", "light", "sensor", "switch"] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index e775b9d97aa..ffb055e6324 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers import ( dispatcher, ) from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import config_flow, helpers @@ -50,7 +51,7 @@ PLATFORMS = ["switch"] _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Legrand Home+ Control component from configuration.yaml.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index d21cd1359f1..2314d2b0c1b 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -26,6 +26,7 @@ from homeassistant.helpers.service import ( async_extract_config_entry_ids, async_extract_referenced_entity_ids, ) +from homeassistant.helpers.typing import ConfigType ATTR_ENTRY_ID = "entry_id" @@ -51,7 +52,7 @@ SCHEMA_RELOAD_CONFIG_ENTRY = vol.All( SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) -async def async_setup(hass: ha.HomeAssistant, config: dict) -> bool: # noqa: C901 +async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" async def async_save_persistent_states(service): diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 967acaf7ddc..705b671f28a 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -44,6 +44,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA, FILTER_SCHEMA from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_extract_referenced_entity_ids +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound, async_get_integration from . import ( # noqa: F401 @@ -187,7 +188,7 @@ def _async_get_entries_by_name(current_entries): return {entry.data.get(CONF_NAME, BRIDGE_NAME): entry for entry in current_entries} -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HomeKit from yaml.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 0c545486c82..e220975dbf1 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -298,7 +298,7 @@ class Router: class HuaweiLteData: """Shared state.""" - hass_config: dict = attr.ib() + hass_config: ConfigType = attr.ib() # Our YAML config, keyed by router URL config: dict[str, dict[str, Any]] = attr.ib() routers: dict[str, Router] = attr.ib(init=False, factory=dict) diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index e27abf70127..51263e38ab7 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from .const import DOMAIN @@ -37,7 +38,7 @@ UPDATE_FIELDS = { } -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Image integration.""" image_dir = pathlib.Path(hass.config.path(DOMAIN)) hass.data[DOMAIN] = storage_collection = ImageStorageCollection(hass, image_dir) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 4fd6daa5102..d626daa8c3b 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -6,11 +6,12 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv, integration_platform, intent +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Intent component.""" hass.http.register_view(IntentHandleView()) diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 76744550649..e3f4b62af63 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -26,7 +26,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the iZone component config.""" conf = config.get(IZONE) if not conf: diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index 38089a6e17f..0480eac80b3 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR @@ -30,7 +31,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the JuiceNet component.""" conf = config.get(DOMAIN) hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 32d0f0e20c0..6785e2e7124 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -36,6 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .config_flow import ( # Loading the config flow file will register the flow CONF_DEFAULT_OPTIONS, @@ -220,7 +221,7 @@ YAML_CONFIGS = "yaml_configs" PLATFORMS = ["binary_sensor", "sensor", "switch"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Konnected platform.""" cfg = config.get(DOMAIN) if cfg is None: diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index e16f1399c40..d8fe591a0ba 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -67,7 +67,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Lovelace commands.""" mode = config[DOMAIN][CONF_MODE] yaml_resources = config[DOMAIN].get(CONF_RESOURCES) diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index e958567940a..4afb66f7173 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -23,6 +23,7 @@ from homeassistant.helpers import ( device_registry as dr, ) from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -50,7 +51,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = ["climate", "sensor"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Honeywell Lyric component.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 5b027a99bf9..cb485ac765f 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from . import local_source, models @@ -36,7 +37,7 @@ def generate_media_source_id(domain: str, identifier: str) -> str: return uri -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the media_source component.""" hass.data[DOMAIN] = {} hass.components.websocket_api.async_register_command(websocket_browse_media) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 12b80554933..69efa26ac44 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from .const import DOMAIN @@ -44,7 +45,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigEntry): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Establish connection with MELCloud.""" if DOMAIN not in config: return True diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 9633ec6556d..1fc5be2a890 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -36,7 +36,7 @@ from .webhook import handle_webhook PLATFORMS = "sensor", "binary_sensor", "device_tracker" -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the mobile app component.""" store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) app_config = await store.async_load() diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index b999b2e94e0..ff340d38424 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -27,6 +27,7 @@ from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, ) +from homeassistant.helpers.typing import ConfigType from . import api, config_flow from .const import DATA_SDM, DATA_SUBSCRIBER, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN @@ -69,7 +70,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["sensor", "camera", "climate"] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Nest components with dispatch between old/new flows.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index edb8837fd18..76a5eeb9c86 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -31,6 +31,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.typing import ConfigType from . import api, config_flow from .const import ( @@ -69,7 +70,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Netatmo component.""" hass.data[DOMAIN] = { DATA_PERSONS: {}, diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 35aecdb6916..92bb492bf7d 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -7,13 +7,14 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN PLATFORMS = [NOTIFY] -async def async_setup(hass: HomeAssistant, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NFAndroidTV component.""" hass.data.setdefault(DOMAIN, {}) # Iterate all entries for notify to only get nfandroidtv diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index 71f885ce491..ebb3a7e4e66 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -60,7 +61,7 @@ SPEED_LIMIT_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NZBGet integration.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 5c44cdf1750..67bec21e123 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_per_platform +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_RTSP_TRANSPORT, @@ -31,7 +32,7 @@ from .const import ( from .device import ONVIFDevice -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ONVIF component.""" # Import from yaml configs = {} diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 4a68dd3356f..ec2c5f7512d 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -15,6 +15,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -100,7 +101,7 @@ def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_DISMISS, data)) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the persistent notification component.""" persistent_notifications: MutableMapping[str, MutableMapping] = OrderedDict() hass.data[DOMAIN] = {"notifications": persistent_notifications} diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 7641a75e9c6..ba1f0ced623 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -293,7 +293,7 @@ The following persons point at invalid users: return filtered -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the person component.""" entity_component = EntityComponent(_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() diff --git a/homeassistant/components/plum_lightpad/__init__.py b/homeassistant/components/plum_lightpad/__init__.py index 9f69c8579a4..f92d087b79d 100644 --- a/homeassistant/components/plum_lightpad/__init__.py +++ b/homeassistant/components/plum_lightpad/__init__.py @@ -10,6 +10,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTAN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .utils import load_plum @@ -34,7 +35,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["light"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Plum Lightpad Platform initialization.""" if DOMAIN not in config: return True diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 9c650363aad..089e028afd1 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -86,7 +87,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the platform.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 3e98274c696..e628dfb9813 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -13,6 +13,7 @@ from homeassistant.helpers.entity_registry import ( async_get, async_migrate_entries, ) +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_POWER, @@ -41,7 +42,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the electricity price sensor from configuration.yaml.""" for conf in config.get(DOMAIN, []): hass.async_create_task( diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 8b9390bb1c9..42c342a2c84 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -31,6 +31,7 @@ from homeassistant.helpers.entity_component import ( EntityComponent, ) from homeassistant.helpers.reload import async_reload_integration_platforms +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import COORDINATOR, DOMAIN, PLATFORM_IDX, REST, REST_DATA, REST_IDX @@ -43,7 +44,7 @@ PLATFORMS = ["binary_sensor", "notify", "sensor", "switch"] COORDINATOR_AWARE_PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the rest platforms.""" component = EntityComponent(_LOGGER, DOMAIN, hass) _async_setup_shared_data(hass) diff --git a/homeassistant/components/safe_mode/__init__.py b/homeassistant/components/safe_mode/__init__.py index 94bd95aabe0..162dd204c54 100644 --- a/homeassistant/components/safe_mode/__init__.py +++ b/homeassistant/components/safe_mode/__init__.py @@ -1,11 +1,12 @@ """The Safe Mode integration.""" from homeassistant.components import persistent_notification from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType DOMAIN = "safe_mode" -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Safe Mode component.""" persistent_notification.async_create( hass, diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 223ca9262ee..2ec087d1e61 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -31,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = ["switch", "sensor", "binary_sensor", "climate"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Screenlogic component.""" domain_data = hass.data[DOMAIN] = {} domain_data[DISCOVERED_GATEWAYS] = await async_discover_gateways_by_unique_id(hass) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index fc13b8ca098..5472ac421c3 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -11,12 +11,13 @@ from homeassistant.components.homeassistant import scene from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.entity import entity_sources as get_entity_sources +from homeassistant.helpers.typing import ConfigType DOMAIN = "search" _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Search component.""" websocket_api.async_register_command(hass, websocket_search_related) return True diff --git a/homeassistant/components/smappee/__init__.py b/homeassistant/components/smappee/__init__.py index 1037d399e64..94c5bbcdcac 100644 --- a/homeassistant/components/smappee/__init__.py +++ b/homeassistant/components/smappee/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from . import api, config_flow @@ -37,7 +38,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Smappee component.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/smarthab/__init__.py b/homeassistant/components/smarthab/__init__.py index ec4d2c9cad6..06d4de36b3c 100644 --- a/homeassistant/components/smarthab/__init__.py +++ b/homeassistant/components/smarthab/__init__.py @@ -9,6 +9,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType DOMAIN = "smarthab" DATA_HUB = "hub" @@ -32,7 +33,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SmartHab platform.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index bc64b173f20..fef2917fb8d 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -57,7 +57,7 @@ from .smartapp import ( _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the SmartThings platform.""" await setup_smartapp_endpoint(hass) return True diff --git a/homeassistant/components/songpal/__init__.py b/homeassistant/components/songpal/__init__.py index b542591b294..2053d2857c2 100644 --- a/homeassistant/components/songpal/__init__.py +++ b/homeassistant/components/songpal/__init__.py @@ -1,5 +1,4 @@ """The songpal component.""" -from collections import OrderedDict import voluptuous as vol @@ -7,6 +6,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import CONF_ENDPOINT, DOMAIN @@ -22,7 +22,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["media_player"] -async def async_setup(hass: HomeAssistant, config: OrderedDict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up songpal environment.""" conf = config.get(DOMAIN) if conf is None: diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 694ddeff998..3b5efbcba9c 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -17,6 +17,7 @@ import attr from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_prepare_setup_platform from .const import ( @@ -34,7 +35,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up STT.""" providers = {} diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index e9a2c5b73a1..87a3260fc40 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers import config_validation as cv 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 .const import ( ATTR_FLAP_ID, @@ -62,7 +63,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Sure Petcare integration.""" conf = config[DOMAIN] hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 6c13067cd7f..6a23f1bb453 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers import ( update_coordinator, ) from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_DEVICE_PASSWORD, @@ -49,7 +50,7 @@ CCONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the switcher component.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index c8200e0e10a..651961c72ac 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -43,7 +43,7 @@ def async_register_info( SystemHealthRegistration(hass, domain).async_register_info(info_callback) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the System Health component.""" hass.components.websocket_api.async_register_command(handle_info) hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index e2c90098314..2c113b63727 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -56,7 +56,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Tradfri component.""" conf = config.get(DOMAIN) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index c21c1d24f0c..80a7753ec8c 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -55,7 +55,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up UPnP component.""" LOGGER.debug("async_setup, config: %s", config) conf_default = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index feac63f694b..9a153841718 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -25,6 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType from homeassistant.util import convert, slugify from homeassistant.util.dt import utc_from_timestamp @@ -63,7 +64,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, base_config: dict) -> bool: +async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: """Set up for Vera controllers.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 6e651cdbcf3..d54d79532ca 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import api, config_flow @@ -50,7 +51,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["media_player", "remote", "binary_sensor", "sensor"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the xbox component.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 2a4ba4eac55..c9e654bca9a 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -24,6 +24,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -152,7 +153,7 @@ UPDATE_REQUEST_PROPERTIES = [ PLATFORMS = ["binary_sensor", "light"] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Yeelight bulbs.""" conf = config.get(DOMAIN, {}) hass.data[DOMAIN] = { diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index e7132f56b55..a85236b6a07 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -28,6 +28,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf @@ -137,7 +138,7 @@ def _async_use_default_interface(adapters: list[Adapter]) -> bool: return True -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Zeroconf and make Home Assistant discoverable.""" zc_args: dict = {} diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 8ab0e9b2703..d4474d793ab 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -30,6 +30,7 @@ from homeassistant.helpers import ( service, storage, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.location import distance @@ -176,7 +177,7 @@ class ZoneStorageCollection(collection.StorageCollection): return {**data, **update_data} -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up configured zones as well as Home Assistant zone if necessary.""" component = entity_component.EntityComponent(_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 6320efddb60..c8f2bd19776 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -28,6 +28,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .addon import AddonError, AddonManager, AddonState, get_addon_manager from .api import async_register_api @@ -79,7 +80,7 @@ DATA_CONNECT_FAILED_LOGGED = "connect_failed_logged" DATA_INVALID_SERVER_VERSION_LOGGED = "invalid_server_version_logged" -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Z-Wave JS component.""" hass.data[DOMAIN] = {} return True diff --git a/homeassistant/config.py b/homeassistant/config.py index cd159dfc8ce..754420dbcce 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -925,7 +925,7 @@ async def async_process_component_config( # noqa: C901 @callback -def config_without_domain(config: dict, domain: str) -> dict: +def config_without_domain(config: ConfigType, domain: str) -> ConfigType: """Return a config with all configuration for a domain removed.""" filter_keys = extract_domain_configs(config, domain) return {key: value for key, value in config.items() if key not in filter_keys} diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index f597ef609ea..8b1bdc93749 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -1,8 +1,6 @@ """The NEW_NAME integration.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -13,6 +11,7 @@ from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, ) +from homeassistant.helpers.typing import ConfigType from . import api, config_flow from .const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN @@ -34,7 +33,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["light"] -async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NEW_NAME component.""" hass.data[DOMAIN] = {} diff --git a/script/scaffold/templates/integration/integration/__init__.py b/script/scaffold/templates/integration/integration/__init__.py index c1f34d5f5b1..e30cd400bf2 100644 --- a/script/scaffold/templates/integration/integration/__init__.py +++ b/script/scaffold/templates/integration/integration/__init__.py @@ -1,17 +1,16 @@ """The NEW_NAME integration.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN CONFIG_SCHEMA = vol.Schema({vol.Optional(DOMAIN): {}}, extra=vol.ALLOW_EXTRA) -async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NEW_NAME integration.""" return True From 3e235f6e70333524208799c5a4052a38743230f3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 13:36:35 +0200 Subject: [PATCH 486/903] Remove `last_reset` attribute and set state class to `total_increasing` for Ovo cost and energy sensors (#54807) --- homeassistant/components/ovo_energy/sensor.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 91290238dce..e1130ca36a5 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -6,12 +6,11 @@ from datetime import timedelta from ovoenergy import OVODailyUsage from ovoenergy.ovoenergy import OVOEnergy -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_CLASS_ENERGY, DEVICE_CLASS_MONETARY from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util.dt import utc_from_timestamp from . import OVOEnergyDeviceEntity from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN @@ -61,8 +60,7 @@ async def async_setup_entry( class OVOEnergySensor(OVOEnergyDeviceEntity, SensorEntity): """Defines a OVO Energy sensor.""" - _attr_last_reset = utc_from_timestamp(0) - _attr_state_class = "measurement" + _attr_state_class = STATE_CLASS_TOTAL_INCREASING def __init__( self, From 7812b50572dd54f58cd6e4a196e8d614300adab1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 13:37:43 +0200 Subject: [PATCH 487/903] Remove `last_reset` attribute and set state class to `total_increasing` for powerwall energy sensors (#54808) --- homeassistant/components/powerwall/sensor.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 0ffa333181d..940dcad8647 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -3,7 +3,11 @@ import logging from tesla_powerwall import MeterType -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, @@ -12,7 +16,6 @@ from homeassistant.const import ( PERCENTAGE, POWER_KILO_WATT, ) -import homeassistant.util.dt as dt_util from .const import ( ATTR_FREQUENCY, @@ -151,10 +154,9 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Direction Energy sensor.""" - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_state_class = STATE_CLASS_TOTAL_INCREASING _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_device_class = DEVICE_CLASS_ENERGY - _attr_last_reset = dt_util.utc_from_timestamp(0) def __init__( self, From 3c5ba1fcc3476cbdf13f13536039e669053d1b25 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 13:41:57 +0200 Subject: [PATCH 488/903] Remove `last_reset` attribute and set state class to `total_increasing` for PVOutput energy sensors (#54809) --- homeassistant/components/pvoutput/sensor.py | 42 ++------------------- 1 file changed, 4 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index 722aea8e868..8126e00d8e5 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -2,9 +2,8 @@ from __future__ import annotations from collections import namedtuple -from datetime import datetime, timedelta +from datetime import timedelta import logging -from typing import cast import voluptuous as vol @@ -12,7 +11,7 @@ from homeassistant.components.rest.data import RestData from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, PLATFORM_SCHEMA, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -26,8 +25,6 @@ from homeassistant.const import ( ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) _ENDPOINT = "http://pvoutput.org/service/r2/getstatus.jsp" @@ -74,15 +71,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities([PvoutputSensor(rest, name)]) -class PvoutputSensor(SensorEntity, RestoreEntity): +class PvoutputSensor(SensorEntity): """Representation of a PVOutput sensor.""" - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_state_class = STATE_CLASS_TOTAL_INCREASING _attr_device_class = DEVICE_CLASS_ENERGY _attr_native_unit_of_measurement = ENERGY_WATT_HOUR - _old_state: int | None = None - def __init__(self, rest, name): """Initialize a PVOutput sensor.""" self.rest = rest @@ -129,37 +124,8 @@ class PvoutputSensor(SensorEntity, RestoreEntity): await self.rest.async_update() self._async_update_from_rest_data() - new_state: int | None = None - state = cast("str | None", self.state) - if state is not None: - new_state = int(state) - - did_reset = False - if new_state is None: - did_reset = False - elif self._old_state is None: - did_reset = True - elif new_state == 0: - did_reset = self._old_state != 0 - elif new_state < self._old_state: - did_reset = True - - if did_reset: - self._attr_last_reset = dt_util.utcnow() - - if new_state is not None: - self._old_state = new_state - async def async_added_to_hass(self): """Ensure the data from the initial update is reflected in the state.""" - last_state = await self.async_get_last_state() - if last_state is not None: - if "last_reset" in last_state.attributes: - self._attr_last_reset = dt_util.as_utc( - datetime.fromisoformat(last_state.attributes["last_reset"]) - ) - self._old_state = int(last_state.state) - self._async_update_from_rest_data() @callback From d9bfb8fc58f54c4460a853fac46b70e970dd9819 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 13:44:08 +0200 Subject: [PATCH 489/903] Remove `last_reset` attribute and set state class to `total_increasing` for rainforest energy sensors (#54810) --- homeassistant/components/rainforest_eagle/sensor.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 64eb243c15d..6e42d2a13a2 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import timedelta import logging from eagle200_reader import EagleReader @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -22,7 +23,7 @@ from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, ) import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle CONF_CLOUD_ID = "cloud_id" CONF_INSTALL_CODE = "install_code" @@ -41,7 +42,6 @@ class SensorType: unit_of_measurement: str device_class: str | None = None state_class: str | None = None - last_reset: datetime | None = None SENSORS = { @@ -54,15 +54,13 @@ SENSORS = { name="Eagle-200 Total Meter Energy Delivered", unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), "summation_received": SensorType( name="Eagle-200 Total Meter Energy Received", unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), "summation_total": SensorType( name="Eagle-200 Net Meter Energy (Delivered minus Received)", @@ -134,7 +132,6 @@ class EagleSensor(SensorEntity): self._attr_native_unit_of_measurement = sensor_info.unit_of_measurement self._attr_device_class = sensor_info.device_class self._attr_state_class = sensor_info.state_class - self._attr_last_reset = sensor_info.last_reset def update(self): """Get the energy information from the Rainforest Eagle.""" From 0b7b4152f1abb95a6c43cfa0a8ff517d6f964312 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 13:55:21 +0200 Subject: [PATCH 490/903] Remove last_reset attribute from devolo energy sensors (#54803) --- .../components/devolo_home_control/sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 5c8bed7818b..61c3e9a5c19 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -162,10 +162,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): ) if consumption == "total": - self._attr_state_class = STATE_CLASS_MEASUREMENT - self._attr_last_reset = device_instance.consumption_property[ - element_uid - ].total_since + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING self._value = getattr( device_instance.consumption_property[element_uid], consumption @@ -180,15 +177,11 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): def _sync(self, message: tuple) -> None: """Update the consumption sensor state.""" - if message[0] == self._attr_unique_id and message[2] != "total_since": + if message[0] == self._attr_unique_id: self._value = getattr( self._device_instance.consumption_property[self._attr_unique_id], self._sensor_type, ) - elif message[0] == self._attr_unique_id and message[2] == "total_since": - self._attr_last_reset = self._device_instance.consumption_property[ - self._attr_unique_id - ].total_since else: self._generic_message(message) self.schedule_update_ha_state() From 60f8e24bde91d50cdb588fcab66c2a6c5dccd492 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 13:58:08 +0200 Subject: [PATCH 491/903] Remove last_reset attribute from sma energy sensors (#54814) --- homeassistant/components/sma/sensor.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 36084c53bb3..8808272ad75 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -32,7 +32,6 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from homeassistant.util import dt as dt_util from .const import ( CONF_CUSTOM, @@ -165,9 +164,8 @@ class SMAsensor(CoordinatorEntity, SensorEntity): self._device_info = device_info if self.unit_of_measurement == ENERGY_KILO_WATT_HOUR: - self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING self._attr_device_class = DEVICE_CLASS_ENERGY - self._attr_last_reset = dt_util.utc_from_timestamp(0) # Set sensor enabled to False. # Will be enabled by async_added_to_hass if actually used. From 0329d0f2465084cd95c888032fd405cc56fe7643 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 14:18:51 +0200 Subject: [PATCH 492/903] Remove last_reset attribute and set state class to total_increasing for tibber energy sensors (#54799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove last_reset attribute from tibber energy sensors * Remove reset_type, fix merge * Update homeassistant/components/tibber/sensor.py Co-authored-by: Franck Nijhof Co-authored-by: Daniel Hjelseth Høyer Co-authored-by: Franck Nijhof --- homeassistant/components/tibber/sensor.py | 108 ++++++---------------- 1 file changed, 30 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 080da3fca13..d376bf0a7d5 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -2,9 +2,7 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass from datetime import timedelta -from enum import Enum import logging from random import randrange @@ -19,6 +17,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -49,166 +48,143 @@ PARALLEL_UPDATES = 0 SIGNAL_UPDATE_ENTITY = "tibber_rt_update_{}" -class ResetType(Enum): - """Data reset type.""" - - HOURLY = "hourly" - DAILY = "daily" - NEVER = "never" - - -@dataclass -class TibberSensorEntityDescription(SensorEntityDescription): - """Describes Tibber sensor entity.""" - - reset_type: ResetType | None = None - - -RT_SENSORS: tuple[TibberSensorEntityDescription, ...] = ( - TibberSensorEntityDescription( +RT_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( key="averagePower", name="average power", device_class=DEVICE_CLASS_POWER, native_unit_of_measurement=POWER_WATT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="power", name="power", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=POWER_WATT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="powerProduction", name="power production", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=POWER_WATT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="minPower", name="min power", device_class=DEVICE_CLASS_POWER, native_unit_of_measurement=POWER_WATT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="maxPower", name="max power", device_class=DEVICE_CLASS_POWER, native_unit_of_measurement=POWER_WATT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedConsumption", name="accumulated consumption", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.DAILY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedConsumptionLastHour", name="accumulated consumption current hour", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.HOURLY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedProduction", name="accumulated production", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.DAILY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedProductionLastHour", name="accumulated production current hour", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.HOURLY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="lastMeterConsumption", name="last meter consumption", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.NEVER, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="lastMeterProduction", name="last meter production", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.NEVER, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="voltagePhase1", name="voltage phase1", device_class=DEVICE_CLASS_VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="voltagePhase2", name="voltage phase2", device_class=DEVICE_CLASS_VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="voltagePhase3", name="voltage phase3", device_class=DEVICE_CLASS_VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="currentL1", name="current L1", device_class=DEVICE_CLASS_CURRENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="currentL2", name="current L2", device_class=DEVICE_CLASS_CURRENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="currentL3", name="current L3", device_class=DEVICE_CLASS_CURRENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="signalStrength", name="signal strength", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=STATE_CLASS_MEASUREMENT, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedReward", name="accumulated reward", device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.DAILY, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedCost", name="accumulated cost", device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.DAILY, ), - TibberSensorEntityDescription( + SensorEntityDescription( key="powerFactor", name="power factor", device_class=DEVICE_CLASS_POWER_FACTOR, @@ -376,12 +352,10 @@ class TibberSensorElPrice(TibberSensor): class TibberSensorRT(TibberSensor, update_coordinator.CoordinatorEntity): """Representation of a Tibber sensor for real time consumption.""" - entity_description: TibberSensorEntityDescription - def __init__( self, tibber_home, - description: TibberSensorEntityDescription, + description: SensorEntityDescription, initial_state, coordinator: TibberRtDataCoordinator, ): @@ -397,18 +371,6 @@ class TibberSensorRT(TibberSensor, update_coordinator.CoordinatorEntity): if description.key in ("accumulatedCost", "accumulatedReward"): self._attr_native_unit_of_measurement = tibber_home.currency - if description.reset_type == ResetType.NEVER: - self._attr_last_reset = dt_util.utc_from_timestamp(0) - elif description.reset_type == ResetType.DAILY: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) - ) - elif description.reset_type == ResetType.HOURLY: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace(minute=0, second=0, microsecond=0) - ) - else: - self._attr_last_reset = None @property def available(self): @@ -422,16 +384,6 @@ class TibberSensorRT(TibberSensor, update_coordinator.CoordinatorEntity): state = live_measurement.get(self.entity_description.key) if state is None: return - timestamp = dt_util.parse_datetime(live_measurement["timestamp"]) - if timestamp is not None and state < self.state: - if self.entity_description.reset_type == ResetType.DAILY: - self._attr_last_reset = dt_util.as_utc( - timestamp.replace(hour=0, minute=0, second=0, microsecond=0) - ) - elif self.entity_description.reset_type == ResetType.HOURLY: - self._attr_last_reset = dt_util.as_utc( - timestamp.replace(minute=0, second=0, microsecond=0) - ) if self.entity_description.key == "powerFactor": state *= 100.0 self._attr_native_value = state From aef8ec968bf604df9f46addbe33adada4733642e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 14:59:22 +0200 Subject: [PATCH 493/903] Remove last_reset attribute from kostal_plenticore energy sensors (#54817) --- .../components/kostal_plenticore/const.py | 26 +++++++------------ .../components/kostal_plenticore/sensor.py | 13 ++-------- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index ede8e10cb25..5cbc1a2af79 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -1,9 +1,9 @@ """Constants for the Kostal Plenticore Solar Inverter integration.""" from homeassistant.components.sensor import ( - ATTR_LAST_RESET, ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -304,8 +304,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: LAST_RESET_NEVER, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -346,8 +345,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: LAST_RESET_NEVER, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -388,8 +386,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: LAST_RESET_NEVER, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -430,8 +427,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: LAST_RESET_NEVER, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -472,8 +468,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: LAST_RESET_NEVER, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -514,8 +509,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: LAST_RESET_NEVER, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -556,8 +550,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: LAST_RESET_NEVER, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -599,8 +592,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: LAST_RESET_NEVER, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 57b37e51d11..19ac4db0f90 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -1,15 +1,11 @@ """Platform for Kostal Plenticore sensors.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import timedelta import logging from typing import Any, Callable -from homeassistant.components.sensor import ( - ATTR_LAST_RESET, - ATTR_STATE_CLASS, - SensorEntity, -) +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -193,11 +189,6 @@ class PlenticoreDataSensor(CoordinatorEntity, SensorEntity): """Return if the entity should be enabled when first added to the entity registry.""" return self._sensor_data.get(ATTR_ENABLED_DEFAULT, False) - @property - def last_reset(self) -> datetime | None: - """Return the last_reset time.""" - return self._sensor_data.get(ATTR_LAST_RESET) - @property def native_value(self) -> Any | None: """Return the state of the sensor.""" From 5536e24dec7d1cfd221eeb881425d85c1dd3bdbd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 15:11:10 +0200 Subject: [PATCH 494/903] Remove `last_reset` attribute and set state class to `total_increasing` for zwave_js energy sensors (#54818) --- homeassistant/components/zwave_js/sensor.py | 53 ++------------- tests/components/zwave_js/common.py | 5 -- tests/components/zwave_js/conftest.py | 18 ------ tests/components/zwave_js/test_sensor.py | 71 ++++----------------- 4 files changed, 18 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index deacf3d874a..220184f5669 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -11,13 +11,13 @@ from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue from homeassistant.components.sensor import ( - ATTR_LAST_RESET, DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, DOMAIN as SENSOR_DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -36,8 +36,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util import dt from .const import ATTR_METER_TYPE, ATTR_VALUE, DATA_CLIENT, DOMAIN, SERVICE_RESET_METER from .discovery import ZwaveDiscoveryInfo @@ -218,7 +216,7 @@ class ZWaveNumericSensor(ZwaveSensorBase): return str(self.info.primary_value.metadata.unit) -class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): +class ZWaveMeterSensor(ZWaveNumericSensor): """Representation of a Z-Wave Meter CC sensor.""" def __init__( @@ -231,51 +229,10 @@ class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): super().__init__(config_entry, client, info) # Entity class attributes - self._attr_state_class = STATE_CLASS_MEASUREMENT if self.device_class == DEVICE_CLASS_ENERGY: - self._attr_last_reset = dt.utc_from_timestamp(0) - - @callback - def async_update_last_reset( - self, node: ZwaveNode, endpoint: int, meter_type: int | None - ) -> None: - """Update last reset.""" - # If the signal is not for this node or is for a different endpoint, - # or a meter type was specified and doesn't match this entity's meter type: - if ( - self.info.node != node - or self.info.primary_value.endpoint != endpoint - or meter_type is not None - and self.info.primary_value.metadata.cc_specific.get("meterType") - != meter_type - ): - return - - self._attr_last_reset = dt.utcnow() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Call when entity is added.""" - await super().async_added_to_hass() - - # If the meter is not an accumulating meter type, do not reset. - if self.device_class != DEVICE_CLASS_ENERGY: - return - - # Restore the last reset time from stored state - restored_state = await self.async_get_last_state() - if restored_state and ATTR_LAST_RESET in restored_state.attributes: - self._attr_last_reset = dt.parse_datetime( - restored_state.attributes[ATTR_LAST_RESET] - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{SERVICE_RESET_METER}", - self.async_update_last_reset, - ) - ) + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + else: + self._attr_state_class = STATE_CLASS_MEASUREMENT async def async_reset_meter( self, meter_type: int | None = None, value: int | None = None diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 0c6b19698a9..2590149c462 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -1,6 +1,4 @@ """Provide common test tools for Z-Wave JS.""" -from datetime import datetime, timezone - AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" HUMIDITY_SENSOR = "sensor.multisensor_6_humidity" POWER_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" @@ -35,6 +33,3 @@ ID_LOCK_CONFIG_PARAMETER_SENSOR = ( ZEN_31_ENTITY = "light.kitchen_under_cabinet_lights" METER_ENERGY_SENSOR = "sensor.smart_switch_6_electric_consumed_kwh" METER_VOLTAGE_SENSOR = "sensor.smart_switch_6_electric_consumed_v" - -DATETIME_ZERO = datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc) -DATETIME_LAST_RESET = datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 8165dac33a7..900a7937539 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -11,11 +11,6 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo -from homeassistant.components.sensor import ATTR_LAST_RESET -from homeassistant.core import State - -from .common import DATETIME_LAST_RESET - from tests.common import MockConfigEntry, load_fixture # Add-on fixtures @@ -858,16 +853,3 @@ def lock_popp_electric_strike_lock_control_fixture( def firmware_file_fixture(): """Return mock firmware file stream.""" return io.BytesIO(bytes(10)) - - -@pytest.fixture(name="restore_last_reset") -def restore_last_reset_fixture(): - """Return mock restore last reset.""" - state = State( - "sensor.test", "test", {ATTR_LAST_RESET: DATETIME_LAST_RESET.isoformat()} - ) - with patch( - "homeassistant.components.zwave_js.sensor.ZWaveMeterSensor.async_get_last_state", - return_value=state, - ): - yield state diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 268d8ee1380..6d64f6f92dd 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,9 +1,10 @@ """Test the Z-Wave JS sensor platform.""" -from unittest.mock import patch - from zwave_js_server.event import Event -from homeassistant.components.sensor import ATTR_LAST_RESET, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.components.zwave_js.const import ( ATTR_METER_TYPE, ATTR_VALUE, @@ -29,14 +30,11 @@ from homeassistant.helpers import entity_registry as er from .common import ( AIR_TEMPERATURE_SENSOR, CURRENT_SENSOR, - DATETIME_LAST_RESET, - DATETIME_ZERO, ENERGY_SENSOR, HUMIDITY_SENSOR, ID_LOCK_CONFIG_PARAMETER_SENSOR, INDICATOR_SENSOR, METER_ENERGY_SENSOR, - METER_VOLTAGE_SENSOR, NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, VOLTAGE_SENSOR, @@ -76,7 +74,7 @@ async def test_energy_sensors(hass, hank_binary_switch, integration): assert state.state == "0.16" assert state.attributes["unit_of_measurement"] == ENERGY_KILO_WATT_HOUR assert state.attributes["device_class"] == DEVICE_CLASS_ENERGY - assert state.attributes["state_class"] == STATE_CLASS_MEASUREMENT + assert state.attributes["state_class"] == STATE_CLASS_TOTAL_INCREASING state = hass.states.get(VOLTAGE_SENSOR) @@ -192,31 +190,14 @@ async def test_reset_meter( client.async_send_command.return_value = {} client.async_send_command_no_wait.return_value = {} - # Validate that non accumulating meter does not have a last reset attribute - - assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes - - # Validate that the sensor last reset is starting from nothing - assert ( - hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] - == DATETIME_ZERO.isoformat() - ) - - # Test successful meter reset call, patching utcnow so we can make sure the last - # reset gets updated - with patch("homeassistant.util.dt.utcnow", return_value=DATETIME_LAST_RESET): - await hass.services.async_call( - DOMAIN, - SERVICE_RESET_METER, - { - ATTR_ENTITY_ID: METER_ENERGY_SENSOR, - }, - blocking=True, - ) - - assert ( - hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] - == DATETIME_LAST_RESET.isoformat() + # Test successful meter reset call + await hass.services.async_call( + DOMAIN, + SERVICE_RESET_METER, + { + ATTR_ENTITY_ID: METER_ENERGY_SENSOR, + }, + blocking=True, ) assert len(client.async_send_command_no_wait.call_args_list) == 1 @@ -226,10 +207,6 @@ async def test_reset_meter( assert args["endpoint"] == 0 assert args["args"] == [] - # Validate that non accumulating meter does not have a last reset attribute - - assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes - client.async_send_command_no_wait.reset_mock() # Test successful meter reset call with options @@ -251,26 +228,4 @@ async def test_reset_meter( assert args["endpoint"] == 0 assert args["args"] == [{"type": 1, "targetValue": 2}] - # Validate that non accumulating meter does not have a last reset attribute - - assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes - client.async_send_command_no_wait.reset_mock() - - -async def test_restore_last_reset( - hass, - client, - aeon_smart_switch_6, - restore_last_reset, - integration, -): - """Test restoring last_reset on setup.""" - assert ( - hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] - == DATETIME_LAST_RESET.isoformat() - ) - - # Validate that non accumulating meter does not have a last reset attribute - - assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes From 20b7125620d1ba6daf53756daea174c67636bec6 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 18 Aug 2021 15:34:50 +0200 Subject: [PATCH 495/903] Activate mypy for Panasonic_viera (#54547) --- homeassistant/components/panasonic_viera/__init__.py | 2 +- homeassistant/components/panasonic_viera/config_flow.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index e187b7c18a5..ab63b535e80 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -1,7 +1,7 @@ """The Panasonic Viera integration.""" from functools import partial import logging -from urllib.request import HTTPError, URLError +from urllib.error import HTTPError, URLError from panasonic_viera import EncryptionRequired, Keys, RemoteControl, SOAPError import voluptuous as vol diff --git a/homeassistant/components/panasonic_viera/config_flow.py b/homeassistant/components/panasonic_viera/config_flow.py index 42400e7348c..d1c6461de21 100644 --- a/homeassistant/components/panasonic_viera/config_flow.py +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -1,7 +1,7 @@ """Config flow for Panasonic Viera TV integration.""" from functools import partial import logging -from urllib.request import URLError +from urllib.error import URLError from panasonic_viera import TV_TYPE_ENCRYPTED, RemoteControl, SOAPError import voluptuous as vol diff --git a/mypy.ini b/mypy.ini index 3108f73a49e..cb6adf5d62e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1550,9 +1550,6 @@ ignore_errors = true [mypy-homeassistant.components.ozw.*] ignore_errors = true -[mypy-homeassistant.components.panasonic_viera.*] -ignore_errors = true - [mypy-homeassistant.components.philips_js.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 6a863355afc..73081ddfc53 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -109,7 +109,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.onvif.*", "homeassistant.components.ovo_energy.*", "homeassistant.components.ozw.*", - "homeassistant.components.panasonic_viera.*", "homeassistant.components.philips_js.*", "homeassistant.components.ping.*", "homeassistant.components.pioneer.*", From 07c0fc9ebad599087d47c8916c916850c8ed2e9f Mon Sep 17 00:00:00 2001 From: SmaginPV Date: Wed, 18 Aug 2021 16:53:17 +0300 Subject: [PATCH 496/903] Remove deprecated Xiaomi Miio fan speeds (#54182) --- homeassistant/components/xiaomi_miio/fan.py | 111 -------------------- 1 file changed, 111 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 35c3765d985..87e8fa0ca2a 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -420,20 +420,12 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): self._supported_features = 0 self._speed_count = 100 self._preset_modes = [] - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = [] @property def supported_features(self): """Flag supported features.""" return self._supported_features - # the speed_list attribute is deprecated, support will end with release 2021.7 - @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return self._speed_list - @property def speed_count(self): """Return the number of speeds of the fan supported.""" @@ -516,9 +508,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): "Turning the miio device on failed.", self._device.on ) - # Remove the async_set_speed call is async_set_percentage and async_set_preset_modes have been implemented - if speed: - await self.async_set_speed(speed) # If operation mode was set the device must not be turned on. if percentage: await self.async_set_percentage(percentage) @@ -610,61 +599,39 @@ class XiaomiAirPurifier(XiaomiGenericDevice): if self._model == MODEL_AIRPURIFIER_PRO: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO - # SUPPORT_SET_SPEED was disabled - # the device supports preset_modes only self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER_PRO elif self._model == MODEL_AIRPURIFIER_PRO_V7: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 - # SUPPORT_SET_SPEED was disabled - # the device supports preset_modes only self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO_V7 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER_PRO_V7 elif self._model in [MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H]: self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S - # SUPPORT_SET_SPEED was disabled - # the device supports preset_modes only self._preset_modes = PRESET_MODES_AIRPURIFIER_2S self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER_2S elif self._model in MODELS_PURIFIER_MIOT: self._device_features = FEATURE_FLAGS_AIRPURIFIER_3 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_3 - # SUPPORT_SET_SPEED was disabled - # the device supports preset_modes only self._preset_modes = PRESET_MODES_AIRPURIFIER_3 self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE self._speed_count = 3 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER_3 elif self._model == MODEL_AIRPURIFIER_V3: self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 - # SUPPORT_SET_SPEED was disabled - # the device supports preset_modes only self._preset_modes = PRESET_MODES_AIRPURIFIER_V3 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER_V3 else: self._device_features = FEATURE_FLAGS_AIRPURIFIER self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER self._preset_modes = PRESET_MODES_AIRPURIFIER self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = [] self._state_attrs.update( {attribute: None for attribute in self._available_attributes} @@ -693,15 +660,6 @@ class XiaomiAirPurifier(XiaomiGenericDevice): return None - # the speed attribute is deprecated, support will end with release 2021.7 - @property - def speed(self): - """Return the current speed.""" - if self._state: - return AirpurifierOperationMode(self._state_attrs[ATTR_MODE]).name - - return None - async def async_set_percentage(self, percentage: int) -> None: """Set the percentage of the fan. @@ -731,21 +689,6 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self.PRESET_MODE_MAPPING[preset_mode], ) - # the async_set_speed function is deprecated, support will end with release 2021.7 - # it is added here only for compatibility with legacy speeds - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if self.supported_features & SUPPORT_SET_SPEED == 0: - return - - _LOGGER.debug("Setting the operation mode to: %s", speed) - - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirpurifierOperationMode[speed.title()], - ) - async def async_set_led_on(self): """Turn the led on.""" if self._device_features & FEATURE_SET_LED == 0: @@ -892,15 +835,6 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): return None - # the speed attribute is deprecated, support will end with release 2021.7 - @property - def speed(self): - """Return the current speed.""" - if self._state: - return AirpurifierMiotOperationMode(self._mode).name - - return None - async def async_set_percentage(self, percentage: int) -> None: """Set the percentage of the fan. @@ -933,23 +867,6 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): self._mode = self.PRESET_MODE_MAPPING[preset_mode].value self.async_write_ha_state() - # the async_set_speed function is deprecated, support will end with release 2021.7 - # it is added here only for compatibility with legacy speeds - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if self.supported_features & SUPPORT_SET_SPEED == 0: - return - - _LOGGER.debug("Setting the operation mode to: %s", speed) - - if await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirpurifierMiotOperationMode[speed.title()], - ): - self._mode = AirpurifierMiotOperationMode[speed.title()].value - self.async_write_ha_state() - class XiaomiAirFresh(XiaomiGenericDevice): """Representation of a Xiaomi Air Fresh.""" @@ -974,8 +891,6 @@ class XiaomiAirFresh(XiaomiGenericDevice): self._device_features = FEATURE_FLAGS_AIRFRESH self._available_attributes = AVAILABLE_ATTRIBUTES_AIRFRESH - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRFRESH self._speed_count = 4 self._preset_modes = PRESET_MODES_AIRFRESH self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE @@ -1005,15 +920,6 @@ class XiaomiAirFresh(XiaomiGenericDevice): return None - # the speed attribute is deprecated, support will end with release 2021.7 - @property - def speed(self): - """Return the current speed.""" - if self._state: - return AirfreshOperationMode(self._mode).name - - return None - async def async_set_percentage(self, percentage: int) -> None: """Set the percentage of the fan. @@ -1049,23 +955,6 @@ class XiaomiAirFresh(XiaomiGenericDevice): self._mode = self.PRESET_MODE_MAPPING[preset_mode].value self.async_write_ha_state() - # the async_set_speed function is deprecated, support will end with release 2021.7 - # it is added here only for compatibility with legacy speeds - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if self.supported_features & SUPPORT_SET_SPEED == 0: - return - - _LOGGER.debug("Setting the operation mode to: %s", speed) - - if await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirfreshOperationMode[speed.title()], - ): - self._mode = AirfreshOperationMode[speed.title()].value - self.async_write_ha_state() - async def async_set_led_on(self): """Turn the led on.""" if self._device_features & FEATURE_SET_LED == 0: From 27849426fe37094516fe78b138a81fa32695e9ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 18 Aug 2021 15:54:11 +0200 Subject: [PATCH 497/903] Remove last_reset attribute and set state class to total_increasing for Integration sensors (#54815) --- homeassistant/components/integration/sensor.py | 13 ++----------- tests/components/integration/test_sensor.py | 17 +++++------------ 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index cabcb2fd394..cf91fd46dad 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -5,11 +5,10 @@ import logging import voluptuous as vol from homeassistant.components.sensor import ( - ATTR_LAST_RESET, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, PLATFORM_SCHEMA, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -28,7 +27,6 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util import dt as dt_util # mypy: allow-untyped-defs, no-check-untyped-defs @@ -124,25 +122,18 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] - self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() state = await self.async_get_last_state() - self._attr_last_reset = dt_util.utcnow() if state: try: self._state = Decimal(state.state) except (DecimalException, ValueError) as err: _LOGGER.warning("Could not restore last state: %s", err) else: - last_reset = dt_util.parse_datetime( - state.attributes.get(ATTR_LAST_RESET, "") - ) - self._attr_last_reset = ( - last_reset if last_reset else dt_util.utc_from_timestamp(0) - ) self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) self._unit_of_measurement = state.attributes.get( diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 36d3d4b3b30..e8aaf906936 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -2,7 +2,7 @@ from datetime import timedelta from unittest.mock import patch -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -39,8 +39,7 @@ async def test_state(hass) -> None: state = hass.states.get("sensor.integration") assert state is not None - assert state.attributes.get("last_reset") == now.isoformat() - assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING assert "device_class" not in state.attributes future_now = dt_util.utcnow() + timedelta(seconds=3600) @@ -58,8 +57,7 @@ async def test_state(hass) -> None: assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR assert state.attributes.get("device_class") == DEVICE_CLASS_ENERGY - assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT - assert state.attributes.get("last_reset") == now.isoformat() + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING async def test_restore_state(hass: HomeAssistant) -> None: @@ -71,7 +69,6 @@ async def test_restore_state(hass: HomeAssistant) -> None: "sensor.integration", "100.0", { - "last_reset": "2019-10-06T21:00:00", "device_class": DEVICE_CLASS_ENERGY, "unit_of_measurement": ENERGY_KILO_WATT_HOUR, }, @@ -97,7 +94,6 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state.state == "100.00" assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR assert state.attributes.get("device_class") == DEVICE_CLASS_ENERGY - assert state.attributes.get("last_reset") == "2019-10-06T21:00:00" async def test_restore_state_failed(hass: HomeAssistant) -> None: @@ -108,9 +104,7 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: State( "sensor.integration", "INVALID", - { - "last_reset": "2019-10-06T21:00:00.000000", - }, + {}, ), ), ) @@ -131,8 +125,7 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: assert state assert state.state == "0" assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR - assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT - assert state.attributes.get("last_reset") != "2019-10-06T21:00:00" + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING assert "device_class" not in state.attributes From 28e421dc5338c7c0d45eb0c2e93501392895bcef Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 15:54:22 +0200 Subject: [PATCH 498/903] Remove `last_reset` attribute and set state class to `total_increasing` for spider energy sensors (#54822) --- homeassistant/components/spider/sensor.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/spider/sensor.py b/homeassistant/components/spider/sensor.py index bf1ab0b18be..8b38fdbe6f6 100644 --- a/homeassistant/components/spider/sensor.py +++ b/homeassistant/components/spider/sensor.py @@ -1,7 +1,9 @@ """Support for Spider Powerplugs (energy & power).""" -from datetime import datetime - -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -9,7 +11,6 @@ from homeassistant.const import ( POWER_WATT, ) from homeassistant.helpers.entity import DeviceInfo -from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -31,7 +32,7 @@ class SpiderPowerPlugEnergy(SensorEntity): _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_device_class = DEVICE_CLASS_ENERGY - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_state_class = STATE_CLASS_TOTAL_INCREASING def __init__(self, api, power_plug) -> None: """Initialize the Spider Power Plug.""" @@ -63,13 +64,6 @@ class SpiderPowerPlugEnergy(SensorEntity): """Return todays energy usage in Kwh.""" return round(self.power_plug.today_energy_consumption / 1000, 2) - @property - def last_reset(self) -> datetime: - """Return the time when last reset; Every midnight.""" - return dt_util.as_utc( - dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) - ) - def update(self) -> None: """Get the latest data.""" self.power_plug = self.api.get_power_plug(self.power_plug.id) From 99477950688ce06be8b99fa4e6a9a2195ce4f939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 18 Aug 2021 17:26:54 +0300 Subject: [PATCH 499/903] Treat Huawei LTE error code 100006 as unsupported functionality (#54253) Internet says 100006 could mean "parameter error", B2368-F20 is reported to respond with that to lan/HostInfo requests. While at it, handle the special case error codes and the "real" not supported exception in the same block. Closes https://github.com/home-assistant/core/issues/53280 --- homeassistant/components/huawei_lte/__init__.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index e220975dbf1..ec9281659f5 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -185,11 +185,6 @@ class Router: _LOGGER.debug("Getting %s for subscribers %s", key, self.subscriptions[key]) try: self.data[key] = func() - except ResponseErrorNotSupportedException: - _LOGGER.info( - "%s not supported by device, excluding from future updates", key - ) - self.subscriptions.pop(key) except ResponseErrorLoginRequiredException: if isinstance(self.connection, AuthorizedConnection): _LOGGER.debug("Trying to authorize again") @@ -206,7 +201,13 @@ class Router: ) self.subscriptions.pop(key) except ResponseErrorException as exc: - if exc.code != -1: + if not isinstance( + exc, ResponseErrorNotSupportedException + ) and exc.code not in ( + # additional codes treated as unusupported + -1, + 100006, + ): raise _LOGGER.info( "%s apparently not supported by device, excluding from future updates", From 4892f6b0945a234ecd449ef7bf3d320e164d9f0e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 16:31:10 +0200 Subject: [PATCH 500/903] Remove `last_reset` attribute and set state class to `total_increasing` for sense energy sensors (#54825) --- homeassistant/components/sense/sensor.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 69cae55ff31..6be24a73a21 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -1,7 +1,9 @@ """Support for monitoring a Sense energy sensor.""" -import datetime - -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( ATTR_ATTRIBUTION, DEVICE_CLASS_ENERGY, @@ -12,7 +14,6 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -import homeassistant.util.dt as dt_util from .const import ( ACTIVE_NAME, @@ -223,7 +224,7 @@ class SenseTrendsSensor(SensorEntity): """Implementation of a Sense energy sensor.""" _attr_device_class = DEVICE_CLASS_ENERGY - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_state_class = STATE_CLASS_TOTAL_INCREASING _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON @@ -258,13 +259,6 @@ class SenseTrendsSensor(SensorEntity): """Return if entity is available.""" return self._had_any_update and self._coordinator.last_update_success - @property - def last_reset(self) -> datetime.datetime: - """Return the time when the sensor was last reset, if any.""" - if self._sensor_type == "DAY": - return dt_util.start_of_local_day() - return None - @callback def _async_update(self): """Track if we had an update so we do not report zero data.""" From 6eba04c454fb9c7bdb30bc41953256e4fa232f63 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 16:45:16 +0200 Subject: [PATCH 501/903] Remove last_reset attribute from wemo energy sensors (#54821) --- homeassistant/components/wemo/sensor.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index b9d22e6995a..f1f32e8b909 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -1,10 +1,11 @@ """Support for power sensors in WeMo Insight devices.""" import asyncio -from datetime import datetime, timedelta +from datetime import timedelta from typing import Callable from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -16,7 +17,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import StateType -from homeassistant.util import Throttle, convert, dt +from homeassistant.util import Throttle, convert from .const import DOMAIN as WEMO_DOMAIN from .entity import WemoSubscriptionEntity @@ -113,15 +114,10 @@ class InsightTodayEnergy(InsightSensor): key="todaymw", name="Today Energy", device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, unit_of_measurement=ENERGY_KILO_WATT_HOUR, ) - @property - def last_reset(self) -> datetime: - """Return the time when the sensor was initialized.""" - return dt.start_of_local_day() - @property def native_value(self) -> StateType: """Return the current energy use today.""" From 09fbc38baaf95754edf7a3f7720c4eb337557924 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 16:45:30 +0200 Subject: [PATCH 502/903] Remove last_reset attribute from keba energy sensors (#54828) --- homeassistant/components/keba/sensor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py index 2c0108ca1cd..a1e0387c707 100644 --- a/homeassistant/components/keba/sensor.py +++ b/homeassistant/components/keba/sensor.py @@ -1,9 +1,12 @@ """Support for KEBA charging station sensors.""" +from __future__ import annotations + from homeassistant.components.sensor import ( DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -12,7 +15,6 @@ from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, POWER_KILO_WATT, ) -from homeassistant.util import dt from . import DOMAIN @@ -74,8 +76,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= name="Total Energy", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), ), ] From 6aca3b326fa02ab5ba68b8acadd24048b18e07e2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 16:57:19 +0200 Subject: [PATCH 503/903] Remove `last_reset` attribute and set state class to `total_increasing` for fronius energy sensors (#54830) --- homeassistant/components/fronius/sensor.py | 30 ++++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 5141c79f31b..0fb046e8aa1 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -32,7 +33,6 @@ from homeassistant.core import callback 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.util import dt _LOGGER = logging.getLogger(__name__) @@ -64,6 +64,17 @@ PREFIX_DEVICE_CLASS_MAPPING = [ ("voltage", DEVICE_CLASS_VOLTAGE), ] +PREFIX_STATE_CLASS_MAPPING = [ + ("state_of_charge", STATE_CLASS_MEASUREMENT), + ("temperature", STATE_CLASS_MEASUREMENT), + ("power_factor", STATE_CLASS_MEASUREMENT), + ("power", STATE_CLASS_MEASUREMENT), + ("energy", STATE_CLASS_TOTAL_INCREASING), + ("current", STATE_CLASS_MEASUREMENT), + ("timestamp", STATE_CLASS_MEASUREMENT), + ("voltage", STATE_CLASS_MEASUREMENT), +] + def _device_id_validator(config): """Ensure that inverters have default id 1 and other devices 0.""" @@ -281,8 +292,6 @@ class FroniusPowerFlow(FroniusAdapter): class FroniusTemplateSensor(SensorEntity): """Sensor for the single values (e.g. pv power, ac power).""" - _attr_state_class = STATE_CLASS_MEASUREMENT - def __init__(self, parent: FroniusAdapter, key: str) -> None: """Initialize a singular value sensor.""" self._key = key @@ -292,6 +301,10 @@ class FroniusTemplateSensor(SensorEntity): if self._key.startswith(prefix): self._attr_device_class = device_class break + for prefix, state_class in PREFIX_STATE_CLASS_MAPPING: + if self._key.startswith(prefix): + self._attr_state_class = state_class + break @property def should_poll(self): @@ -311,17 +324,6 @@ class FroniusTemplateSensor(SensorEntity): self._attr_native_value = round(self._attr_native_value, 2) self._attr_native_unit_of_measurement = state.get("unit") - @property - def last_reset(self) -> dt.dt.datetime | None: - """Return the time when the sensor was last reset, if it is a meter.""" - if self._key.endswith("day"): - return dt.start_of_local_day() - if self._key.endswith("year"): - return dt.start_of_local_day(dt.dt.date(dt.now().year, 1, 1)) - if self._key.endswith("total") or self._key.startswith("energy_real"): - return dt.utc_from_timestamp(0) - return None - async def async_added_to_hass(self): """Register at parent component for updates.""" self.async_on_remove(self._parent.register(self)) From 9c7ea786a786bf917080e04aa006b61bcd24407c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 16:57:38 +0200 Subject: [PATCH 504/903] Remove `last_reset` attribute and set state class to `total_increasing` for saj energy sensors (#54813) Co-authored-by: Franck Nijhof --- homeassistant/components/saj/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 795823b9e9f..8e59899de27 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -34,7 +35,6 @@ from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later -from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -177,10 +177,10 @@ class SAJsensor(SensorEntity): self._serialnumber = serialnumber self._state = self._sensor.value - if pysaj_sensor.name in ("current_power", "total_yield", "temperature"): + if pysaj_sensor.name in ("current_power", "temperature"): self._attr_state_class = STATE_CLASS_MEASUREMENT if pysaj_sensor.name == "total_yield": - self._attr_last_reset = dt_util.utc_from_timestamp(0) + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING @property def name(self): From e98d50f6d1e6012bcd4d0735f1476306851fc305 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 16:58:13 +0200 Subject: [PATCH 505/903] Remove `last_reset` attribute and set state class to `total_increasing` for mysensors energy sensors (#54827) --- homeassistant/components/mysensors/sensor.py | 5 ++--- tests/components/mysensors/test_sensor.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 68fdf2a21b2..94a9cde1df2 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -9,6 +9,7 @@ from homeassistant.components import mysensors from homeassistant.components.sensor import ( DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -42,7 +43,6 @@ 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.util.dt import utc_from_timestamp from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .helpers import on_unload @@ -122,8 +122,7 @@ SENSORS: dict[str, SensorEntityDescription] = { key="V_KWH", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), "V_LIGHT_LEVEL": SensorEntityDescription( key="V_LIGHT_LEVEL", diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 880226ced60..18d88a24206 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( ATTR_LAST_RESET, ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -24,7 +25,6 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant -from homeassistant.util.dt import utc_from_timestamp from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem from tests.common import MockConfigEntry @@ -92,8 +92,7 @@ async def test_energy_sensor( assert state.state == "18000" assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT - assert state.attributes[ATTR_LAST_RESET] == utc_from_timestamp(0).isoformat() + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING async def test_sound_sensor( From a6ac55390a3d6c041de17921d44c2c482d39058e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 17:14:02 +0200 Subject: [PATCH 506/903] Remove `last_reset` attribute and set state class to `total_increasing` for smartthings energy sensors (#54824) Co-authored-by: Franck Nijhof --- .../components/smartthings/sensor.py | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index a8e6c0472e9..7c682486f04 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -3,12 +3,15 @@ from __future__ import annotations from collections import namedtuple from collections.abc import Sequence -from datetime import datetime from pysmartthings import Attribute, Capability from pysmartthings.device import DeviceEntity -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( AREA_SQUARE_METERS, CONCENTRATION_PARTS_PER_MILLION, @@ -33,7 +36,6 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, VOLUME_CUBIC_METERS, ) -from homeassistant.util.dt import utc_from_timestamp from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN @@ -133,7 +135,7 @@ CAPABILITY_TO_SENSORS = { "Energy Meter", ENERGY_KILO_WATT_HOUR, DEVICE_CLASS_ENERGY, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, ) ], Capability.equivalent_carbon_dioxide_measurement: [ @@ -507,13 +509,6 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): unit = self._device.status.attributes[self._attribute].unit return UNITS.get(unit, unit) if unit else self._default_unit - @property - def last_reset(self) -> datetime | None: - """Return the time when the sensor was last reset, if any.""" - if self._attribute == Attribute.energy: - return utc_from_timestamp(0) - return None - class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Three Axis Sensor.""" @@ -554,8 +549,9 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): """Init the class.""" super().__init__(device) self.report_name = report_name - # This is an exception for STATE_CLASS_MEASUREMENT per @balloob self._attr_state_class = STATE_CLASS_MEASUREMENT + if self.report_name != "power": + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING @property def name(self) -> str: @@ -590,10 +586,3 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): if self.report_name == "power": return POWER_WATT return ENERGY_KILO_WATT_HOUR - - @property - def last_reset(self) -> datetime | None: - """Return the time when the sensor was last reset, if any.""" - if self.report_name != "power": - return utc_from_timestamp(0) - return None From c1595d5ceb7fbf7d097ef83d6c28ba15bd8ebb3a Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 18 Aug 2021 11:53:00 -0400 Subject: [PATCH 507/903] Only show zwave_js command classes that are on the node (#54794) --- .../components/zwave_js/device_condition.py | 5 ++++- tests/components/zwave_js/test_device_condition.py | 13 ++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index b419230a0bd..4ae8142ec9e 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -197,7 +197,10 @@ async def async_get_condition_capabilities( "extra_fields": vol.Schema( { vol.Required(ATTR_COMMAND_CLASS): vol.In( - {cc.value: cc.name for cc in CommandClass} + { + CommandClass(cc.id).value: CommandClass(cc.id).name + for cc in sorted(node.command_classes, key=lambda cc: cc.name) # type: ignore[no-any-return] + } ), vol.Required(ATTR_PROPERTY): cv.string, vol.Optional(ATTR_PROPERTY_KEY): cv.string, diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index eef672c4c5b..0256981a726 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -430,7 +430,18 @@ async def test_get_condition_capabilities_value( ) assert capabilities and "extra_fields" in capabilities - cc_options = [(cc.value, cc.name) for cc in CommandClass] + cc_options = [ + (133, "ASSOCIATION"), + (128, "BATTERY"), + (112, "CONFIGURATION"), + (98, "DOOR_LOCK"), + (122, "FIRMWARE_UPDATE_MD"), + (114, "MANUFACTURER_SPECIFIC"), + (113, "ALARM"), + (152, "SECURITY"), + (99, "USER_CODE"), + (134, "VERSION"), + ] assert voluptuous_serialize.convert( capabilities["extra_fields"], custom_serializer=cv.custom_serializer From 041ba2ec3a179594d55b0a3c683f8177a43031f9 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Wed, 18 Aug 2021 17:58:07 +0200 Subject: [PATCH 508/903] Fix BMW remote services in rest_of_world & north_america (#54726) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 8131ac1415c..a7c4c5c837b 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.18"], + "requirements": ["bimmer_connected==0.7.19"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 0eb8046c327..021d225c362 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ beautifulsoup4==4.9.3 bellows==0.26.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.18 +bimmer_connected==0.7.19 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd478a18538..ecb66f6bad4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -226,7 +226,7 @@ base36==0.1.1 bellows==0.26.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.18 +bimmer_connected==0.7.19 # homeassistant.components.blebox blebox_uniapi==1.3.3 From bce7c73925b3ffed6978dd342a120dfd7b7c171f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 17:58:32 +0200 Subject: [PATCH 509/903] Remove `last_reset` attribute from and set state class to `total_increasing` for enphase_envoy energy sensors (#54831) --- homeassistant/components/enphase_envoy/const.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 1d0dfba8990..ff42ef23746 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -3,10 +3,10 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntityDescription, ) from homeassistant.const import DEVICE_CLASS_ENERGY, ENERGY_WATT_HOUR, POWER_WATT -from homeassistant.util import dt DOMAIN = "enphase_envoy" @@ -41,9 +41,8 @@ SENSORS = ( key="lifetime_production", name="Lifetime Energy Production", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), ), SensorEntityDescription( key="consumption", @@ -69,9 +68,8 @@ SENSORS = ( key="lifetime_consumption", name="Lifetime Energy Consumption", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), ), SensorEntityDescription( key="inverters", From 8d37fd08c79f5cc4acbc3e1f5c026b8abcb173f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Lov=C3=A9n?= Date: Wed, 18 Aug 2021 17:59:31 +0200 Subject: [PATCH 510/903] Fix integration sensors sometimes not getting device_class or unit_of_measurement (#54802) --- homeassistant/components/integration/sensor.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index cf91fd46dad..b8e72c3be5c 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -145,12 +145,6 @@ class IntegrationSensor(RestoreEntity, SensorEntity): """Handle the sensor state changes.""" old_state = event.data.get("old_state") new_state = event.data.get("new_state") - if ( - old_state is None - or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) - or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): - return if self._unit_of_measurement is None: unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -162,6 +156,14 @@ class IntegrationSensor(RestoreEntity, SensorEntity): and new_state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER ): self._attr_device_class = DEVICE_CLASS_ENERGY + + if ( + old_state is None + or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + ): + return + try: # integration as the Riemann integral of previous measures. area = 0 From 5d19575a846d8cc6d94bccb4e645896c0de870e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Aug 2021 11:00:09 -0500 Subject: [PATCH 511/903] Exclude global scope IPv6 when setting up zeroconf interfaces (#54632) --- homeassistant/components/zeroconf/__init__.py | 10 +++++++--- tests/components/zeroconf/test_init.py | 12 ++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index a85236b6a07..6829c9c5e17 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -162,10 +162,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: interfaces.extend( ipv4["address"] for ipv4 in ipv4s - if not ipaddress.ip_address(ipv4["address"]).is_loopback + if not ipaddress.IPv4Address(ipv4["address"]).is_loopback ) - if adapter["ipv6"] and adapter["index"] not in interfaces: - interfaces.append(adapter["index"]) + if ipv6s := adapter["ipv6"]: + for ipv6_addr in ipv6s: + address = ipv6_addr["address"] + v6_ip_address = ipaddress.IPv6Address(address) + if not v6_ip_address.is_global and not v6_ip_address.is_loopback: + interfaces.append(ipv6_addr["address"]) aio_zc = await _async_get_instance(hass, **zc_args) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 0db8f0f5227..a284c91e4f4 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -799,7 +799,14 @@ async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zero hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert mock_zc.mock_calls[0] == call( - interfaces=[1, "192.168.1.5", "172.16.1.5", 3], ip_version=IPVersion.All + interfaces=[ + "2001:db8::", + "fe80::1234:5678:9abc:def0", + "192.168.1.5", + "172.16.1.5", + "fe80::dead:beef:dead:beef", + ], + ip_version=IPVersion.All, ) @@ -862,5 +869,6 @@ async def test_async_detect_interfaces_explicitly_set_ipv6(hass, mock_async_zero await hass.async_block_till_done() assert mock_zc.mock_calls[0] == call( - interfaces=["192.168.1.5", 1], ip_version=IPVersion.All + interfaces=["192.168.1.5", "fe80::dead:beef:dead:beef"], + ip_version=IPVersion.All, ) From bca9360d523a173bffad465b118f0197c163c38a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 18:25:33 +0200 Subject: [PATCH 512/903] Remove last_reset attribute from tasmota energy sensors (#54836) --- homeassistant/components/tasmota/sensor.py | 24 ++++++---------------- tests/components/tasmota/test_sensor.py | 5 +++-- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 29144370ae7..39ee97d1648 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime -import logging from typing import Any from hatasmota import const as hc, sensor as tasmota_sensor, status_sensor @@ -10,7 +9,11 @@ from hatasmota.entity import TasmotaEntity as HATasmotaEntity from hatasmota.models import DiscoveryHashType from homeassistant.components import sensor -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -49,14 +52,11 @@ 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.util import dt as dt_util from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate -_LOGGER = logging.getLogger(__name__) - DEVICE_CLASS = "device_class" STATE_CLASS = "state_class" ICON = "icon" @@ -121,7 +121,7 @@ SENSOR_DEVICE_CLASS_ICON_MAP = { hc.SENSOR_TODAY: {DEVICE_CLASS: DEVICE_CLASS_ENERGY}, hc.SENSOR_TOTAL: { DEVICE_CLASS: DEVICE_CLASS_ENERGY, - STATE_CLASS: STATE_CLASS_MEASUREMENT, + STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, hc.SENSOR_TOTAL_START_TIME: {ICON: "mdi:progress-clock"}, hc.SENSOR_TVOC: {ICON: "mdi:air-filter"}, @@ -188,7 +188,6 @@ async def async_setup_entry( class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): """Representation of a Tasmota sensor.""" - _attr_last_reset = None _tasmota_entity: tasmota_sensor.TasmotaSensor def __init__(self, **kwds: Any) -> None: @@ -212,17 +211,6 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): self._state_timestamp = state else: self._state = state - if "last_reset" in kwargs: - try: - last_reset_dt = dt_util.parse_datetime(kwargs["last_reset"]) - last_reset = dt_util.as_utc(last_reset_dt) if last_reset_dt else None - if last_reset is None: - raise ValueError - self._attr_last_reset = last_reset - except ValueError: - _LOGGER.warning( - "Invalid last_reset timestamp '%s'", kwargs["last_reset"] - ) self.async_write_ha_state() @property diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index fc1e7fd624b..adb73dcf334 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -255,6 +255,9 @@ async def test_indexed_sensor_state_via_mqtt2(hass, mqtt_mock, setup_tasmota): state = hass.states.get("sensor.tasmota_energy_total") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) + assert ( + state.attributes[sensor.ATTR_STATE_CLASS] == sensor.STATE_CLASS_TOTAL_INCREASING + ) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") state = hass.states.get("sensor.tasmota_energy_total") @@ -269,7 +272,6 @@ async def test_indexed_sensor_state_via_mqtt2(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("sensor.tasmota_energy_total") assert state.state == "1.2" - assert state.attributes["last_reset"] == "2018-11-23T15:33:47+00:00" # Test polled state update async_fire_mqtt_message( @@ -279,7 +281,6 @@ async def test_indexed_sensor_state_via_mqtt2(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("sensor.tasmota_energy_total") assert state.state == "5.6" - assert state.attributes["last_reset"] == "2018-11-23T16:33:47+00:00" async def test_bad_indexed_sensor_state_via_mqtt(hass, mqtt_mock, setup_tasmota): From e7a0604a40921cdbf151521b4f8555e008504391 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Aug 2021 11:36:13 -0500 Subject: [PATCH 513/903] Make yeelight discovery async (#54711) --- homeassistant/components/yeelight/__init__.py | 271 +++++++++++------- .../components/yeelight/config_flow.py | 82 +++--- homeassistant/components/yeelight/light.py | 4 +- .../components/yeelight/manifest.json | 2 +- requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/yeelight/__init__.py | 58 +++- .../components/yeelight/test_binary_sensor.py | 13 +- tests/components/yeelight/test_config_flow.py | 175 ++++++++--- tests/components/yeelight/test_init.py | 112 ++++---- tests/components/yeelight/test_light.py | 17 +- 11 files changed, 478 insertions(+), 258 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index c9e654bca9a..0ea4eb8e84f 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -2,13 +2,17 @@ from __future__ import annotations import asyncio +import contextlib from datetime import timedelta import logging +from urllib.parse import urlparse +from async_upnp_client.search import SSDPListener import voluptuous as vol -from yeelight import BulbException, discover_bulbs +from yeelight import BulbException from yeelight.aio import KEY_CONNECTED, AsyncBulb +from homeassistant import config_entries from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady from homeassistant.const import ( CONF_DEVICES, @@ -24,6 +28,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -69,6 +74,12 @@ ACTIVE_COLOR_FLOWING = "1" NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light" DISCOVERY_INTERVAL = timedelta(seconds=60) +SSDP_TARGET = ("239.255.255.250", 1982) +SSDP_ST = "wifi_bulb" +DISCOVERY_ATTEMPTS = 3 +DISCOVERY_SEARCH_INTERVAL = timedelta(seconds=2) +DISCOVERY_TIMEOUT = 2 + YEELIGHT_RGB_TRANSITION = "RGBTransition" YEELIGHT_HSV_TRANSACTION = "HSVTransition" @@ -193,20 +204,12 @@ async def _async_initialize( hass.config_entries.async_setup_platforms(entry, PLATFORMS) if not device: + # get device and start listening for local pushes device = await _async_get_device(hass, host, entry) + + await device.async_setup() entry_data[DATA_DEVICE] = device - # start listening for local pushes - await device.bulb.async_listen(device.async_update_callback) - - # register stop callback to shutdown listening for local pushes - async def async_stop_listen_task(event): - """Stop listen thread.""" - _LOGGER.debug("Shutting down Yeelight Listener") - await device.bulb.async_stop_listening() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_listen_task) - entry.async_on_unload( async_dispatcher_connect( hass, DEVICE_INITIALIZED.format(host), _async_load_platforms @@ -251,7 +254,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.data.get(CONF_HOST): try: device = await _async_get_device(hass, entry.data[CONF_HOST], entry) - except OSError as ex: + except BulbException as ex: # If CONF_ID is not valid we cannot fallback to discovery # so we must retry by raising ConfigEntryNotReady if not entry.data.get(CONF_ID): @@ -267,16 +270,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex return True - # discovery - scanner = YeelightScanner.async_get(hass) - - async def _async_from_discovery(host: str) -> None: + async def _async_from_discovery(capabilities: dict[str, str]) -> None: + host = urlparse(capabilities["location"]).hostname try: await _async_initialize(hass, entry, host) except BulbException: _LOGGER.exception("Failed to connect to bulb at %s", host) - scanner.async_register_callback(entry.data[CONF_ID], _async_from_discovery) + # discovery + scanner = YeelightScanner.async_get(hass) + await scanner.async_register_callback(entry.data[CONF_ID], _async_from_discovery) return True @@ -294,10 +297,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: scanner = YeelightScanner.async_get(hass) scanner.async_unregister_callback(entry.data[CONF_ID]) - device = entry_data[DATA_DEVICE] - _LOGGER.debug("Shutting down Yeelight Listener") - await device.bulb.async_stop_listening() - _LOGGER.debug("Yeelight Listener stopped") + if DATA_DEVICE in entry_data: + device = entry_data[DATA_DEVICE] + _LOGGER.debug("Shutting down Yeelight Listener") + await device.bulb.async_stop_listening() + _LOGGER.debug("Yeelight Listener stopped") data_config_entries.pop(entry.entry_id) @@ -307,9 +311,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_unique_name(capabilities: dict) -> str: """Generate name from capabilities.""" - model = capabilities["model"] - unique_id = capabilities["id"] - return f"yeelight_{model}_{unique_id}" + model = str(capabilities["model"]).replace("_", " ").title() + short_id = hex(int(capabilities["id"], 16)) + return f"Yeelight {model} {short_id}" async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): @@ -333,88 +337,147 @@ class YeelightScanner: def __init__(self, hass: HomeAssistant) -> None: """Initialize class.""" self._hass = hass - self._seen = {} self._callbacks = {} - self._scan_task = None + self._host_discovered_events = {} + self._unique_id_capabilities = {} + self._host_capabilities = {} + self._track_interval = None + self._listener = None + self._connected_event = None - async def _async_scan(self): - _LOGGER.debug("Yeelight scanning") - # Run 3 times as packets can get lost - for _ in range(3): - devices = await self._hass.async_add_executor_job(discover_bulbs) - for device in devices: - unique_id = device["capabilities"]["id"] - if unique_id in self._seen: - continue - host = device["ip"] - self._seen[unique_id] = host - _LOGGER.debug("Yeelight discovered at %s", host) - if unique_id in self._callbacks: - self._hass.async_create_task(self._callbacks[unique_id](host)) - self._callbacks.pop(unique_id) - if len(self._callbacks) == 0: - self._async_stop_scan() + async def async_setup(self): + """Set up the scanner.""" + if self._connected_event: + await self._connected_event.wait() + return + self._connected_event = asyncio.Event() - await asyncio.sleep(DISCOVERY_INTERVAL.total_seconds()) - self._scan_task = self._hass.loop.create_task(self._async_scan()) + async def _async_connected(): + self._listener.async_search() + self._connected_event.set() + + self._listener = SSDPListener( + async_callback=self._async_process_entry, + service_type=SSDP_ST, + target=SSDP_TARGET, + async_connect_callback=_async_connected, + ) + await self._listener.async_start() + await self._connected_event.wait() + + async def async_discover(self): + """Discover bulbs.""" + await self.async_setup() + for _ in range(DISCOVERY_ATTEMPTS): + self._listener.async_search() + await asyncio.sleep(DISCOVERY_SEARCH_INTERVAL.total_seconds()) + return self._unique_id_capabilities.values() @callback - def _async_start_scan(self): + def async_scan(self, *_): + """Send discovery packets.""" + _LOGGER.debug("Yeelight scanning") + self._listener.async_search() + + async def async_get_capabilities(self, host): + """Get capabilities via SSDP.""" + if host in self._host_capabilities: + return self._host_capabilities[host] + + host_event = asyncio.Event() + self._host_discovered_events.setdefault(host, []).append(host_event) + await self.async_setup() + + self._listener.async_search((host, SSDP_TARGET[1])) + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(host_event.wait(), timeout=DISCOVERY_TIMEOUT) + + self._host_discovered_events[host].remove(host_event) + return self._host_capabilities.get(host) + + def _async_discovered_by_ssdp(self, response): + @callback + def _async_start_flow(*_): + asyncio.create_task( + self._hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=response, + ) + ) + + # Delay starting the flow in case the discovery is the result + # of another discovery + async_call_later(self._hass, 1, _async_start_flow) + + async def _async_process_entry(self, response): + """Process a discovery.""" + _LOGGER.debug("Discovered via SSDP: %s", response) + unique_id = response["id"] + host = urlparse(response["location"]).hostname + if unique_id not in self._unique_id_capabilities: + _LOGGER.debug("Yeelight discovered with %s", response) + self._async_discovered_by_ssdp(response) + self._host_capabilities[host] = response + self._unique_id_capabilities[unique_id] = response + for event in self._host_discovered_events.get(host, []): + event.set() + if unique_id in self._callbacks: + self._hass.async_create_task(self._callbacks[unique_id](response)) + self._callbacks.pop(unique_id) + if not self._callbacks: + self._async_stop_scan() + + async def _async_start_scan(self): """Start scanning for Yeelight devices.""" _LOGGER.debug("Start scanning") - # Use loop directly to avoid home assistant track this task - self._scan_task = self._hass.loop.create_task(self._async_scan()) + await self.async_setup() + if not self._track_interval: + self._track_interval = async_track_time_interval( + self._hass, self.async_scan, DISCOVERY_INTERVAL + ) + self.async_scan() @callback def _async_stop_scan(self): """Stop scanning.""" - _LOGGER.debug("Stop scanning") - if self._scan_task is not None: - self._scan_task.cancel() - self._scan_task = None + if self._track_interval is None: + return + _LOGGER.debug("Stop scanning interval") + self._track_interval() + self._track_interval = None - @callback - def async_register_callback(self, unique_id, callback_func): + async def async_register_callback(self, unique_id, callback_func): """Register callback function.""" - host = self._seen.get(unique_id) - if host is not None: - self._hass.async_create_task(callback_func(host)) - else: - self._callbacks[unique_id] = callback_func - if len(self._callbacks) == 1: - self._async_start_scan() + if capabilities := self._unique_id_capabilities.get(unique_id): + self._hass.async_create_task(callback_func(capabilities)) + return + self._callbacks[unique_id] = callback_func + await self._async_start_scan() @callback def async_unregister_callback(self, unique_id): """Unregister callback function.""" - if unique_id not in self._callbacks: - return - self._callbacks.pop(unique_id) - if len(self._callbacks) == 0: + self._callbacks.pop(unique_id, None) + if not self._callbacks: self._async_stop_scan() class YeelightDevice: """Represents single Yeelight device.""" - def __init__(self, hass, host, config, bulb, capabilities): + def __init__(self, hass, host, config, bulb): """Initialize device.""" self._hass = hass self._config = config self._host = host self._bulb_device = bulb - self._capabilities = capabilities or {} + self._capabilities = {} self._device_type = None self._available = False self._initialized = False - - self._name = host # Default name is host - if capabilities: - # Generate name from model and id when capabilities is available - self._name = _async_unique_name(capabilities) - if config.get(CONF_NAME): - # Override default name when name is set in config - self._name = config[CONF_NAME] + self._name = None @property def bulb(self): @@ -444,7 +507,7 @@ class YeelightDevice: @property def model(self): """Return configured/autodetected device model.""" - return self._bulb_device.model + return self._bulb_device.model or self._capabilities.get("model") @property def fw_version(self): @@ -530,7 +593,8 @@ class YeelightDevice: await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES) self._available = True if not self._initialized: - await self._async_initialize_device() + self._initialized = True + async_dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) except BulbException as ex: if self._available: # just inform once _LOGGER.error( @@ -540,28 +604,18 @@ class YeelightDevice: return self._available - async def _async_get_capabilities(self): - """Request device capabilities.""" - try: - await self._hass.async_add_executor_job(self.bulb.get_capabilities) - _LOGGER.debug( - "Device %s, %s capabilities: %s", - self._host, - self.name, - self.bulb.capabilities, - ) - except BulbException as ex: - _LOGGER.error( - "Unable to get device capabilities %s, %s: %s", - self._host, - self.name, - ex, - ) - - async def _async_initialize_device(self): - await self._async_get_capabilities() - self._initialized = True - async_dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) + async def async_setup(self): + """Fetch capabilities and setup name if available.""" + scanner = YeelightScanner.async_get(self._hass) + self._capabilities = await scanner.async_get_capabilities(self._host) or {} + if name := self._config.get(CONF_NAME): + # Override default name when name is set in config + self._name = name + elif self._capabilities: + # Generate name from model and id when capabilities is available + self._name = _async_unique_name(self._capabilities) + else: + self._name = self._host # Default name is host async def async_update(self): """Update device properties and send data updated signal.""" @@ -628,6 +682,19 @@ async def _async_get_device( # Set up device bulb = AsyncBulb(host, model=model or None) - capabilities = await hass.async_add_executor_job(bulb.get_capabilities) - return YeelightDevice(hass, host, entry.options, bulb, capabilities) + device = YeelightDevice(hass, host, entry.options, bulb) + # start listening for local pushes + await device.bulb.async_listen(device.async_update_callback) + + # register stop callback to shutdown listening for local pushes + async def async_stop_listen_task(event): + """Stop listen thread.""" + _LOGGER.debug("Shutting down Yeelight Listener") + await device.bulb.async_stop_listening() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_listen_task) + ) + + return device diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index a66571cae93..d93f59535cf 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -1,8 +1,10 @@ """Config flow for Yeelight integration.""" import logging +from urllib.parse import urlparse import voluptuous as vol import yeelight +from yeelight.aio import AsyncBulb from homeassistant import config_entries, exceptions from homeassistant.components.dhcp import IP_ADDRESS @@ -19,6 +21,7 @@ from . import ( CONF_TRANSITION, DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, + YeelightScanner, _async_unique_name, ) @@ -54,6 +57,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_ip = discovery_info[IP_ADDRESS] return await self._async_handle_discovery() + async def async_step_ssdp(self, discovery_info): + """Handle discovery from ssdp.""" + self._discovered_ip = urlparse(discovery_info["location"]).hostname + await self.async_set_unique_id(discovery_info["id"]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._discovered_ip}, reload_on_update=False + ) + return await self._async_handle_discovery() + async def _async_handle_discovery(self): """Handle any discovery.""" self.context[CONF_HOST] = self._discovered_ip @@ -62,7 +74,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_in_progress") try: - self._discovered_model = await self._async_try_connect(self._discovered_ip) + self._discovered_model = await self._async_try_connect( + self._discovered_ip, raise_on_progress=True + ) except CannotConnect: return self.async_abort(reason="cannot_connect") @@ -96,7 +110,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not user_input.get(CONF_HOST): return await self.async_step_pick_device() try: - model = await self._async_try_connect(user_input[CONF_HOST]) + model = await self._async_try_connect( + user_input[CONF_HOST], raise_on_progress=False + ) except CannotConnect: errors["base"] = "cannot_connect" else: @@ -119,10 +135,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: unique_id = user_input[CONF_DEVICE] capabilities = self._discovered_devices[unique_id] - await self.async_set_unique_id(unique_id) + await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() + host = urlparse(capabilities["location"]).hostname return self.async_create_entry( - title=_async_unique_name(capabilities), data={CONF_ID: unique_id} + title=_async_unique_name(capabilities), + data={CONF_ID: unique_id, CONF_HOST: host}, ) configured_devices = { @@ -131,19 +149,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if entry.data[CONF_ID] } devices_name = {} + scanner = YeelightScanner.async_get(self.hass) + devices = await scanner.async_discover() # Run 3 times as packets can get lost - for _ in range(3): - devices = await self.hass.async_add_executor_job(yeelight.discover_bulbs) - for device in devices: - capabilities = device["capabilities"] - unique_id = capabilities["id"] - if unique_id in configured_devices: - continue # ignore configured devices - model = capabilities["model"] - host = device["ip"] - name = f"{host} {model} {unique_id}" - self._discovered_devices[unique_id] = capabilities - devices_name[unique_id] = name + for capabilities in devices: + unique_id = capabilities["id"] + if unique_id in configured_devices: + continue # ignore configured devices + model = capabilities["model"] + host = urlparse(capabilities["location"]).hostname + name = f"{host} {model} {unique_id}" + self._discovered_devices[unique_id] = capabilities + devices_name[unique_id] = name # Check if there is at least one device if not devices_name: @@ -157,7 +174,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle import step.""" host = user_input[CONF_HOST] try: - await self._async_try_connect(host) + await self._async_try_connect(host, raise_on_progress=False) except CannotConnect: _LOGGER.error("Failed to import %s: cannot connect", host) return self.async_abort(reason="cannot_connect") @@ -169,27 +186,26 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) - async def _async_try_connect(self, host): + async def _async_try_connect(self, host, raise_on_progress=True): """Set up with options.""" self._async_abort_entries_match({CONF_HOST: host}) - bulb = yeelight.Bulb(host) - try: - capabilities = await self.hass.async_add_executor_job(bulb.get_capabilities) - if capabilities is None: # timeout - _LOGGER.debug("Failed to get capabilities from %s: timeout", host) - else: - _LOGGER.debug("Get capabilities: %s", capabilities) - await self.async_set_unique_id(capabilities["id"]) - return capabilities["model"] - except OSError as err: - _LOGGER.debug("Failed to get capabilities from %s: %s", host, err) - # Ignore the error since get_capabilities uses UDP discovery packet - # which does not work in all network environments - + scanner = YeelightScanner.async_get(self.hass) + capabilities = await scanner.async_get_capabilities(host) + if capabilities is None: # timeout + _LOGGER.debug("Failed to get capabilities from %s: timeout", host) + else: + _LOGGER.debug("Get capabilities: %s", capabilities) + await self.async_set_unique_id( + capabilities["id"], raise_on_progress=raise_on_progress + ) + return capabilities["model"] # Fallback to get properties + bulb = AsyncBulb(host) try: - await self.hass.async_add_executor_job(bulb.get_properties) + await bulb.async_listen(lambda _: True) + await bulb.async_get_properties() + await bulb.async_stop_listening() except yeelight.BulbException as err: _LOGGER.error("Failed to get properties from %s: %s", host, err) raise CannotConnect from err diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index b714ddfaba8..4766d897909 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -905,7 +905,7 @@ class YeelightNightLightMode(YeelightGenericLight): @property def name(self) -> str: """Return the name of the device if any.""" - return f"{self.device.name} nightlight" + return f"{self.device.name} Nightlight" @property def icon(self): @@ -997,7 +997,7 @@ class YeelightAmbientLight(YeelightColorLightWithoutNightlightSwitch): @property def name(self) -> str: """Return the name of the device if any.""" - return f"{self.device.name} ambilight" + return f"{self.device.name} Ambilight" @property def _brightness_property(self): diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 3528b096c67..4c5994b1f6e 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.2"], + "requirements": ["yeelight==0.7.2", "async-upnp-client==0.20.0"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index 021d225c362..69da5abc72d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -314,6 +314,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp +# homeassistant.components.yeelight async-upnp-client==0.20.0 # homeassistant.components.supla diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ecb66f6bad4..e476881426c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -205,6 +205,7 @@ arcam-fmj==0.7.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp +# homeassistant.components.yeelight async-upnp-client==0.20.0 # homeassistant.components.aurora diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 9fa864d6213..cb2936cf8e2 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -1,9 +1,13 @@ """Tests for the Yeelight integration.""" +import asyncio +from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch +from async_upnp_client.search import SSDPListener from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS +from homeassistant.components import yeelight as hass_yeelight from homeassistant.components.yeelight import ( CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH_TYPE, @@ -13,6 +17,7 @@ from homeassistant.components.yeelight import ( YeelightScanner, ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME +from homeassistant.core import callback IP_ADDRESS = "192.168.1.239" MODEL = "color" @@ -23,13 +28,16 @@ CAPABILITIES = { "id": ID, "model": MODEL, "fw_ver": FW_VER, + "location": f"yeelight://{IP_ADDRESS}", "support": "get_prop set_default set_power toggle set_bright start_cf stop_cf" " set_scene cron_add cron_get cron_del set_ct_abx set_rgb", "name": "", } NAME = "name" -UNIQUE_NAME = f"yeelight_{MODEL}_{ID}" +SHORT_ID = hex(int("0x000000000015243f", 16)) +UNIQUE_NAME = f"yeelight_{MODEL}_{SHORT_ID}" +UNIQUE_FRIENDLY_NAME = f"Yeelight {MODEL.title()} {SHORT_ID}" MODULE = "homeassistant.components.yeelight" MODULE_CONFIG_FLOW = f"{MODULE}.config_flow" @@ -81,8 +89,8 @@ CONFIG_ENTRY_DATA = {CONF_ID: ID} def _mocked_bulb(cannot_connect=False): bulb = MagicMock() - type(bulb).get_capabilities = MagicMock( - return_value=None if cannot_connect else CAPABILITIES + type(bulb).async_listen = AsyncMock( + side_effect=BulbException if cannot_connect else None ) type(bulb).async_get_properties = AsyncMock( side_effect=BulbException if cannot_connect else None @@ -98,7 +106,6 @@ def _mocked_bulb(cannot_connect=False): bulb.last_properties = PROPERTIES.copy() bulb.music_mode = False bulb.async_get_properties = AsyncMock() - bulb.async_listen = AsyncMock() bulb.async_stop_listening = AsyncMock() bulb.async_update = AsyncMock() bulb.async_turn_on = AsyncMock() @@ -116,12 +123,43 @@ def _mocked_bulb(cannot_connect=False): return bulb -def _patch_discovery(prefix, no_device=False): +def _patched_ssdp_listener(info, *args, **kwargs): + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + await listener.async_connect_callback() + + @callback + def _async_search(*_): + if info: + asyncio.create_task(listener.async_callback(info)) + + listener.async_start = _async_callback + listener.async_search = _async_search + return listener + + +def _patch_discovery(no_device=False): YeelightScanner._scanner = None # Clear class scanner to reset hass - def _mocked_discovery(timeout=2, interface=False): - if no_device: - return [] - return [{"ip": IP_ADDRESS, "port": 55443, "capabilities": CAPABILITIES}] + def _generate_fake_ssdp_listener(*args, **kwargs): + return _patched_ssdp_listener( + None if no_device else CAPABILITIES, + *args, + **kwargs, + ) - return patch(f"{prefix}.discover_bulbs", side_effect=_mocked_discovery) + return patch( + "homeassistant.components.yeelight.SSDPListener", + new=_generate_fake_ssdp_listener, + ) + + +def _patch_discovery_interval(): + return patch.object( + hass_yeelight, "DISCOVERY_SEARCH_INTERVAL", timedelta(seconds=0) + ) + + +def _patch_discovery_timeout(): + return patch.object(hass_yeelight, "DISCOVERY_TIMEOUT", 0.0001) diff --git a/tests/components/yeelight/test_binary_sensor.py b/tests/components/yeelight/test_binary_sensor.py index 472d8de4919..350c289f5b5 100644 --- a/tests/components/yeelight/test_binary_sensor.py +++ b/tests/components/yeelight/test_binary_sensor.py @@ -6,7 +6,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_component from homeassistant.setup import async_setup_component -from . import MODULE, NAME, PROPERTIES, YAML_CONFIGURATION, _mocked_bulb +from . import ( + MODULE, + NAME, + PROPERTIES, + YAML_CONFIGURATION, + _mocked_bulb, + _patch_discovery, +) ENTITY_BINARY_SENSOR = f"binary_sensor.{NAME}_nightlight" @@ -14,9 +21,7 @@ ENTITY_BINARY_SENSOR = f"binary_sensor.{NAME}_nightlight" async def test_nightlight(hass: HomeAssistant): """Test nightlight sensor.""" mocked_bulb = _mocked_bulb() - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), patch( - f"{MODULE}.config_flow.yeelight.Bulb", return_value=mocked_bulb - ): + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION) await hass.async_block_till_done() diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 247630ecfc3..5bbfcc9283b 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Yeelight config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -25,14 +25,17 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM from . import ( + CAPABILITIES, ID, IP_ADDRESS, MODULE, MODULE_CONFIG_FLOW, NAME, - UNIQUE_NAME, + UNIQUE_FRIENDLY_NAME, _mocked_bulb, _patch_discovery, + _patch_discovery_interval, + _patch_discovery_timeout, ) from tests.common import MockConfigEntry @@ -55,21 +58,23 @@ async def test_discovery(hass: HomeAssistant): assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(f"{MODULE_CONFIG_FLOW}.yeelight"): + with _patch_discovery(), _patch_discovery_interval(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result2["type"] == "form" assert result2["step_id"] == "pick_device" assert not result2["errors"] - with patch(f"{MODULE}.async_setup", return_value=True) as mock_setup, patch( + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_setup, patch( f"{MODULE}.async_setup_entry", return_value=True ) as mock_setup_entry: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DEVICE: ID} ) assert result3["type"] == "create_entry" - assert result3["title"] == UNIQUE_NAME - assert result3["data"] == {CONF_ID: ID} + assert result3["title"] == UNIQUE_FRIENDLY_NAME + assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS} await hass.async_block_till_done() mock_setup.assert_called_once() mock_setup_entry.assert_called_once() @@ -82,7 +87,7 @@ async def test_discovery(hass: HomeAssistant): assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(f"{MODULE_CONFIG_FLOW}.yeelight"): + with _patch_discovery(), _patch_discovery_interval(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result2["type"] == "abort" assert result2["reason"] == "no_devices_found" @@ -94,7 +99,9 @@ async def test_discovery_no_device(hass: HomeAssistant): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with _patch_discovery(f"{MODULE_CONFIG_FLOW}.yeelight", no_device=True): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result2["type"] == "abort" @@ -114,26 +121,27 @@ async def test_import(hass: HomeAssistant): # Cannot connect mocked_bulb = _mocked_bulb(cannot_connect=True) - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config ) - type(mocked_bulb).get_capabilities.assert_called_once() - type(mocked_bulb).get_properties.assert_called_once() assert result["type"] == "abort" assert result["reason"] == "cannot_connect" # Success mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb), patch( - f"{MODULE}.async_setup", return_value=True - ) as mock_setup, patch( + with _patch_discovery(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ), patch(f"{MODULE}.async_setup", return_value=True) as mock_setup, patch( f"{MODULE}.async_setup_entry", return_value=True ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config ) - type(mocked_bulb).get_capabilities.assert_called_once() assert result["type"] == "create_entry" assert result["title"] == DEFAULT_NAME assert result["data"] == { @@ -150,7 +158,9 @@ async def test_import(hass: HomeAssistant): # Duplicate mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config ) @@ -169,7 +179,11 @@ async def test_manual(hass: HomeAssistant): # Cannot connect (timeout) mocked_bulb = _mocked_bulb(cannot_connect=True) - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) @@ -178,8 +192,11 @@ async def test_manual(hass: HomeAssistant): assert result2["errors"] == {"base": "cannot_connect"} # Cannot connect (error) - type(mocked_bulb).get_capabilities = MagicMock(side_effect=OSError) - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) @@ -187,9 +204,11 @@ async def test_manual(hass: HomeAssistant): # Success mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb), patch( - f"{MODULE}.async_setup", return_value=True - ), patch(f"{MODULE}.async_setup_entry", return_value=True): + with _patch_discovery(), _patch_discovery_timeout(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ), patch(f"{MODULE}.async_setup", return_value=True), patch( + f"{MODULE}.async_setup_entry", return_value=True + ): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) @@ -203,7 +222,11 @@ async def test_manual(hass: HomeAssistant): DOMAIN, context={"source": config_entries.SOURCE_USER} ) mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) @@ -219,7 +242,7 @@ async def test_options(hass: HomeAssistant): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -241,7 +264,7 @@ async def test_options(hass: HomeAssistant): config[CONF_NIGHTLIGHT_SWITCH] = True user_input = {**config} user_input.pop(CONF_NAME) - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input ) @@ -262,15 +285,18 @@ async def test_manual_no_capabilities(hass: HomeAssistant): assert not result["errors"] mocked_bulb = _mocked_bulb() - type(mocked_bulb).get_capabilities = MagicMock(return_value=None) - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb), patch( + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ), patch( f"{MODULE}.async_setup", return_value=True - ), patch(f"{MODULE}.async_setup_entry", return_value=True): + ), patch( + f"{MODULE}.async_setup_entry", return_value=True + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) - type(mocked_bulb).get_capabilities.assert_called_once() - type(mocked_bulb).get_properties.assert_called_once() assert result["type"] == "create_entry" assert result["data"] == {CONF_HOST: IP_ADDRESS} @@ -280,39 +306,53 @@ async def test_discovered_by_homekit_and_dhcp(hass): await setup.async_setup_component(hass, "persistent_notification", {}) mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "1.2.3.4", "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + data={"host": IP_ADDRESS, "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, ) + await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_FORM assert result["errors"] is None - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": "1.2.3.4", "macaddress": "aa:bb:cc:dd:ee:ff"}, + data={"ip": IP_ADDRESS, "macaddress": "aa:bb:cc:dd:ee:ff"}, ) + await hass.async_block_till_done() assert result2["type"] == RESULT_TYPE_ABORT assert result2["reason"] == "already_in_progress" - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": "1.2.3.4", "macaddress": "00:00:00:00:00:00"}, + data={"ip": IP_ADDRESS, "macaddress": "00:00:00:00:00:00"}, ) + await hass.async_block_till_done() assert result3["type"] == RESULT_TYPE_ABORT assert result3["reason"] == "already_in_progress" - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", side_effect=CannotConnect): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", side_effect=CannotConnect + ): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data={"ip": "1.2.3.5", "macaddress": "00:00:00:00:00:01"}, ) + await hass.async_block_till_done() assert result3["type"] == RESULT_TYPE_ABORT assert result3["reason"] == "cannot_connect" @@ -335,17 +375,25 @@ async def test_discovered_by_dhcp_or_homekit(hass, source, data): await setup.async_setup_component(hass, "persistent_notification", {}) mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] is None - with patch(f"{MODULE}.async_setup", return_value=True) as mock_async_setup, patch( + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( f"{MODULE}.async_setup_entry", return_value=True ) as mock_async_setup_entry: result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] == "create_entry" assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} assert mock_async_setup.called @@ -370,10 +418,55 @@ async def test_discovered_by_dhcp_or_homekit_failed_to_get_id(hass, source, data await setup.async_setup_component(hass, "persistent_notification", {}) mocked_bulb = _mocked_bulb() - type(mocked_bulb).get_capabilities = MagicMock(return_value=None) - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "cannot_connect" + + +async def test_discovered_ssdp(hass): + """Test we can setup when discovered from ssdp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=CAPABILITIES + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} + assert mock_async_setup.called + assert mock_async_setup_entry.called + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=CAPABILITIES + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 575ad4cb594..d7f4a05b436 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -1,5 +1,6 @@ """Test Yeelight.""" -from unittest.mock import AsyncMock, MagicMock, patch +from datetime import timedelta +from unittest.mock import AsyncMock, patch from yeelight import BulbException, BulbType @@ -22,9 +23,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import ( - CAPABILITIES, CONFIG_ENTRY_DATA, ENTITY_AMBILIGHT, ENTITY_BINARY_SENSOR, @@ -34,12 +35,14 @@ from . import ( ID, IP_ADDRESS, MODULE, - MODULE_CONFIG_FLOW, + SHORT_ID, _mocked_bulb, _patch_discovery, + _patch_discovery_interval, + _patch_discovery_timeout, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_ip_changes_fallback_discovery(hass: HomeAssistant): @@ -51,19 +54,15 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant): mocked_bulb = _mocked_bulb(True) mocked_bulb.bulb_type = BulbType.WhiteTempMood - mocked_bulb.get_capabilities = MagicMock( - side_effect=[OSError, CAPABILITIES, CAPABILITIES] - ) + mocked_bulb.async_listen = AsyncMock(side_effect=[BulbException, None, None, None]) - _discovered_devices = [{"capabilities": CAPABILITIES, "ip": IP_ADDRESS}] - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), patch( - f"{MODULE}.discover_bulbs", return_value=_discovered_devices - ): + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + await hass.async_block_till_done() binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( - f"yeelight_color_{ID}" + f"yeelight_color_{SHORT_ID}" ) type(mocked_bulb).async_get_properties = AsyncMock(None) @@ -77,6 +76,19 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant): entity_registry = er.async_get(hass) assert entity_registry.async_get(binary_sensor_entity_id) is not None + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): + # The discovery should update the ip address + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done() + assert config_entry.data[CONF_HOST] == IP_ADDRESS + + # Make sure we can still reload with the new ip right after we change it + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get(binary_sensor_entity_id) is not None + async def test_ip_changes_id_missing_cannot_fallback(hass: HomeAssistant): """Test Yeelight ip changes and we fallback to discovery.""" @@ -85,9 +97,7 @@ async def test_ip_changes_id_missing_cannot_fallback(hass: HomeAssistant): mocked_bulb = _mocked_bulb(True) mocked_bulb.bulb_type = BulbType.WhiteTempMood - mocked_bulb.get_capabilities = MagicMock( - side_effect=[OSError, CAPABILITIES, CAPABILITIES] - ) + mocked_bulb.async_listen = AsyncMock(side_effect=[BulbException, None, None, None]) with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): assert not await hass.config_entries.async_setup(config_entry.entry_id) @@ -102,9 +112,7 @@ async def test_setup_discovery(hass: HomeAssistant): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with _patch_discovery(MODULE), patch( - f"{MODULE}.AsyncBulb", return_value=mocked_bulb - ): + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -127,9 +135,7 @@ async def test_setup_import(hass: HomeAssistant): """Test import from yaml.""" mocked_bulb = _mocked_bulb() name = "yeelight" - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), patch( - f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb - ): + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): assert await async_setup_component( hass, DOMAIN, @@ -162,9 +168,7 @@ async def test_unique_ids_device(hass: HomeAssistant): mocked_bulb = _mocked_bulb() mocked_bulb.bulb_type = BulbType.WhiteTempMood - with _patch_discovery(MODULE), patch( - f"{MODULE}.AsyncBulb", return_value=mocked_bulb - ): + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -188,9 +192,7 @@ async def test_unique_ids_entry(hass: HomeAssistant): mocked_bulb = _mocked_bulb() mocked_bulb.bulb_type = BulbType.WhiteTempMood - with _patch_discovery(MODULE), patch( - f"{MODULE}.AsyncBulb", return_value=mocked_bulb - ): + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -220,30 +222,13 @@ async def test_bulb_off_while_adding_in_ha(hass: HomeAssistant): mocked_bulb = _mocked_bulb(True) mocked_bulb.bulb_type = BulbType.WhiteTempMood - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), patch( - f"{MODULE}.config_flow.yeelight.Bulb", return_value=mocked_bulb - ): + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( - IP_ADDRESS.replace(".", "_") - ) - - type(mocked_bulb).get_capabilities = MagicMock(CAPABILITIES) - type(mocked_bulb).get_properties = MagicMock(None) - - await hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][ - DATA_DEVICE - ].async_update() - hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][ - DATA_DEVICE - ].async_update_callback({}) - await hass.async_block_till_done() - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - assert entity_registry.async_get(binary_sensor_entity_id) is not None + assert config_entry.state is ConfigEntryState.LOADED async def test_async_listen_error_late_discovery(hass, caplog): @@ -251,12 +236,9 @@ async def test_async_listen_error_late_discovery(hass, caplog): config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) config_entry.add_to_hass(hass) - mocked_bulb = _mocked_bulb() - mocked_bulb.async_listen = AsyncMock(side_effect=BulbException) + mocked_bulb = _mocked_bulb(cannot_connect=True) - with _patch_discovery(MODULE), patch( - f"{MODULE}.AsyncBulb", return_value=mocked_bulb - ): + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -264,17 +246,33 @@ async def test_async_listen_error_late_discovery(hass, caplog): assert "Failed to connect to bulb at" in caplog.text -async def test_async_listen_error_has_host(hass: HomeAssistant): +async def test_async_listen_error_has_host_with_id(hass: HomeAssistant): """Test the async listen error.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_ID: ID, CONF_HOST: "127.0.0.1"} ) config_entry.add_to_hass(hass) - mocked_bulb = _mocked_bulb() - mocked_bulb.async_listen = AsyncMock(side_effect=BulbException) + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb(cannot_connect=True) + ): + await hass.config_entries.async_setup(config_entry.entry_id) - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_async_listen_error_has_host_without_id(hass: HomeAssistant): + """Test the async listen error but no id.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}) + config_entry.add_to_hass(hass) + + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb(cannot_connect=True) + ): await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 8b7ec154b83..7497fa8773e 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -102,9 +102,10 @@ from . import ( MODULE, NAME, PROPERTIES, - UNIQUE_NAME, + UNIQUE_FRIENDLY_NAME, _mocked_bulb, _patch_discovery, + _patch_discovery_interval, ) from tests.common import MockConfigEntry @@ -132,7 +133,7 @@ async def test_services(hass: HomeAssistant, caplog): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with _patch_discovery(MODULE), patch( + with _patch_discovery(), _patch_discovery_interval(), patch( f"{MODULE}.AsyncBulb", return_value=mocked_bulb ): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -559,7 +560,7 @@ async def test_device_types(hass: HomeAssistant, caplog): model, target_properties, nightlight_properties=None, - name=UNIQUE_NAME, + name=UNIQUE_FRIENDLY_NAME, entity_id=ENTITY_LIGHT, ): config_entry = MockConfigEntry( @@ -598,7 +599,7 @@ async def test_device_types(hass: HomeAssistant, caplog): assert hass.states.get(entity_id).state == "off" state = hass.states.get(f"{entity_id}_nightlight") assert state.state == "on" - nightlight_properties["friendly_name"] = f"{name} nightlight" + nightlight_properties["friendly_name"] = f"{name} Nightlight" nightlight_properties["icon"] = "mdi:weather-night" nightlight_properties["flowing"] = False nightlight_properties["night_light"] = True @@ -893,7 +894,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - name=f"{UNIQUE_NAME} ambilight", + name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", ) @@ -914,7 +915,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - name=f"{UNIQUE_NAME} ambilight", + name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", ) @@ -935,7 +936,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - name=f"{UNIQUE_NAME} ambilight", + name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", ) @@ -969,7 +970,7 @@ async def test_effects(hass: HomeAssistant): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with _patch_discovery(MODULE), patch( + with _patch_discovery(), _patch_discovery_interval(), patch( f"{MODULE}.AsyncBulb", return_value=mocked_bulb ): assert await hass.config_entries.async_setup(config_entry.entry_id) From 08193169d0da03c81635ab05235cc9e94ec18abb Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 18 Aug 2021 13:27:41 -0400 Subject: [PATCH 514/903] Remove unnecessary signal during zwave_js.reset_meter service call (#54837) --- homeassistant/components/zwave_js/sensor.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 220184f5669..1ffa263dae7 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -31,10 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_METER_TYPE, ATTR_VALUE, DATA_CLIENT, DOMAIN, SERVICE_RESET_METER @@ -256,15 +253,6 @@ class ZWaveMeterSensor(ZWaveNumericSensor): options, ) - # Notify meters that may have been reset - async_dispatcher_send( - self.hass, - f"{DOMAIN}_{SERVICE_RESET_METER}", - node, - primary_value.endpoint, - options.get("type"), - ) - class ZWaveListSensor(ZwaveSensorBase): """Representation of a Z-Wave Numeric sensor with multiple states.""" From 30564d59b67b860bf734a97d21b0c45b9731171a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Aug 2021 12:32:52 -0500 Subject: [PATCH 515/903] Bump yeelight quality scale to platinum with switch to async local push (#54589) --- homeassistant/components/yeelight/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 4c5994b1f6e..b1c1c131907 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -5,6 +5,7 @@ "requirements": ["yeelight==0.7.2", "async-upnp-client==0.20.0"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, + "quality_scale": "platinum", "iot_class": "local_push", "dhcp": [{ "hostname": "yeelink-*" From 6d0ce814e79b72b13e8f2eff371e926ddba155ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Aug 2021 12:33:26 -0500 Subject: [PATCH 516/903] Add new network apis to reduce code duplication (#54832) --- homeassistant/components/network/__init__.py | 32 +++++++++++++++- homeassistant/components/ssdp/__init__.py | 30 ++++----------- homeassistant/components/zeroconf/__init__.py | 37 +++++-------------- 3 files changed, 48 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 48903d145e7..a7dffad7084 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -1,13 +1,14 @@ """The Network Configuration integration.""" from __future__ import annotations +from ipaddress import IPv4Address, IPv6Address import logging import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api.connection import ActiveConnection -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -45,6 +46,35 @@ async def async_get_source_ip(hass: HomeAssistant, target_ip: str) -> str: return source_ip if source_ip in all_ipv4s else all_ipv4s[0] +@bind_hass +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) + sources: list[IPv4Address | IPv6Address] = [] + for adapter in adapters: + if not adapter["enabled"]: + continue + if adapter["ipv4"]: + sources.extend(IPv4Address(ipv4["address"]) for ipv4 in adapter["ipv4"]) + if adapter["ipv6"]: + # With python 3.9 add scope_ids can be + # added by enumerating adapter["ipv6"]s + # IPv6Address(f"::%{ipv6['scope_id']}") + sources.extend(IPv6Address(ipv6["address"]) for ipv6 in adapter["ipv6"]) + + return sources + + +@callback +def async_only_default_interface_enabled(adapters: list[Adapter]) -> bool: + """Check to see if any non-default adapter is enabled.""" + return not any( + adapter["enabled"] and not adapter["default"] for adapter in adapters + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up network for Home Assistant.""" diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 4d21fdb6aab..1fd2bba77cc 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -116,14 +116,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -@core_callback -def _async_use_default_interface(adapters: list[network.Adapter]) -> bool: - for adapter in adapters: - if adapter["enabled"] and not adapter["default"]: - return False - return True - - @core_callback def _async_process_callbacks( callbacks: list[Callable[[dict], None]], discovery_info: dict[str, str] @@ -204,24 +196,16 @@ class Scanner: """Build the list of ssdp sources.""" adapters = await network.async_get_adapters(self.hass) sources: set[IPv4Address | IPv6Address] = set() - if _async_use_default_interface(adapters): + if network.async_only_default_interface_enabled(adapters): sources.add(IPv4Address("0.0.0.0")) return sources - for adapter in adapters: - if not adapter["enabled"]: - continue - if adapter["ipv4"]: - ipv4 = adapter["ipv4"][0] - sources.add(IPv4Address(ipv4["address"])) - if adapter["ipv6"]: - ipv6 = adapter["ipv6"][0] - # With python 3.9 add scope_ids can be - # added by enumerating adapter["ipv6"]s - # IPv6Address(f"::%{ipv6['scope_id']}") - sources.add(IPv6Address(ipv6["address"])) - - return sources + return { + source_ip + for source_ip in await network.async_get_enabled_source_ips(self.hass) + if not source_ip.is_loopback + and not (isinstance(source_ip, IPv6Address) and source_ip.is_global) + } async def async_scan(self, *_: Any) -> None: """Scan for new entries using ssdp default and broadcast target.""" diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 6829c9c5e17..8b1f482e05e 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import Coroutine from contextlib import suppress import fnmatch -import ipaddress +from ipaddress import IPv6Address, ip_address import logging import socket from typing import Any, TypedDict, cast @@ -131,13 +131,6 @@ async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZero return aio_zc -def _async_use_default_interface(adapters: list[Adapter]) -> bool: - for adapter in adapters: - if adapter["enabled"] and not adapter["default"]: - return False - return True - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Zeroconf and make Home Assistant discoverable.""" zc_args: dict = {} @@ -151,25 +144,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: zc_args["ip_version"] = IPVersion.All - if not ipv6 and _async_use_default_interface(adapters): + if not ipv6 and network.async_only_default_interface_enabled(adapters): zc_args["interfaces"] = InterfaceChoice.Default else: - interfaces = zc_args["interfaces"] = [] - for adapter in adapters: - if not adapter["enabled"]: - continue - if ipv4s := adapter["ipv4"]: - interfaces.extend( - ipv4["address"] - for ipv4 in ipv4s - if not ipaddress.IPv4Address(ipv4["address"]).is_loopback - ) - if ipv6s := adapter["ipv6"]: - for ipv6_addr in ipv6s: - address = ipv6_addr["address"] - v6_ip_address = ipaddress.IPv6Address(address) - if not v6_ip_address.is_global and not v6_ip_address.is_loopback: - interfaces.append(ipv6_addr["address"]) + zc_args["interfaces"] = [ + str(source_ip) + for source_ip in await network.async_get_enabled_source_ips(hass) + if not source_ip.is_loopback + and not (isinstance(source_ip, IPv6Address) and source_ip.is_global) + ] aio_zc = await _async_get_instance(hass, **zc_args) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) @@ -213,7 +196,7 @@ def _get_announced_addresses( addresses = { addr.packed for addr in [ - ipaddress.ip_address(ip["address"]) + ip_address(ip["address"]) for adapter in adapters if adapter["enabled"] for ip in cast(list, adapter["ipv6"]) + cast(list, adapter["ipv4"]) @@ -530,7 +513,7 @@ def info_from_service(service: AsyncServiceInfo) -> HaServiceInfo | None: address = service.addresses[0] return { - "host": str(ipaddress.ip_address(address)), + "host": str(ip_address(address)), "port": service.port, "hostname": service.server, "type": service.type, From 2f77b5025ca565d6ea174fe4f29c5a68343ba1cf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 Aug 2021 11:21:51 -0700 Subject: [PATCH 517/903] Add energy validation (#54567) --- homeassistant/components/energy/manifest.json | 2 +- homeassistant/components/energy/validate.py | 277 +++++++++++ .../components/energy/websocket_api.py | 17 + homeassistant/components/recorder/__init__.py | 11 + tests/components/energy/test_validate.py | 443 ++++++++++++++++++ tests/components/energy/test_websocket_api.py | 16 + 6 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/energy/validate.py create mode 100644 tests/components/energy/test_validate.py diff --git a/homeassistant/components/energy/manifest.json b/homeassistant/components/energy/manifest.json index 3a3cbeff4e7..5ddc6457a61 100644 --- a/homeassistant/components/energy/manifest.json +++ b/homeassistant/components/energy/manifest.json @@ -4,6 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/energy", "codeowners": ["@home-assistant/core"], "iot_class": "calculated", - "dependencies": ["websocket_api", "history"], + "dependencies": ["websocket_api", "history", "recorder"], "quality_scale": "internal" } diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py new file mode 100644 index 00000000000..01709081d68 --- /dev/null +++ b/homeassistant/components/energy/validate.py @@ -0,0 +1,277 @@ +"""Validate the energy preferences provide valid data.""" +from __future__ import annotations + +import dataclasses +from typing import Any + +from homeassistant.components import recorder, sensor +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, callback, valid_entity_id + +from . import data +from .const import DOMAIN + + +@dataclasses.dataclass +class ValidationIssue: + """Error or warning message.""" + + type: str + identifier: str + value: Any | None = None + + +@dataclasses.dataclass +class EnergyPreferencesValidation: + """Dictionary holding validation information.""" + + energy_sources: list[list[ValidationIssue]] = dataclasses.field( + default_factory=list + ) + device_consumption: list[list[ValidationIssue]] = dataclasses.field( + default_factory=list + ) + + def as_dict(self) -> dict: + """Return dictionary version.""" + return dataclasses.asdict(self) + + +@callback +def _async_validate_energy_stat( + hass: HomeAssistant, stat_value: str, result: list[ValidationIssue] +) -> None: + """Validate a statistic.""" + has_entity_source = valid_entity_id(stat_value) + + if not has_entity_source: + return + + if not recorder.is_entity_recorded(hass, stat_value): + result.append( + ValidationIssue( + "recorder_untracked", + stat_value, + ) + ) + return + + state = hass.states.get(stat_value) + + if state is None: + result.append( + ValidationIssue( + "entity_not_defined", + stat_value, + ) + ) + return + + if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + result.append(ValidationIssue("entity_unavailable", stat_value, state.state)) + return + + try: + current_value: float | None = float(state.state) + except ValueError: + result.append( + ValidationIssue("entity_state_non_numeric", stat_value, state.state) + ) + return + + if current_value is not None and current_value < 0: + result.append( + ValidationIssue("entity_negative_state", stat_value, current_value) + ) + + unit = state.attributes.get("unit_of_measurement") + + if unit not in (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR): + result.append( + ValidationIssue("entity_unexpected_unit_energy", stat_value, unit) + ) + + state_class = state.attributes.get("state_class") + + if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + result.append( + ValidationIssue( + "entity_unexpected_state_class_total_increasing", + stat_value, + state_class, + ) + ) + + +@callback +def _async_validate_price_entity( + hass: HomeAssistant, entity_id: str, result: list[ValidationIssue] +) -> None: + """Validate that the price entity is correct.""" + state = hass.states.get(entity_id) + + if state is None: + result.append( + ValidationIssue( + "entity_not_defined", + entity_id, + ) + ) + return + + try: + value: float | None = float(state.state) + except ValueError: + result.append( + ValidationIssue("entity_state_non_numeric", entity_id, state.state) + ) + return + + if value is not None and value < 0: + result.append(ValidationIssue("entity_negative_state", entity_id, value)) + + unit = state.attributes.get("unit_of_measurement") + + if unit is None or not unit.endswith( + (f"/{ENERGY_KILO_WATT_HOUR}", f"/{ENERGY_WATT_HOUR}") + ): + result.append(ValidationIssue("entity_unexpected_unit_price", entity_id, unit)) + + +@callback +def _async_validate_cost_stat( + hass: HomeAssistant, stat_id: str, result: list[ValidationIssue] +) -> None: + """Validate that the cost stat is correct.""" + has_entity = valid_entity_id(stat_id) + + if not has_entity: + return + + if not recorder.is_entity_recorded(hass, stat_id): + result.append( + ValidationIssue( + "recorder_untracked", + stat_id, + ) + ) + + +@callback +def _async_validate_cost_entity( + hass: HomeAssistant, entity_id: str, result: list[ValidationIssue] +) -> None: + """Validate that the cost entity is correct.""" + if not recorder.is_entity_recorded(hass, entity_id): + result.append( + ValidationIssue( + "recorder_untracked", + entity_id, + ) + ) + + state = hass.states.get(entity_id) + + if state is None: + result.append( + ValidationIssue( + "entity_not_defined", + entity_id, + ) + ) + return + + state_class = state.attributes.get("state_class") + + if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + result.append( + ValidationIssue( + "entity_unexpected_state_class_total_increasing", entity_id, state_class + ) + ) + + +async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: + """Validate the energy configuration.""" + manager = await data.async_get_manager(hass) + + result = EnergyPreferencesValidation() + + if manager.data is None: + return result + + for source in manager.data["energy_sources"]: + source_result: list[ValidationIssue] = [] + result.energy_sources.append(source_result) + + if source["type"] == "grid": + for flow in source["flow_from"]: + _async_validate_energy_stat( + hass, flow["stat_energy_from"], source_result + ) + + if flow.get("stat_cost") is not None: + _async_validate_cost_stat(hass, flow["stat_cost"], source_result) + + elif flow.get("entity_energy_price") is not None: + _async_validate_price_entity( + hass, flow["entity_energy_price"], source_result + ) + _async_validate_cost_entity( + hass, + hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_from"]], + source_result, + ) + + for flow in source["flow_to"]: + _async_validate_energy_stat(hass, flow["stat_energy_to"], source_result) + + if flow.get("stat_compensation") is not None: + _async_validate_cost_stat( + hass, flow["stat_compensation"], source_result + ) + + elif flow.get("entity_energy_price") is not None: + _async_validate_price_entity( + hass, flow["entity_energy_price"], source_result + ) + _async_validate_cost_entity( + hass, + hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_to"]], + source_result, + ) + + elif source["type"] == "gas": + _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + + if source.get("stat_cost") is not None: + _async_validate_cost_stat(hass, source["stat_cost"], source_result) + + elif source.get("entity_energy_price") is not None: + _async_validate_price_entity( + hass, source["entity_energy_price"], source_result + ) + _async_validate_cost_entity( + hass, + hass.data[DOMAIN]["cost_sensors"][source["stat_energy_from"]], + source_result, + ) + + elif source["type"] == "solar": + _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + + elif source["type"] == "battery": + _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + _async_validate_energy_stat(hass, source["stat_energy_to"], source_result) + + for device in manager.data["device_consumption"]: + device_result: list[ValidationIssue] = [] + result.device_consumption.append(device_result) + _async_validate_energy_stat(hass, device["stat_consumption"], device_result) + + return result diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index d1c8869a1c2..6d71a75b9b4 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -18,6 +18,7 @@ from .data import ( EnergyPreferencesUpdate, async_get_manager, ) +from .validate import async_validate EnergyWebSocketCommandHandler = Callable[ [HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"], @@ -35,6 +36,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_get_prefs) websocket_api.async_register_command(hass, ws_save_prefs) websocket_api.async_register_command(hass, ws_info) + websocket_api.async_register_command(hass, ws_validate) def _ws_with_manager( @@ -113,3 +115,18 @@ def ws_info( ) -> None: """Handle get info command.""" connection.send_result(msg["id"], hass.data[DOMAIN]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/validate", + } +) +@websocket_api.async_response +async def ws_validate( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle validate command.""" + connection.send_result(msg["id"], (await async_validate(hass)).as_dict()) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index e9c12e5f88a..e6c15729d24 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -174,6 +174,17 @@ async def async_migration_in_progress(hass: HomeAssistant) -> bool: return hass.data[DATA_INSTANCE].migration_in_progress +@bind_hass +def is_entity_recorded(hass: HomeAssistant, entity_id: str) -> bool: + """Check if an entity is being recorded. + + Async friendly. + """ + if DATA_INSTANCE not in hass.data: + return False + return hass.data[DATA_INSTANCE].entity_filter(entity_id) + + def run_information(hass, point_in_time: datetime | None = None): """Return information about current run. diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py new file mode 100644 index 00000000000..9a0b2105007 --- /dev/null +++ b/tests/components/energy/test_validate.py @@ -0,0 +1,443 @@ +"""Test that validation works.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.energy import async_get_manager, validate +from homeassistant.setup import async_setup_component + +from tests.common import async_init_recorder_component + + +@pytest.fixture +def mock_is_entity_recorded(): + """Mock recorder.is_entity_recorded.""" + mocks = {} + + with patch( + "homeassistant.components.recorder.is_entity_recorded", + side_effect=lambda hass, entity_id: mocks.get(entity_id, True), + ): + yield mocks + + +@pytest.fixture(autouse=True) +async def mock_energy_manager(hass): + """Set up energy.""" + await async_init_recorder_component(hass) + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + return manager + + +async def test_validation_empty_config(hass): + """Test validating an empty config.""" + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [], + } + + +async def test_validation(hass, mock_energy_manager): + """Test validating success.""" + for key in ("device_cons", "battery_import", "battery_export", "solar_production"): + hass.states.async_set( + f"sensor.{key}", + "123", + {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + ) + + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_import", + "stat_energy_to": "sensor.battery_export", + }, + {"type": "solar", "stat_energy_from": "sensor.solar_production"}, + ], + "device_consumption": [{"stat_consumption": "sensor.device_cons"}], + } + ) + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [[], []], + "device_consumption": [[]], + } + + +async def test_validation_device_consumption_entity_missing(hass, mock_energy_manager): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.not_exist"}]} + ) + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "entity_not_defined", + "identifier": "sensor.not_exist", + "value": None, + } + ] + ], + } + + +async def test_validation_device_consumption_entity_unavailable( + hass, mock_energy_manager +): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.unavailable"}]} + ) + hass.states.async_set("sensor.unavailable", "unavailable", {}) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "entity_unavailable", + "identifier": "sensor.unavailable", + "value": "unavailable", + } + ] + ], + } + + +async def test_validation_device_consumption_entity_non_numeric( + hass, mock_energy_manager +): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.non_numeric"}]} + ) + hass.states.async_set("sensor.non_numeric", "123,123.10") + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "entity_state_non_numeric", + "identifier": "sensor.non_numeric", + "value": "123,123.10", + }, + ] + ], + } + + +async def test_validation_device_consumption_entity_unexpected_unit( + hass, mock_energy_manager +): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.unexpected_unit"}]} + ) + hass.states.async_set( + "sensor.unexpected_unit", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.unexpected_unit", + "value": "beers", + } + ] + ], + } + + +async def test_validation_device_consumption_recorder_not_tracked( + hass, mock_energy_manager, mock_is_entity_recorded +): + """Test validating device based on untracked entity.""" + mock_is_entity_recorded["sensor.not_recorded"] = False + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.not_recorded"}]} + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "recorder_untracked", + "identifier": "sensor.not_recorded", + "value": None, + } + ] + ], + } + + +async def test_validation_solar(hass, mock_energy_manager): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + {"type": "solar", "stat_energy_from": "sensor.solar_production"} + ] + } + ) + hass.states.async_set( + "sensor.solar_production", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.solar_production", + "value": "beers", + } + ] + ], + "device_consumption": [], + } + + +async def test_validation_battery(hass, mock_energy_manager): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_import", + "stat_energy_to": "sensor.battery_export", + } + ] + } + ) + hass.states.async_set( + "sensor.battery_import", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.battery_export", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.battery_import", + "value": "beers", + }, + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.battery_export", + "value": "beers", + }, + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorded): + """Test validating grid with sensors for energy and cost/compensation.""" + mock_is_entity_recorded["sensor.grid_cost_1"] = False + mock_is_entity_recorded["sensor.grid_compensation_1"] = False + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_consumption_1", + "stat_cost": "sensor.grid_cost_1", + } + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_production_1", + "stat_compensation": "sensor.grid_compensation_1", + } + ], + } + ] + } + ) + hass.states.async_set( + "sensor.grid_consumption_1", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.grid_production_1", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.grid_consumption_1", + "value": "beers", + }, + { + "type": "recorder_untracked", + "identifier": "sensor.grid_cost_1", + "value": None, + }, + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.grid_production_1", + "value": "beers", + }, + { + "type": "recorder_untracked", + "identifier": "sensor.grid_compensation_1", + "value": None, + }, + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid_price_not_exist(hass, mock_energy_manager): + """Test validating grid with price entity that does not exist.""" + hass.states.async_set( + "sensor.grid_consumption_1", + "10.10", + {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.grid_production_1", + "10.10", + {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + ) + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_consumption_1", + "entity_energy_from": "sensor.grid_consumption_1", + "entity_energy_price": "sensor.grid_price_1", + } + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_production_1", + "entity_energy_to": "sensor.grid_production_1", + "number_energy_price": 0.10, + } + ], + } + ] + } + ) + await hass.async_block_till_done() + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_not_defined", + "identifier": "sensor.grid_price_1", + "value": None, + } + ] + ], + "device_consumption": [], + } + + +@pytest.mark.parametrize( + "state, unit, expected", + ( + ( + "123,123.12", + "$/kWh", + { + "type": "entity_state_non_numeric", + "identifier": "sensor.grid_price_1", + "value": "123,123.12", + }, + ), + ( + "-100", + "$/kWh", + { + "type": "entity_negative_state", + "identifier": "sensor.grid_price_1", + "value": -100.0, + }, + ), + ( + "123", + "$/Ws", + { + "type": "entity_unexpected_unit_price", + "identifier": "sensor.grid_price_1", + "value": "$/Ws", + }, + ), + ), +) +async def test_validation_grid_price_errors( + hass, mock_energy_manager, state, unit, expected +): + """Test validating grid with price data that gives errors.""" + hass.states.async_set( + "sensor.grid_consumption_1", + "10.10", + {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.grid_price_1", + state, + {"unit_of_measurement": unit, "state_class": "total_increasing"}, + ) + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_consumption_1", + "entity_energy_from": "sensor.grid_consumption_1", + "entity_energy_price": "sensor.grid_price_1", + } + ], + "flow_to": [], + } + ] + } + ) + await hass.async_block_till_done() + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [expected], + ], + "device_consumption": [], + } diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 60ac82108bc..732bdaa93cf 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -216,3 +216,19 @@ async def test_handle_duplicate_from_stat(hass, hass_ws_client) -> None: assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "invalid_format" + + +async def test_validate(hass, hass_ws_client) -> None: + """Test we can validate the preferences.""" + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "energy/validate"}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == { + "energy_sources": [], + "device_consumption": [], + } From f9fa5fa804291cdc3c2ab9592b3841fb2444bb72 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 20:22:01 +0200 Subject: [PATCH 518/903] Deprecate last_reset options in MQTT sensor (#54840) --- homeassistant/components/mqtt/sensor.py | 29 +++++++++++++++---------- tests/components/mqtt/test_sensor.py | 22 +++++++++++++++++++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index eac136d3f84..16c19c8fc51 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -53,18 +53,23 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False -PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) +PLATFORM_SCHEMA = vol.All( + # Deprecated, remove in Home Assistant 2021.11 + cv.deprecated(CONF_LAST_RESET_TOPIC), + cv.deprecated(CONF_LAST_RESET_VALUE_TEMPLATE), + mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } + ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), +) async def async_setup_platform( diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 15ca9870077..724dec1c93f 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -306,6 +306,28 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" +async def test_last_reset_deprecated(hass, mqtt_mock, caplog): + """Test the setting of the last_reset property via MQTT.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "fav unit", + "last_reset_topic": "last-reset-topic", + "last_reset_value_template": "{{ value_json.last_reset }}", + } + }, + ) + await hass.async_block_till_done() + + assert "The 'last_reset_topic' option is deprecated" in caplog.text + assert "The 'last_reset_value_template' option is deprecated" in caplog.text + + async def test_force_update_disabled(hass, mqtt_mock): """Test force update option.""" assert await async_setup_component( From 98e8e893649b020748fbdb97d1fad57205e1e1c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 18 Aug 2021 21:30:37 +0200 Subject: [PATCH 519/903] Mill data coordinator (#53603) Co-authored-by: Paulus Schoutsen --- homeassistant/components/mill/__init__.py | 35 ++++- homeassistant/components/mill/climate.py | 163 +++++++++----------- homeassistant/components/mill/manifest.json | 2 +- homeassistant/components/mill/sensor.py | 38 ++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 126 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 75422cd26e1..73cb65daf05 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -1,15 +1,43 @@ """The mill component.""" +from datetime import timedelta +import logging + from mill import Mill from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + PLATFORMS = ["climate", "sensor"] +class MillDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Mill data.""" + + def __init__( + self, + hass: HomeAssistant, + *, + mill_data_connection: Mill, + ) -> None: + """Initialize global Mill data updater.""" + self.mill_data_connection = mill_data_connection + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_method=mill_data_connection.fetch_heater_data, + update_interval=timedelta(seconds=30), + ) + + async def async_setup_entry(hass, entry): """Set up the Mill heater.""" mill_data_connection = Mill( @@ -20,9 +48,12 @@ async def async_setup_entry(hass, entry): if not await mill_data_connection.connect(): raise ConfigEntryNotReady - await mill_data_connection.find_all_heaters() + hass.data[DOMAIN] = MillDataUpdateCoordinator( + hass, + mill_data_connection=mill_data_connection, + ) - hass.data[DOMAIN] = mill_data_connection + await hass.data[DOMAIN].async_config_entry_first_refresh() hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 16c78329b0b..199bdf393a1 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -11,8 +11,10 @@ from homeassistant.components.climate.const import ( SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_AWAY_TEMP, @@ -41,11 +43,11 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema( async def async_setup_entry(hass, entry, async_add_entities): """Set up the Mill climate.""" - mill_data_connection = hass.data[DOMAIN] + mill_data_coordinator = hass.data[DOMAIN] dev = [] - for heater in mill_data_connection.heaters.values(): - dev.append(MillHeater(heater, mill_data_connection)) + for heater in mill_data_coordinator.data.values(): + dev.append(MillHeater(mill_data_coordinator, heater)) async_add_entities(dev) async def set_room_temp(service): @@ -54,7 +56,7 @@ async def async_setup_entry(hass, entry, async_add_entities): sleep_temp = service.data.get(ATTR_SLEEP_TEMP) comfort_temp = service.data.get(ATTR_COMFORT_TEMP) away_temp = service.data.get(ATTR_AWAY_TEMP) - await mill_data_connection.set_room_temperatures_by_name( + await mill_data_coordinator.mill_data_connection.set_room_temperatures_by_name( room_name, sleep_temp, comfort_temp, away_temp ) @@ -63,122 +65,97 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -class MillHeater(ClimateEntity): +class MillHeater(CoordinatorEntity, ClimateEntity): """Representation of a Mill Thermostat device.""" _attr_fan_modes = [FAN_ON, HVAC_MODE_OFF] _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_supported_features = SUPPORT_FLAGS - _attr_target_temperature_step = 1 + _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = TEMP_CELSIUS - def __init__(self, heater, mill_data_connection): + def __init__(self, coordinator, heater): """Initialize the thermostat.""" - self._heater = heater - self._conn = mill_data_connection + super().__init__(coordinator) + + self._id = heater.device_id self._attr_unique_id = heater.device_id self._attr_name = heater.name - - @property - def available(self): - """Return True if entity is available.""" - return self._heater.available - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - res = { - "open_window": self._heater.open_window, - "heating": self._heater.is_heating, - "controlled_by_tibber": self._heater.tibber_control, - "heater_generation": 1 if self._heater.is_gen1 else 2, + self._attr_device_info = { + "identifiers": {(DOMAIN, heater.device_id)}, + "name": self.name, + "manufacturer": MANUFACTURER, + "model": f"generation {1 if heater.is_gen1 else 2}", } - if self._heater.room: - res["room"] = self._heater.room.name - res["avg_room_temp"] = self._heater.room.avg_temp + if heater.is_gen1: + self._attr_hvac_modes = [HVAC_MODE_HEAT] else: - res["room"] = "Independent device" - return res - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._heater.set_temp - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._heater.current_temp - - @property - def fan_mode(self): - """Return the fan setting.""" - return FAN_ON if self._heater.fan_status == 1 else HVAC_MODE_OFF - - @property - def hvac_action(self): - """Return current hvac i.e. heat, cool, idle.""" - if self._heater.is_gen1 or self._heater.is_heating == 1: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE - - @property - def hvac_mode(self) -> str: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - if self._heater.is_gen1 or self._heater.power_status == 1: - return HVAC_MODE_HEAT - return HVAC_MODE_OFF - - @property - def hvac_modes(self): - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - if self._heater.is_gen1: - return [HVAC_MODE_HEAT] - return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + self._attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + self._update_attr(heater) async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - await self._conn.set_heater_temp(self._heater.device_id, int(temperature)) + await self.coordinator.mill_data_connection.set_heater_temp( + self._id, int(temperature) + ) + await self.coordinator.async_request_refresh() async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" fan_status = 1 if fan_mode == FAN_ON else 0 - await self._conn.heater_control(self._heater.device_id, fan_status=fan_status) + await self.coordinator.mill_data_connection.heater_control( + self._id, fan_status=fan_status + ) + await self.coordinator.async_request_refresh() async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" + heater = self.coordinator.data[self._id] + if hvac_mode == HVAC_MODE_HEAT: - await self._conn.heater_control(self._heater.device_id, power_status=1) - elif hvac_mode == HVAC_MODE_OFF and not self._heater.is_gen1: - await self._conn.heater_control(self._heater.device_id, power_status=0) + await self.coordinator.mill_data_connection.heater_control( + self._id, power_status=1 + ) + await self.coordinator.async_request_refresh() + elif hvac_mode == HVAC_MODE_OFF and not heater.is_gen1: + await self.coordinator.mill_data_connection.heater_control( + self._id, power_status=0 + ) + await self.coordinator.async_request_refresh() - async def async_update(self): - """Retrieve latest state.""" - self._heater = await self._conn.update_device(self._heater.device_id) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr(self.coordinator.data[self._id]) + self.async_write_ha_state() - @property - def device_id(self): - """Return the ID of the physical device this sensor is part of.""" - return self._heater.device_id - - @property - def device_info(self): - """Return the device_info of the device.""" - device_info = { - "identifiers": {(DOMAIN, self.device_id)}, - "name": self.name, - "manufacturer": MANUFACTURER, - "model": f"generation {1 if self._heater.is_gen1 else 2}", + @callback + def _update_attr(self, heater): + self._attr_available = heater.available + self._attr_extra_state_attributes = { + "open_window": heater.open_window, + "heating": heater.is_heating, + "controlled_by_tibber": heater.tibber_control, + "heater_generation": 1 if heater.is_gen1 else 2, } - return device_info + if heater.room: + self._attr_extra_state_attributes["room"] = heater.room.name + self._attr_extra_state_attributes["avg_room_temp"] = heater.room.avg_temp + else: + self._attr_extra_state_attributes["room"] = "Independent device" + self._attr_target_temperature = heater.set_temp + self._attr_current_temperature = heater.current_temp + self._attr_fan_mode = FAN_ON if heater.fan_status == 1 else HVAC_MODE_OFF + if heater.is_gen1 or heater.is_heating == 1: + self._attr_hvac_action = CURRENT_HVAC_HEAT + else: + self._attr_hvac_action = CURRENT_HVAC_IDLE + if heater.is_gen1 or heater.power_status == 1: + self._attr_hvac_mode = HVAC_MODE_HEAT + else: + self._attr_hvac_mode = HVAC_MODE_OFF diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 161bbe274ef..33a7c35c169 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -2,7 +2,7 @@ "domain": "mill", "name": "Mill", "documentation": "https://www.home-assistant.io/integrations/mill", - "requirements": ["millheater==0.5.0"], + "requirements": ["millheater==0.5.2"], "codeowners": ["@danielhiversen"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index ce7704ad1be..11b006e4b6e 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -6,6 +6,8 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.const import ENERGY_KILO_WATT_HOUR +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONSUMPTION_TODAY, CONSUMPTION_YEAR, DOMAIN, MANUFACTURER @@ -13,27 +15,28 @@ from .const import CONSUMPTION_TODAY, CONSUMPTION_YEAR, DOMAIN, MANUFACTURER async def async_setup_entry(hass, entry, async_add_entities): """Set up the Mill sensor.""" - mill_data_connection = hass.data[DOMAIN] + mill_data_coordinator = hass.data[DOMAIN] entities = [ - MillHeaterEnergySensor(heater, mill_data_connection, sensor_type) + MillHeaterEnergySensor(mill_data_coordinator, sensor_type, heater) for sensor_type in (CONSUMPTION_TODAY, CONSUMPTION_YEAR) - for heater in mill_data_connection.heaters.values() + for heater in mill_data_coordinator.data.values() ] async_add_entities(entities) -class MillHeaterEnergySensor(SensorEntity): +class MillHeaterEnergySensor(CoordinatorEntity, SensorEntity): """Representation of a Mill Sensor device.""" _attr_device_class = DEVICE_CLASS_ENERGY _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_state_class = STATE_CLASS_TOTAL_INCREASING - def __init__(self, heater, mill_data_connection, sensor_type): + def __init__(self, coordinator, sensor_type, heater): """Initialize the sensor.""" + super().__init__(coordinator) + self._id = heater.device_id - self._conn = mill_data_connection self._sensor_type = sensor_type self._attr_name = f"{heater.name} {sensor_type.replace('_', ' ')}" @@ -44,20 +47,19 @@ class MillHeaterEnergySensor(SensorEntity): "manufacturer": MANUFACTURER, "model": f"generation {1 if heater.is_gen1 else 2}", } + self._update_attr(heater) - async def async_update(self): - """Retrieve latest state.""" - heater = await self._conn.update_device(self._id) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr(self.coordinator.data[self._id]) + self.async_write_ha_state() + + @callback + def _update_attr(self, heater): self._attr_available = heater.available if self._sensor_type == CONSUMPTION_TODAY: - _state = heater.day_consumption + self._attr_native_value = heater.day_consumption elif self._sensor_type == CONSUMPTION_YEAR: - _state = heater.year_consumption - else: - _state = None - if _state is None: - self._attr_native_value = _state - return - - self._attr_native_value = _state + self._attr_native_value = heater.year_consumption diff --git a/requirements_all.txt b/requirements_all.txt index 69da5abc72d..9ec1ccce7c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -980,7 +980,7 @@ micloud==0.3 miflora==0.7.0 # homeassistant.components.mill -millheater==0.5.0 +millheater==0.5.2 # homeassistant.components.minio minio==4.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e476881426c..710f4e9ae2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -551,7 +551,7 @@ mficlient==0.3.0 micloud==0.3 # homeassistant.components.mill -millheater==0.5.0 +millheater==0.5.2 # homeassistant.components.minio minio==4.0.9 From c74f9a8313c621c9d8c777b07216c286d55d6118 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Aug 2021 21:47:57 +0200 Subject: [PATCH 520/903] Remove stale references to last_reset (#54838) * Remove stale references to last_reset * Update tests --- homeassistant/components/kostal_plenticore/const.py | 3 --- tests/components/dsmr/test_sensor.py | 13 ------------- tests/components/mysensors/test_sensor.py | 2 -- 3 files changed, 18 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index 5cbc1a2af79..9f902da7d2f 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -16,14 +16,11 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, ) -from homeassistant.util.dt import utc_from_timestamp DOMAIN = "kostal_plenticore" ATTR_ENABLED_DEFAULT = "entity_registry_enabled_default" -LAST_RESET_NEVER = utc_from_timestamp(0) - # Defines all entities for process data. # # Each entry is defined with a tuple of these values: diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 88e984cea1b..6d40437d87a 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -14,7 +14,6 @@ from unittest.mock import DEFAULT, MagicMock from homeassistant import config_entries from homeassistant.components.dsmr.const import DOMAIN from homeassistant.components.sensor import ( - ATTR_LAST_RESET, ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, STATE_CLASS_MEASUREMENT, @@ -137,7 +136,6 @@ async def test_default_setup(hass, dsmr_connection_fixture): assert power_consumption.state == STATE_UNKNOWN assert power_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER assert power_consumption.attributes.get(ATTR_ICON) is None - assert power_consumption.attributes.get(ATTR_LAST_RESET) is None assert power_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert power_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None @@ -159,7 +157,6 @@ async def test_default_setup(hass, dsmr_connection_fixture): assert power_tariff.state == "low" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) is None assert power_tariff.attributes.get(ATTR_ICON) == "mdi:flash" - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) is None assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" @@ -167,7 +164,6 @@ async def test_default_setup(hass, dsmr_connection_fixture): gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING ) @@ -259,7 +255,6 @@ async def test_v4_meter(hass, dsmr_connection_fixture): assert power_tariff.state == "low" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) is None assert power_tariff.attributes.get(ATTR_ICON) == "mdi:flash" - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) is None assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" @@ -268,7 +263,6 @@ async def test_v4_meter(hass, dsmr_connection_fixture): assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING ) @@ -331,7 +325,6 @@ async def test_v5_meter(hass, dsmr_connection_fixture): assert power_tariff.state == "low" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) is None assert power_tariff.attributes.get(ATTR_ICON) == "mdi:flash" - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) is None assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" @@ -339,7 +332,6 @@ async def test_v5_meter(hass, dsmr_connection_fixture): gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING ) @@ -407,7 +399,6 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): assert power_tariff.state == "123.456" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY assert power_tariff.attributes.get(ATTR_ICON) is None - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING assert ( power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR @@ -421,7 +412,6 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING ) @@ -484,7 +474,6 @@ async def test_belgian_meter(hass, dsmr_connection_fixture): assert power_tariff.state == "normal" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) is None assert power_tariff.attributes.get(ATTR_ICON) == "mdi:flash" - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) is None assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" @@ -492,7 +481,6 @@ async def test_belgian_meter(hass, dsmr_connection_fixture): gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is DEVICE_CLASS_GAS - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is None assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING ) @@ -544,7 +532,6 @@ async def test_belgian_meter_low(hass, dsmr_connection_fixture): assert power_tariff.state == "low" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) is None assert power_tariff.attributes.get(ATTR_ICON) == "mdi:flash" - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) is None assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 18d88a24206..d648aebdefd 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -7,7 +7,6 @@ from mysensors.sensor import Sensor import pytest from homeassistant.components.sensor import ( - ATTR_LAST_RESET, ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, @@ -75,7 +74,6 @@ async def test_power_sensor( assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT - assert ATTR_LAST_RESET not in state.attributes async def test_energy_sensor( From c75c4aeea54f451b07a9428d12da61af606ebcf4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 Aug 2021 13:56:43 -0700 Subject: [PATCH 521/903] Bump frontend to 20210818.0 (#54851) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a4a97914622..a83e3572828 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210813.0" + "home-assistant-frontend==20210818.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 213503a92c5..7e4e57f608f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==3.3.2 defusedxml==0.7.1 emoji==1.2.0 hass-nabucasa==0.46.0 -home-assistant-frontend==20210813.0 +home-assistant-frontend==20210818.0 httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9ec1ccce7c3..37377856e5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -787,7 +787,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210813.0 +home-assistant-frontend==20210818.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 710f4e9ae2f..48151c9392f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -453,7 +453,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210813.0 +home-assistant-frontend==20210818.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 799a97789d4390b1feeaf678942248da12b579e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Aug 2021 16:40:45 -0500 Subject: [PATCH 522/903] Drop tado codeowner (#54849) - I no longer have any tado devices --- CODEOWNERS | 2 +- homeassistant/components/tado/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 85b89649a99..3606fade468 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -506,7 +506,7 @@ homeassistant/components/synology_dsm/* @hacf-fr @Quentame @mib1185 homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff homeassistant/components/system_bridge/* @timmo001 -homeassistant/components/tado/* @michaelarnauts @bdraco @noltari +homeassistant/components/tado/* @michaelarnauts @noltari homeassistant/components/tag/* @balloob @dmulcahey homeassistant/components/tahoma/* @philklei homeassistant/components/tankerkoenig/* @guillempages diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 758756e8127..8cf0ed260e8 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -3,7 +3,7 @@ "name": "Tado", "documentation": "https://www.home-assistant.io/integrations/tado", "requirements": ["python-tado==0.10.0"], - "codeowners": ["@michaelarnauts", "@bdraco", "@noltari"], + "codeowners": ["@michaelarnauts", "@noltari"], "config_flow": true, "homekit": { "models": ["tado", "AC02"] From 4a9a6cd5389a8578efdc78d580d1dfbf5a18ded0 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 19 Aug 2021 00:13:56 +0000 Subject: [PATCH 523/903] [ci skip] Translation update --- .../components/airtouch4/translations/cs.json | 17 +++++++++++++++++ .../components/airtouch4/translations/de.json | 19 +++++++++++++++++++ .../components/airtouch4/translations/it.json | 19 +++++++++++++++++++ .../components/airtouch4/translations/nl.json | 17 +++++++++++++++++ .../airtouch4/translations/zh-Hant.json | 19 +++++++++++++++++++ .../binary_sensor/translations/it.json | 8 ++++++++ .../binary_sensor/translations/nl.json | 5 +++++ .../components/sensor/translations/it.json | 18 ++++++++++++++++++ .../components/sensor/translations/nl.json | 16 ++++++++++++++++ .../components/tractive/translations/it.json | 4 +++- .../components/tractive/translations/nl.json | 4 +++- .../uptimerobot/translations/it.json | 17 +++++++++++++++-- 12 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/airtouch4/translations/cs.json create mode 100644 homeassistant/components/airtouch4/translations/de.json create mode 100644 homeassistant/components/airtouch4/translations/it.json create mode 100644 homeassistant/components/airtouch4/translations/nl.json create mode 100644 homeassistant/components/airtouch4/translations/zh-Hant.json diff --git a/homeassistant/components/airtouch4/translations/cs.json b/homeassistant/components/airtouch4/translations/cs.json new file mode 100644 index 00000000000..6fabc170b6e --- /dev/null +++ b/homeassistant/components/airtouch4/translations/cs.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/de.json b/homeassistant/components/airtouch4/translations/de.json new file mode 100644 index 00000000000..84f93d09962 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "no_units": "Es konnten keine AirTouch 4-Gruppen gefunden werden." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Richte deine AirTouch 4-Verbindungsdetails ein." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/it.json b/homeassistant/components/airtouch4/translations/it.json new file mode 100644 index 00000000000..f9a72a50e33 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/it.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "no_units": "Impossibile trovare alcun gruppo AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Imposta i dettagli della connessione AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/nl.json b/homeassistant/components/airtouch4/translations/nl.json new file mode 100644 index 00000000000..d6137499b3e --- /dev/null +++ b/homeassistant/components/airtouch4/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "no_units": "Kan geen AirTouch 4-groepen vinden." + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/zh-Hant.json b/homeassistant/components/airtouch4/translations/zh-Hant.json new file mode 100644 index 00000000000..9ac310b531b --- /dev/null +++ b/homeassistant/components/airtouch4/translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "no_units": "\u627e\u4e0d\u5230\u4efb\u4f55 AirTouch 4 \u7fa4\u7d44\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "title": "\u8a2d\u5b9a AirTouch 4 \u9023\u7dda\u8cc7\u8a0a\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/it.json b/homeassistant/components/binary_sensor/translations/it.json index 68c427cbc04..b6301ed8f62 100644 --- a/homeassistant/components/binary_sensor/translations/it.json +++ b/homeassistant/components/binary_sensor/translations/it.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} non sta rilevando un problema", "is_no_smoke": "{entity_name} non sta rilevando il fumo", "is_no_sound": "{entity_name} non sta rilevando il suono", + "is_no_update": "{entity_name} \u00e8 aggiornato", "is_no_vibration": "{entity_name} non sta rilevando la vibrazione", "is_not_bat_low": "{entity_name} la batteria \u00e8 normale", "is_not_cold": "{entity_name} non \u00e8 freddo", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} sta rilevando il fumo", "is_sound": "{entity_name} sta rilevando il suono", "is_unsafe": "{entity_name} non \u00e8 sicuro", + "is_update": "{entity_name} ha un aggiornamento disponibile", "is_vibration": "{entity_name} sta rilevando la vibrazione" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} ha smesso di rilevare un problema", "no_smoke": "{entity_name} ha smesso la rilevazione di fumo", "no_sound": "{entity_name} ha smesso di rilevare il suono", + "no_update": "{entity_name} \u00e8 diventato aggiornato", "no_vibration": "{entity_name} ha smesso di rilevare le vibrazioni", "not_bat_low": "{entity_name} batteria normale", "not_cold": "{entity_name} non \u00e8 diventato freddo", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} disattivato", "turned_on": "{entity_name} attivato", "unsafe": "{entity_name} diventato non sicuro", + "update": "{entity_name} ha ottenuto un aggiornamento disponibile", "vibration": "{entity_name} iniziato a rilevare le vibrazioni" } }, @@ -178,6 +182,10 @@ "off": "Assente", "on": "Rilevato" }, + "update": { + "off": "Aggiornato", + "on": "Aggiornamento disponibile" + }, "vibration": { "off": "Assente", "on": "Rilevata" diff --git a/homeassistant/components/binary_sensor/translations/nl.json b/homeassistant/components/binary_sensor/translations/nl.json index 9352bfa8d47..b44dd3449eb 100644 --- a/homeassistant/components/binary_sensor/translations/nl.json +++ b/homeassistant/components/binary_sensor/translations/nl.json @@ -42,6 +42,7 @@ "is_smoke": "{entity_name} detecteert rook", "is_sound": "{entity_name} detecteert geluid", "is_unsafe": "{entity_name} is onveilig", + "is_update": "{entity_name} heeft een update beschikbaar", "is_vibration": "{entity_name} detecteert trillingen" }, "trigger_type": { @@ -86,6 +87,7 @@ "turned_off": "{entity_name} uitgeschakeld", "turned_on": "{entity_name} ingeschakeld", "unsafe": "{entity_name} werd onveilig", + "update": "{entity_name} kreeg een update beschikbaar", "vibration": "{entity_name} begon trillingen te detecteren" } }, @@ -178,6 +180,9 @@ "off": "Niet gedetecteerd", "on": "Gedetecteerd" }, + "update": { + "on": "Update beschikbaar" + }, "vibration": { "off": "Niet gedetecteerd", "on": "Gedetecteerd" diff --git a/homeassistant/components/sensor/translations/it.json b/homeassistant/components/sensor/translations/it.json index 6ae19c201d7..7b9b483c024 100644 --- a/homeassistant/components/sensor/translations/it.json +++ b/homeassistant/components/sensor/translations/it.json @@ -6,12 +6,21 @@ "is_carbon_monoxide": "Livello attuale di concentrazione di monossido di carbonio in {entity_name}", "is_current": "Corrente attuale di {entity_name}", "is_energy": "Energia attuale di {entity_name}", + "is_gas": "Attuale gas di {entity_name}", "is_humidity": "Umidit\u00e0 attuale di {entity_name}", "is_illuminance": "Illuminazione attuale di {entity_name}", + "is_nitrogen_dioxide": "Attuale livello di concentrazione di biossido di azoto di {entity_name}", + "is_nitrogen_monoxide": "Attuale livello di concentrazione di monossido di azoto di {entity_name}", + "is_nitrous_oxide": "Attuale livello di concentrazione di ossidi di azoto di {entity_name}", + "is_ozone": "Attuale livello di concentrazione di ozono di {entity_name}", + "is_pm1": "Attuale livello di concentrazione di PM1 di {entity_name}", + "is_pm10": "Attuale livello di concentrazione di PM10 di {entity_name}", + "is_pm25": "Attuale livello di concentrazione di PM2.5 di {entity_name}", "is_power": "Alimentazione attuale di {entity_name}", "is_power_factor": "Fattore di potenza attuale di {entity_name}", "is_pressure": "Pressione attuale di {entity_name}", "is_signal_strength": "Potenza del segnale attuale di {entity_name}", + "is_sulphur_dioxide": "Attuale livello di concentrazione di anidride solforosa di {entity_name}", "is_temperature": "Temperatura attuale di {entity_name}", "is_value": "Valore attuale di {entity_name}", "is_voltage": "Tensione attuale di {entity_name}" @@ -22,12 +31,21 @@ "carbon_monoxide": "Variazioni nella concentrazione di monossido di carbonio di {entity_name}", "current": "variazioni di corrente di {entity_name}", "energy": "variazioni di energia di {entity_name}", + "gas": "Variazioni di gas di {entity_name}", "humidity": "variazioni di umidit\u00e0 di {entity_name} ", "illuminance": "variazioni dell'illuminazione di {entity_name}", + "nitrogen_dioxide": "Variazioni della concentrazione di biossido di azoto di {entity_name}", + "nitrogen_monoxide": "Variazioni della concentrazione di monossido di azoto di {entity_name}", + "nitrous_oxide": "Variazioni della concentrazione di ossidi di azoto di {entity_name}", + "ozone": "Variazioni della concentrazione di ozono di {entity_name}", + "pm1": "Variazioni della concentrazione di PM1 di {entity_name}", + "pm10": "Variazioni della concentrazione di PM10 di {entity_name}", + "pm25": "Variazioni della concentrazione di PM2.5 di {entity_name}", "power": "variazioni di alimentazione di {entity_name}", "power_factor": "variazioni del fattore di potenza di {entity_name}", "pressure": "variazioni della pressione di {entity_name}", "signal_strength": "variazioni della potenza del segnale di {entity_name}", + "sulphur_dioxide": "Variazioni della concentrazione di anidride solforosa di {entity_name}", "temperature": "variazioni di temperatura di {entity_name}", "value": "{entity_name} valori cambiati", "voltage": "variazioni di tensione di {entity_name}" diff --git a/homeassistant/components/sensor/translations/nl.json b/homeassistant/components/sensor/translations/nl.json index 933caf15de8..c3ab0bf5bfa 100644 --- a/homeassistant/components/sensor/translations/nl.json +++ b/homeassistant/components/sensor/translations/nl.json @@ -9,10 +9,18 @@ "is_gas": "Huidig {entity_name} gas", "is_humidity": "Huidige {entity_name} vochtigheidsgraad", "is_illuminance": "Huidige {entity_name} verlichtingssterkte", + "is_nitrogen_dioxide": "Huidige {entity_name} stikstofdioxideconcentratie", + "is_nitrogen_monoxide": "Huidige {entity_name} stikstofmonoxideconcentratie", + "is_nitrous_oxide": "Huidige {entity_name} distikstofmonoxideconcentratie", + "is_ozone": "Huidige {entity_name} ozonconcentratie", + "is_pm1": "Huidige {entity_name} PM1-concentratie", + "is_pm10": "Huidige {entity_name} PM10-concentratie", + "is_pm25": "Huidige {entity_name} PM2.5-concentratie", "is_power": "Huidige {entity_name}\nvermogen", "is_power_factor": "Huidige {entity_name} vermogensfactor", "is_pressure": "Huidige {entity_name} druk", "is_signal_strength": "Huidige {entity_name} signaalsterkte", + "is_sulphur_dioxide": "Huidige {entity_name} zwaveldioxideconcentratie", "is_temperature": "Huidige {entity_name} temperatuur", "is_value": "Huidige {entity_name} waarde", "is_voltage": "Huidige {entity_name} spanning" @@ -26,10 +34,18 @@ "gas": "{entity_name} gas verandert", "humidity": "{entity_name} vochtigheidsgraad gewijzigd", "illuminance": "{entity_name} verlichtingssterkte gewijzigd", + "nitrogen_dioxide": "{entity_name} stikstofdioxideconcentratieverandering", + "nitrogen_monoxide": "{entity_name} stikstofmonoxideconcentratieverandering", + "nitrous_oxide": "{entity_name} distikstofmonoxideconcentratieverandering", + "ozone": "{entity_name} ozonconcentratieveranderingen", + "pm1": "{entity_name} PM1-concentratieveranderingen", + "pm10": "{entity_name} PM10-concentratieveranderingen", + "pm25": "{entity_name} PM2.5-concentratieveranderingen", "power": "{entity_name} vermogen gewijzigd", "power_factor": "{entity_name} power factor verandert", "pressure": "{entity_name} druk gewijzigd", "signal_strength": "{entity_name} signaalsterkte gewijzigd", + "sulphur_dioxide": "{entity_name} zwaveldioxideconcentratieveranderingen", "temperature": "{entity_name} temperatuur gewijzigd", "value": "{entity_name} waarde gewijzigd", "voltage": "{entity_name} voltage verandert" diff --git a/homeassistant/components/tractive/translations/it.json b/homeassistant/components/tractive/translations/it.json index 484d1e229e2..44cdc2df3d7 100644 --- a/homeassistant/components/tractive/translations/it.json +++ b/homeassistant/components/tractive/translations/it.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_failed_existing": "Impossibile aggiornare la voce di configurazione, rimuovere l'integrazione e configurarla di nuovo.", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "invalid_auth": "Autenticazione non valida", diff --git a/homeassistant/components/tractive/translations/nl.json b/homeassistant/components/tractive/translations/nl.json index 2ae14092cde..b0e1f17cdc3 100644 --- a/homeassistant/components/tractive/translations/nl.json +++ b/homeassistant/components/tractive/translations/nl.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Apparaat is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "reauth_failed_existing": "Kon het configuratie-item niet bijwerken, verwijder de integratie en stel deze opnieuw in.", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "invalid_auth": "Ongeldige authenticatie", diff --git a/homeassistant/components/uptimerobot/translations/it.json b/homeassistant/components/uptimerobot/translations/it.json index 6b151199afe..517bbf6463f 100644 --- a/homeassistant/components/uptimerobot/translations/it.json +++ b/homeassistant/components/uptimerobot/translations/it.json @@ -1,17 +1,30 @@ { "config": { "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato" + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_failed_existing": "Impossibile aggiornare la voce di configurazione, rimuovere l'integrazione e configurarla di nuovo.", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", + "unknown": "Errore imprevisto" }, "error": { "cannot_connect": "Impossibile connettersi", + "invalid_api_key": "Chiave API non valida", + "reauth_failed_matching_account": "La chiave API che hai fornito non corrisponde all'ID account per la configurazione esistente.", "unknown": "Errore imprevisto" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Chiave API" + }, + "description": "Devi fornire una nuova chiave API di sola lettura da Uptime Robot", + "title": "Autenticare nuovamente l'integrazione" + }, "user": { "data": { "api_key": "Chiave API" - } + }, + "description": "Devi fornire una chiave API di sola lettura da Uptime Robot" } } } From 4f9c7882166985534475230b6b823cf751eab05f Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Thu, 19 Aug 2021 11:50:28 +1000 Subject: [PATCH 524/903] Update PULL_REQUEST_TEMPLATE.md (#54762) * Update PULL_REQUEST_TEMPLATE.md * Update PULL_REQUEST_TEMPLATE.md Address review comments by moving changes into 'Checklist' section * Update PULL_REQUEST_TEMPLATE.md * Update .github/PULL_REQUEST_TEMPLATE.md Co-authored-by: Franck Nijhof * Update .github/PULL_REQUEST_TEMPLATE.md Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- .github/PULL_REQUEST_TEMPLATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7c169580cb2..974022834fb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -71,6 +71,7 @@ If the code communicates with devices, web services, or third-party tools: Updated and included derived files by running: `python3 -m script.hassfest`. - [ ] New or updated dependencies have been added to `requirements_all.txt`. Updated by running `python3 -m script.gen_requirements_all`. +- [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description. - [ ] Untested files have been added to `.coveragerc`. The integration reached or maintains the following [Integration Quality Scale][quality-scale]: From d3f7312834dc7dea4426cc3ed572384a37945e79 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Wed, 18 Aug 2021 19:50:46 -0600 Subject: [PATCH 525/903] Improve MyQ code quality through creation of MyQ entity (#54728) --- homeassistant/components/myq/__init__.py | 56 ++++++++++++++++++- homeassistant/components/myq/binary_sensor.py | 44 +-------------- homeassistant/components/myq/cover.py | 52 +++-------------- homeassistant/components/myq/light.py | 49 +--------------- 4 files changed, 67 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index 063f044117e..253c10544c9 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -3,6 +3,13 @@ from datetime import timedelta import logging import pymyq +from pymyq.const import ( + DEVICE_STATE as MYQ_DEVICE_STATE, + DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, + KNOWN_MODELS, + MANUFACTURER, +) +from pymyq.device import MyQDevice from pymyq.errors import InvalidCredentialsError, MyQError from homeassistant.config_entries import ConfigEntry @@ -10,7 +17,11 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, PLATFORMS, UPDATE_INTERVAL @@ -63,3 +74,46 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +class MyQEntity(CoordinatorEntity): + """Base class for MyQ Entities.""" + + def __init__(self, coordinator: DataUpdateCoordinator, device: MyQDevice) -> None: + """Initialize class.""" + super().__init__(coordinator) + self._device = device + self._attr_unique_id = device.device_id + + @property + def name(self): + """Return the name if any, name can change if user changes it within MyQ.""" + return self._device.name + + @property + def device_info(self): + """Return the device_info of the device.""" + device_info = { + "identifiers": {(DOMAIN, self._device.device_id)}, + "name": self._device.name, + "manufacturer": MANUFACTURER, + "sw_version": self._device.firmware_version, + } + model = ( + KNOWN_MODELS.get(self._device.device_id[2:4]) + if self._device.device_id is not None + else None + ) + if model: + device_info["model"] = model + if self._device.parent_device_id: + device_info["via_device"] = (DOMAIN, self._device.parent_device_id) + return device_info + + @property + def available(self): + """Return if the device is online.""" + # Not all devices report online so assume True if its missing + return super().available and self._device.device_json[MYQ_DEVICE_STATE].get( + MYQ_DEVICE_STATE_ONLINE, True + ) diff --git a/homeassistant/components/myq/binary_sensor.py b/homeassistant/components/myq/binary_sensor.py index 96ab589253b..9f2d766fcc4 100644 --- a/homeassistant/components/myq/binary_sensor.py +++ b/homeassistant/components/myq/binary_sensor.py @@ -1,17 +1,10 @@ """Support for MyQ gateways.""" -from pymyq.const import ( - DEVICE_STATE as MYQ_DEVICE_STATE, - DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, - KNOWN_MODELS, - MANUFACTURER, -) - from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, BinarySensorEntity, ) -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import MyQEntity from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY @@ -29,16 +22,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(entities) -class MyQBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): +class MyQBinarySensorEntity(MyQEntity, BinarySensorEntity): """Representation of a MyQ gateway.""" _attr_device_class = DEVICE_CLASS_CONNECTIVITY - def __init__(self, coordinator, device): - """Initialize with API object, device id.""" - super().__init__(coordinator) - self._device = device - @property def name(self): """Return the name of the garage door if any.""" @@ -47,35 +35,9 @@ class MyQBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): @property def is_on(self): """Return if the device is online.""" - if not self.coordinator.last_update_success: - return False - - # Not all devices report online so assume True if its missing - return self._device.device_json[MYQ_DEVICE_STATE].get( - MYQ_DEVICE_STATE_ONLINE, True - ) + return super().available @property def available(self) -> bool: """Entity is always available.""" return True - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self._device.device_id - - @property - def device_info(self): - """Return the device_info of the device.""" - device_info = { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self.name, - "manufacturer": MANUFACTURER, - "sw_version": self._device.firmware_version, - } - model = KNOWN_MODELS.get(self._device.device_id[2:4]) - if model: - device_info["model"] = model - - return device_info diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index 87b8223c477..e8e06dc3b22 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -1,13 +1,7 @@ """Support for MyQ-Enabled Garage Doors.""" import logging -from pymyq.const import ( - DEVICE_STATE as MYQ_DEVICE_STATE, - DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, - DEVICE_TYPE_GATE as MYQ_DEVICE_TYPE_GATE, - KNOWN_MODELS, - MANUFACTURER, -) +from pymyq.const import DEVICE_TYPE_GATE as MYQ_DEVICE_TYPE_GATE from pymyq.errors import MyQError from homeassistant.components.cover import ( @@ -19,8 +13,8 @@ from homeassistant.components.cover import ( ) from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import MyQEntity from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS _LOGGER = logging.getLogger(__name__) @@ -33,16 +27,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = data[MYQ_COORDINATOR] async_add_entities( - [MyQDevice(coordinator, device) for device in myq.covers.values()] + [MyQCover(coordinator, device) for device in myq.covers.values()] ) -class MyQDevice(CoordinatorEntity, CoverEntity): +class MyQCover(MyQEntity, CoverEntity): """Representation of a MyQ cover.""" + _attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + def __init__(self, coordinator, device): """Initialize with API object, device id.""" - super().__init__(coordinator) + super().__init__(coordinator, device) self._device = device if device.device_type == MYQ_DEVICE_TYPE_GATE: self._attr_device_class = DEVICE_CLASS_GATE @@ -50,19 +46,6 @@ class MyQDevice(CoordinatorEntity, CoverEntity): self._attr_device_class = DEVICE_CLASS_GARAGE self._attr_unique_id = device.device_id - @property - def name(self): - """Return the name of the garage door if any.""" - return self._device.name - - @property - def available(self): - """Return if the device is online.""" - # Not all devices report online so assume True if its missing - return super().available and self._device.device_json[MYQ_DEVICE_STATE].get( - MYQ_DEVICE_STATE_ONLINE, True - ) - @property def is_closed(self): """Return true if cover is closed, else False.""" @@ -83,11 +66,6 @@ class MyQDevice(CoordinatorEntity, CoverEntity): """Return if the cover is opening or not.""" return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE - async def async_close_cover(self, **kwargs): """Issue close command to cover.""" if self.is_closing or self.is_closed: @@ -133,19 +111,3 @@ class MyQDevice(CoordinatorEntity, CoverEntity): if not result: raise HomeAssistantError(f"Opening of cover {self._device.name} failed") - - @property - def device_info(self): - """Return the device_info of the device.""" - device_info = { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self._device.name, - "manufacturer": MANUFACTURER, - "sw_version": self._device.firmware_version, - } - model = KNOWN_MODELS.get(self._device.device_id[2:4]) - if model: - device_info["model"] = model - if self._device.parent_device_id: - device_info["via_device"] = (DOMAIN, self._device.parent_device_id) - return device_info diff --git a/homeassistant/components/myq/light.py b/homeassistant/components/myq/light.py index 98119c2157a..d8154d7c427 100644 --- a/homeassistant/components/myq/light.py +++ b/homeassistant/components/myq/light.py @@ -1,19 +1,13 @@ """Support for MyQ-Enabled lights.""" import logging -from pymyq.const import ( - DEVICE_STATE as MYQ_DEVICE_STATE, - DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, - KNOWN_MODELS, - MANUFACTURER, -) from pymyq.errors import MyQError from homeassistant.components.light import LightEntity from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import MyQEntity from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS _LOGGER = logging.getLogger(__name__) @@ -30,29 +24,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -class MyQLight(CoordinatorEntity, LightEntity): +class MyQLight(MyQEntity, LightEntity): """Representation of a MyQ light.""" _attr_supported_features = 0 - def __init__(self, coordinator, device): - """Initialize with API object, device id.""" - super().__init__(coordinator) - self._device = device - self._attr_unique_id = device.device_id - self._attr_name = device.name - - @property - def available(self): - """Return if the device is online.""" - if not super().available: - return False - - # Not all devices report online so assume True if its missing - return self._device.device_json[MYQ_DEVICE_STATE].get( - MYQ_DEVICE_STATE_ONLINE, True - ) - @property def is_on(self): """Return true if the light is on, else False.""" @@ -92,24 +68,3 @@ class MyQLight(CoordinatorEntity, LightEntity): # Write new state to HASS self.async_write_ha_state() - - @property - def device_info(self): - """Return the device_info of the device.""" - device_info = { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self._device.name, - "manufacturer": MANUFACTURER, - "sw_version": self._device.firmware_version, - } - if model := KNOWN_MODELS.get(self._device.device_id[2:4]): - device_info["model"] = model - if self._device.parent_device_id: - device_info["via_device"] = (DOMAIN, self._device.parent_device_id) - return device_info - - async def async_added_to_hass(self): - """Subscribe to updates.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) From e11ffbcdafca67b2d0f4d3059e41851dd9ea6c00 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 18 Aug 2021 22:24:44 -0400 Subject: [PATCH 526/903] Rework goalzero for EntityDescription (#54786) * Rework goalzero for EntityDescription * changes * fix * lint --- homeassistant/components/goalzero/__init__.py | 21 +-- .../components/goalzero/binary_sensor.py | 78 +++++--- .../components/goalzero/config_flow.py | 15 +- homeassistant/components/goalzero/const.py | 141 --------------- homeassistant/components/goalzero/sensor.py | 166 +++++++++++++++--- homeassistant/components/goalzero/switch.py | 63 ++++--- 6 files changed, 252 insertions(+), 232 deletions(-) diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 308934819cd..379a56512c6 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -43,7 +43,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [DOMAIN_BINARY_SENSOR, DOMAIN_SENSOR, DOMAIN_SWITCH] -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Goal Zero Yeti from a config entry.""" name = entry.data[CONF_NAME] host = entry.data[CONF_HOST] @@ -81,7 +81,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -94,7 +94,13 @@ class YetiEntity(CoordinatorEntity): _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} - def __init__(self, api, coordinator, name, server_unique_id): + def __init__( + self, + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti entity.""" super().__init__(coordinator) self.api = api @@ -104,15 +110,10 @@ class YetiEntity(CoordinatorEntity): @property def device_info(self) -> DeviceInfo: """Return the device information of the entity.""" - model = sw_version = None - if self.api.sysdata: - model = self.api.sysdata[ATTR_MODEL] - if self.api.data: - sw_version = self.api.data["firmwareVersion"] return { ATTR_IDENTIFIERS: {(DOMAIN, self._server_unique_id)}, ATTR_MANUFACTURER: "Goal Zero", ATTR_NAME: self._name, - ATTR_MODEL: str(model), - ATTR_SW_VERSION: str(sw_version), + ATTR_MODEL: self.api.sysdata.get(ATTR_MODEL), + ATTR_SW_VERSION: self.api.data.get("firmwareVersion"), } diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index f9a110eff55..21eecc678ad 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -1,14 +1,51 @@ """Support for Goal Zero Yeti Sensors.""" -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_NAME, CONF_NAME +from __future__ import annotations -from . import YetiEntity -from .const import BINARY_SENSOR_DICT, DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_POWER, + BinarySensorEntity, + BinarySensorEntityDescription, +) +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.update_coordinator import DataUpdateCoordinator + +from . import Yeti, YetiEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN PARALLEL_UPDATES = 0 +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="backlight", + name="Backlight", + icon="mdi:clock-digital", + ), + BinarySensorEntityDescription( + key="app_online", + name="App Online", + device_class=DEVICE_CLASS_CONNECTIVITY, + ), + BinarySensorEntityDescription( + key="isCharging", + name="Charging", + device_class=DEVICE_CLASS_BATTERY_CHARGING, + ), + BinarySensorEntityDescription( + key="inputDetected", + name="Input Detected", + device_class=DEVICE_CLASS_POWER, + ), +) -async def async_setup_entry(hass, entry, async_add_entities): + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Goal Zero Yeti sensor.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] @@ -17,10 +54,10 @@ async def async_setup_entry(hass, entry, async_add_entities): goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], name, - sensor_name, + description, entry.entry_id, ) - for sensor_name in BINARY_SENSOR_DICT + for description in BINARY_SENSOR_TYPES ) @@ -29,26 +66,19 @@ class YetiBinarySensor(YetiEntity, BinarySensorEntity): def __init__( self, - api, - coordinator, - name, - sensor_name, - server_unique_id, - ): + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + description: BinarySensorEntityDescription, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti sensor.""" super().__init__(api, coordinator, name, server_unique_id) - - self._condition = sensor_name - self._attr_device_class = BINARY_SENSOR_DICT[sensor_name].get(ATTR_DEVICE_CLASS) - self._attr_icon = BINARY_SENSOR_DICT[sensor_name].get(ATTR_ICON) - self._attr_name = f"{name} {BINARY_SENSOR_DICT[sensor_name].get(ATTR_NAME)}" - self._attr_unique_id = ( - f"{server_unique_id}/{BINARY_SENSOR_DICT[sensor_name].get(ATTR_NAME)}" - ) + self.entity_description = description + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{server_unique_id}/{description.key}" @property def is_on(self) -> bool: """Return if the service is on.""" - if self.api.data: - return self.api.data[self._condition] == 1 - return False + return self.api.data.get(self.entity_description.key) == 1 diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index 4c525de9c7d..cc2c4a9874f 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -13,6 +13,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.typing import DiscoveryInfoType from .const import DEFAULT_NAME, DOMAIN @@ -24,11 +25,11 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize a Goal Zero Yeti flow.""" self.ip_address = None - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle dhcp discovery.""" self.ip_address = discovery_info[IP_ADDRESS] @@ -36,7 +37,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured(updates={CONF_HOST: self.ip_address}) self._async_abort_entries_match({CONF_HOST: self.ip_address}) - _, error = await self._async_try_connect(self.ip_address) + _, error = await self._async_try_connect(str(self.ip_address)) if error is None: return await self.async_step_confirm_discovery() return self.async_abort(reason=error) @@ -63,7 +64,9 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def async_step_user(self, user_input=None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" errors = {} if user_input is not None: @@ -74,7 +77,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): mac_address, error = await self._async_try_connect(host) if error is None: - await self.async_set_unique_id(format_mac(mac_address)) + await self.async_set_unique_id(format_mac(str(mac_address))) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) return self.async_create_entry( title=name, @@ -98,7 +101,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_try_connect(self, host) -> tuple: + async def _async_try_connect(self, host: str) -> tuple[str | None, str | None]: """Try connecting to Goal Zero Yeti.""" try: session = async_get_clientsession(self.hass) diff --git a/homeassistant/components/goalzero/const.py b/homeassistant/components/goalzero/const.py index e9fed7dc52b..d99cacb253e 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -1,37 +1,6 @@ """Constants for the Goal Zero Yeti integration.""" from datetime import timedelta -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY_CHARGING, - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_POWER, -) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, - ELECTRIC_CURRENT_AMPERE, - ELECTRIC_POTENTIAL_VOLT, - ENERGY_WATT_HOUR, - PERCENTAGE, - POWER_WATT, - SIGNAL_STRENGTH_DECIBELS, - TEMP_CELSIUS, - TIME_MINUTES, - TIME_SECONDS, -) - ATTRIBUTION = "Data provided by Goal Zero" ATTR_DEFAULT_ENABLED = "default_enabled" @@ -41,113 +10,3 @@ DEFAULT_NAME = "Yeti" DATA_KEY_API = "api" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) - -BINARY_SENSOR_DICT = { - "backlight": {ATTR_NAME: "Backlight", ATTR_ICON: "mdi:clock-digital"}, - "app_online": { - ATTR_NAME: "App Online", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CONNECTIVITY, - }, - "isCharging": { - ATTR_NAME: "Charging", - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, - }, - "inputDetected": { - ATTR_NAME: "Input Detected", - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - }, -} - -SENSOR_DICT = { - "wattsIn": { - ATTR_NAME: "Watts In", - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: True, - }, - "ampsIn": { - ATTR_NAME: "Amps In", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, - ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: False, - }, - "wattsOut": { - ATTR_NAME: "Watts Out", - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: True, - }, - "ampsOut": { - ATTR_NAME: "Amps Out", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, - ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: False, - }, - "whOut": { - ATTR_NAME: "WH Out", - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: False, - }, - "whStored": { - ATTR_NAME: "WH Stored", - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: True, - }, - "volts": { - ATTR_NAME: "Volts", - ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, - ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, - ATTR_DEFAULT_ENABLED: False, - }, - "socPercent": { - ATTR_NAME: "State of Charge Percent", - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_DEFAULT_ENABLED: True, - }, - "timeToEmptyFull": { - ATTR_NAME: "Time to Empty/Full", - ATTR_DEVICE_CLASS: TIME_MINUTES, - ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, - ATTR_DEFAULT_ENABLED: True, - }, - "temperature": { - ATTR_NAME: "Temperature", - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_DEFAULT_ENABLED: True, - }, - "wifiStrength": { - ATTR_NAME: "Wifi Strength", - ATTR_DEVICE_CLASS: DEVICE_CLASS_SIGNAL_STRENGTH, - ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, - ATTR_DEFAULT_ENABLED: True, - }, - "timestamp": { - ATTR_NAME: "Total Run Time", - ATTR_UNIT_OF_MEASUREMENT: TIME_SECONDS, - ATTR_DEFAULT_ENABLED: False, - }, - "ssid": { - ATTR_NAME: "Wi-Fi SSID", - ATTR_DEFAULT_ENABLED: False, - }, - "ipAddr": { - ATTR_NAME: "IP Address", - ATTR_DEFAULT_ENABLED: False, - }, -} - -SWITCH_DICT = { - "v12PortStatus": "12V Port Status", - "usbPortStatus": "USB Port Status", - "acPortStatus": "AC Port Status", -} diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index dbb85aa2d48..8890c7db69c 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -2,28 +2,137 @@ from __future__ import annotations from homeassistant.components.sensor import ( - ATTR_LAST_RESET, - ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + SIGNAL_STRENGTH_DECIBELS, + TEMP_CELSIUS, + TIME_MINUTES, + TIME_SECONDS, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import ( - ATTR_DEFAULT_ENABLED, - DATA_KEY_API, - DATA_KEY_COORDINATOR, - DOMAIN, - SENSOR_DICT, +from . import Yeti, YetiEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="wattsIn", + name="Watts In", + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="ampsIn", + name="Amps In", + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="wattsOut", + name="Watts Out", + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="ampsOut", + name="Amps Out", + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="whOut", + name="WH Out", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="whStored", + name="WH Stored", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="volts", + name="Volts", + device_class=DEVICE_CLASS_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="socPercent", + name="State of Charge Percent", + device_class=DEVICE_CLASS_BATTERY, + unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="timeToEmptyFull", + name="Time to Empty/Full", + device_class=TIME_MINUTES, + unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + ), + SensorEntityDescription( + key="wifiStrength", + name="Wifi Strength", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + ), + SensorEntityDescription( + key="timestamp", + name="Total Run Time", + unit_of_measurement=TIME_SECONDS, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="ssid", + name="Wi-Fi SSID", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="ipAddr", + name="IP Address", + entity_registry_enabled_default=False, + ), ) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Goal Zero Yeti sensor.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] @@ -32,10 +141,10 @@ async def async_setup_entry(hass, entry, async_add_entities): goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], name, - sensor_name, + description, entry.entry_id, ) - for sensor_name in SENSOR_DICT + for description in SENSOR_TYPES ] async_add_entities(sensors, True) @@ -43,22 +152,21 @@ async def async_setup_entry(hass, entry, async_add_entities): class YetiSensor(YetiEntity, SensorEntity): """Representation of a Goal Zero Yeti sensor.""" - def __init__(self, api, coordinator, name, sensor_name, server_unique_id): + def __init__( + self, + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + description: SensorEntityDescription, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti sensor.""" super().__init__(api, coordinator, name, server_unique_id) - self._condition = sensor_name - sensor = SENSOR_DICT[sensor_name] - self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) - self._attr_entity_registry_enabled_default = sensor.get(ATTR_DEFAULT_ENABLED) - self._attr_last_reset = sensor.get(ATTR_LAST_RESET) - self._attr_name = f"{name} {sensor.get(ATTR_NAME)}" - self._attr_native_unit_of_measurement = sensor.get(ATTR_UNIT_OF_MEASUREMENT) - self._attr_state_class = sensor.get(ATTR_STATE_CLASS) - self._attr_unique_id = f"{server_unique_id}/{sensor_name}" + self._attr_name = f"{name} {description.name}" + self.entity_description = description + self._attr_unique_id = f"{server_unique_id}/{description.key}" @property - def native_value(self) -> str | None: + def native_value(self) -> str: """Return the state.""" - if self.api.data: - return self.api.data.get(self._condition) - return None + return self.api.data.get(self.entity_description.key) diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index 9d37bcb0b7b..767c728e62b 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -1,14 +1,35 @@ """Support for Goal Zero Yeti Switches.""" from __future__ import annotations -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +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.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN, SWITCH_DICT +from . import Yeti, YetiEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN + +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="v12PortStatus", + name="12V Port Status", + ), + SwitchEntityDescription( + key="usbPortStatus", + name="USB Port Status", + ), + SwitchEntityDescription( + key="acPortStatus", + name="AC Port Status", + ), +) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Goal Zero Yeti switch.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] @@ -17,10 +38,10 @@ async def async_setup_entry(hass, entry, async_add_entities): goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], name, - switch_name, + description, entry.entry_id, ) - for switch_name in SWITCH_DICT + for description in SWITCH_TYPES ) @@ -29,33 +50,31 @@ class YetiSwitch(YetiEntity, SwitchEntity): def __init__( self, - api, - coordinator, - name, - switch_name, - server_unique_id, - ): + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + description: SwitchEntityDescription, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti switch.""" super().__init__(api, coordinator, name, server_unique_id) - self._condition = switch_name - self._attr_name = f"{name} {SWITCH_DICT[switch_name]}" - self._attr_unique_id = f"{server_unique_id}/{switch_name}" + self.entity_description = description + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{server_unique_id}/{description.key}" @property def is_on(self) -> bool: """Return state of the switch.""" - if self.api.data: - return self.api.data[self._condition] - return False + return self.api.data.get(self.entity_description.key) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn off the switch.""" - payload = {self._condition: 0} + payload = {self.entity_description.key: 0} await self.api.post_state(payload=payload) self.coordinator.async_set_updated_data(data=payload) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn on the switch.""" - payload = {self._condition: 1} + payload = {self.entity_description.key: 1} await self.api.post_state(payload=payload) self.coordinator.async_set_updated_data(data=payload) From 71b123845cc03645fecd5a030b54afc5b8c4d452 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Wed, 18 Aug 2021 22:17:16 -0700 Subject: [PATCH 527/903] Always mock SubscriptionRegistry & DiscoveryResponder for wemo tests (#53967) * Always mock SubscriptionRegistry & DiscoveryResponder for wemo tests * Use autospec=True for patch --- tests/components/wemo/conftest.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 7766fe512cc..bf69318706c 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -22,8 +22,8 @@ def pywemo_model_fixture(): return "LightSwitch" -@pytest.fixture(name="pywemo_registry") -def pywemo_registry_fixture(): +@pytest.fixture(name="pywemo_registry", autouse=True) +async def async_pywemo_registry_fixture(): """Fixture for SubscriptionRegistry instances.""" registry = create_autospec(pywemo.SubscriptionRegistry, instance=True) @@ -40,6 +40,13 @@ def pywemo_registry_fixture(): yield registry +@pytest.fixture(name="pywemo_discovery_responder", autouse=True) +def pywemo_discovery_responder_fixture(): + """Fixture for the DiscoveryResponder instance.""" + with patch("pywemo.ssdp.DiscoveryResponder", autospec=True): + yield + + @pytest.fixture(name="pywemo_device") def pywemo_device_fixture(pywemo_registry, pywemo_model): """Fixture for WeMoDevice instances.""" From e7fa3e727b1cce2f7c7764810bf2c88a8024e31b Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Wed, 18 Aug 2021 23:38:52 -0700 Subject: [PATCH 528/903] Bump pywemo to 0.6.7 (#54862) --- homeassistant/components/wemo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 21a7760741a..59eae24c714 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -3,7 +3,7 @@ "name": "Belkin WeMo", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wemo", - "requirements": ["pywemo==0.6.6"], + "requirements": ["pywemo==0.6.7"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/requirements_all.txt b/requirements_all.txt index 37377856e5f..96b6d6757b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1980,7 +1980,7 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.6 +pywemo==0.6.7 # homeassistant.components.wilight pywilight==0.0.70 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48151c9392f..c7ac2def786 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1106,7 +1106,7 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.6 +pywemo==0.6.7 # homeassistant.components.wilight pywilight==0.0.70 From faec82ae8f82bf5f9fb43a3c601a2e295b49dfc8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 19 Aug 2021 09:27:43 +0200 Subject: [PATCH 529/903] Add binary sensor platform to Renault integration (#54750) * Add binary sensor platform * Add tests * Simplify code * Adjust descriptions * Adjust tests * Make "fuel" tests more explicit * Updates for device registry checks --- .../components/renault/binary_sensor.py | 58 +++++++ homeassistant/components/renault/const.py | 1 + tests/components/renault/const.py | 50 ++++++ .../components/renault/test_binary_sensor.py | 155 ++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 homeassistant/components/renault/binary_sensor.py create mode 100644 tests/components/renault/test_binary_sensor.py diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py new file mode 100644 index 00000000000..dd3ccb036e0 --- /dev/null +++ b/homeassistant/components/renault/binary_sensor.py @@ -0,0 +1,58 @@ +"""Support for Renault binary sensors.""" +from __future__ import annotations + +from renault_api.kamereon.enums import ChargeState, PlugState + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_PLUG, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .renault_entities import RenaultBatteryDataEntity, RenaultDataEntity +from .renault_hub import RenaultHub + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] + entities: list[RenaultDataEntity] = [] + for vehicle in proxy.vehicles.values(): + if "battery" in vehicle.coordinators: + entities.append(RenaultPluggedInSensor(vehicle, "Plugged In")) + entities.append(RenaultChargingSensor(vehicle, "Charging")) + async_add_entities(entities) + + +class RenaultPluggedInSensor(RenaultBatteryDataEntity, BinarySensorEntity): + """Plugged In binary sensor.""" + + _attr_device_class = DEVICE_CLASS_PLUG + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if (not self.data) or (self.data.plugStatus is None): + return None + return self.data.get_plug_status() == PlugState.PLUGGED + + +class RenaultChargingSensor(RenaultBatteryDataEntity, BinarySensorEntity): + """Charging binary sensor.""" + + _attr_device_class = DEVICE_CLASS_BATTERY_CHARGING + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if (not self.data) or (self.data.chargingStatus is None): + return None + return self.data.get_charging_status() == ChargeState.CHARGE_IN_PROGRESS diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 51f6c10c6f1..0987d1829ed 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -7,6 +7,7 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" DEFAULT_SCAN_INTERVAL = 300 # 5 minutes PLATFORMS = [ + "binary_sensor", "sensor", ] diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 8c3d6e9f98f..2c742aa07cd 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -1,4 +1,9 @@ """Constants for the Renault integration tests.""" +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_PLUG, + DOMAIN as BINARY_SENSOR_DOMAIN, +) from homeassistant.components.renault.const import ( CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, @@ -19,6 +24,8 @@ from homeassistant.const import ( LENGTH_KILOMETERS, PERCENTAGE, POWER_KILO_WATT, + STATE_OFF, + STATE_ON, STATE_UNKNOWN, TEMP_CELSIUS, TIME_MINUTES, @@ -54,6 +61,20 @@ MOCK_VEHICLES = { "cockpit": "cockpit_ev.json", "hvac_status": "hvac_status.json", }, + BINARY_SENSOR_DOMAIN: [ + { + "entity_id": "binary_sensor.plugged_in", + "unique_id": "vf1aaaaa555777999_plugged_in", + "result": STATE_ON, + "class": DEVICE_CLASS_PLUG, + }, + { + "entity_id": "binary_sensor.charging", + "unique_id": "vf1aaaaa555777999_charging", + "result": STATE_ON, + "class": DEVICE_CLASS_BATTERY_CHARGING, + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -147,6 +168,20 @@ MOCK_VEHICLES = { "charge_mode": "charge_mode_schedule.json", "cockpit": "cockpit_ev.json", }, + BINARY_SENSOR_DOMAIN: [ + { + "entity_id": "binary_sensor.plugged_in", + "unique_id": "vf1aaaaa555777999_plugged_in", + "result": STATE_OFF, + "class": DEVICE_CLASS_PLUG, + }, + { + "entity_id": "binary_sensor.charging", + "unique_id": "vf1aaaaa555777999_charging", + "result": STATE_OFF, + "class": DEVICE_CLASS_BATTERY_CHARGING, + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -233,6 +268,20 @@ MOCK_VEHICLES = { "charge_mode": "charge_mode_always.json", "cockpit": "cockpit_fuel.json", }, + BINARY_SENSOR_DOMAIN: [ + { + "entity_id": "binary_sensor.plugged_in", + "unique_id": "vf1aaaaa555777123_plugged_in", + "result": STATE_ON, + "class": DEVICE_CLASS_PLUG, + }, + { + "entity_id": "binary_sensor.charging", + "unique_id": "vf1aaaaa555777123_charging", + "result": STATE_ON, + "class": DEVICE_CLASS_BATTERY_CHARGING, + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -327,6 +376,7 @@ MOCK_VEHICLES = { # Ignore, # charge-mode ], "endpoints": {"cockpit": "cockpit_fuel.json"}, + BINARY_SENSOR_DOMAIN: [], SENSOR_DOMAIN: [ { "entity_id": "sensor.fuel_autonomy", diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py new file mode 100644 index 00000000000..71bb90f16a6 --- /dev/null +++ b/tests/components/renault/test_binary_sensor.py @@ -0,0 +1,155 @@ +"""Tests for Renault binary sensors.""" +from unittest.mock import patch + +import pytest +from renault_api.kamereon import exceptions + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.setup import async_setup_component + +from . import ( + check_device_registry, + setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, + setup_renault_integration_vehicle_with_side_effect, +) +from .const import MOCK_VEHICLES + +from tests.common import mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_binary_sensors(hass, vehicle_type): + """Test for Renault binary sensors.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + await setup_renault_integration_vehicle(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BINARY_SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == expected_entity["result"] + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_binary_sensor_empty(hass, vehicle_type): + """Test for Renault binary sensors with empty data from Renault.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_no_data(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BINARY_SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_binary_sensor_errors(hass, vehicle_type): + """Test for Renault binary sensors with temporary failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + invalid_upstream_exception = exceptions.InvalidUpstreamException( + "err.tech.500", + "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BINARY_SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +async def test_binary_sensor_access_denied(hass): + """Test for Renault binary sensors with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + access_denied_exception = exceptions.AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, access_denied_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 + + +async def test_binary_sensor_not_supported(hass): + """Test for Renault binary sensors with not supported failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + not_supported_exception = exceptions.NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, not_supported_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 From 0688aaa2b6546feae6241410541aae1675a4f0c0 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 19 Aug 2021 09:37:31 +0200 Subject: [PATCH 530/903] Check for duplicate entity name/address in modbus entities (#54669) * Check for duplicate entity name/address. --- homeassistant/components/modbus/__init__.py | 8 ++++- homeassistant/components/modbus/validators.py | 34 +++++++++++++++++++ tests/components/modbus/test_cover.py | 2 +- tests/components/modbus/test_fan.py | 2 +- tests/components/modbus/test_light.py | 2 +- tests/components/modbus/test_switch.py | 2 +- 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 12e2273bf88..e98a61257c6 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -116,7 +116,12 @@ from .const import ( UDP, ) from .modbus import ModbusHub, async_modbus_setup -from .validators import number_validator, scan_interval_validator, struct_validator +from .validators import ( + duplicate_entity_validator, + number_validator, + scan_interval_validator, + struct_validator, +) _LOGGER = logging.getLogger(__name__) @@ -327,6 +332,7 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: vol.All( cv.ensure_list, scan_interval_validator, + duplicate_entity_validator, [ vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA), ], diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index b59557e58d2..543618e11fd 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -9,9 +9,11 @@ from typing import Any import voluptuous as vol from homeassistant.const import ( + CONF_ADDRESS, CONF_COUNT, CONF_NAME, CONF_SCAN_INTERVAL, + CONF_SLAVE, CONF_STRUCTURE, CONF_TIMEOUT, ) @@ -189,3 +191,35 @@ def scan_interval_validator(config: dict) -> dict: ) hub[CONF_TIMEOUT] = minimum_scan_interval - 1 return config + + +def duplicate_entity_validator(config: dict) -> dict: + """Control scan_interval.""" + for hub_index, hub in enumerate(config): + addresses: set[str] = set() + for component, conf_key in PLATFORMS: + if conf_key not in hub: + continue + names: set[str] = set() + errors: list[int] = [] + for index, entry in enumerate(hub[conf_key]): + name = entry[CONF_NAME] + addr = str(entry[CONF_ADDRESS]) + if CONF_SLAVE in entry: + addr += "_" + str(entry[CONF_SLAVE]) + if addr in addresses: + err = f"Modbus {component}/{name} address {addr} is duplicate, second entry not loaded!" + _LOGGER.warning(err) + errors.append(index) + elif name in names: + err = f"Modbus {component}/{name}  is duplicate, second entry not loaded!" + _LOGGER.warning(err) + errors.append(index) + else: + names.add(name) + addresses.add(addr) + + for i in reversed(errors): + del config[hub_index][conf_key][i] + + return config diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index 266193294c6..a315d8176ae 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -235,7 +235,7 @@ async def test_restore_state_cover(hass, mock_test_state, mock_modbus): { CONF_NAME: f"{TEST_ENTITY_NAME}2", CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, + CONF_ADDRESS: 1235, CONF_SCAN_INTERVAL: 0, }, ] diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index fb65f737d27..821a5cace99 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -231,7 +231,7 @@ async def test_fan_service_turn(hass, caplog, mock_pymodbus): }, { CONF_NAME: f"{TEST_ENTITY_NAME}2", - CONF_ADDRESS: 17, + CONF_ADDRESS: 18, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index f679883e908..486dfdc64f8 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -231,7 +231,7 @@ async def test_light_service_turn(hass, caplog, mock_pymodbus): }, { CONF_NAME: f"{TEST_ENTITY_NAME}2", - CONF_ADDRESS: 17, + CONF_ADDRESS: 18, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 302189001c5..fb929d26caf 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -245,7 +245,7 @@ async def test_switch_service_turn(hass, caplog, mock_pymodbus): }, { CONF_NAME: f"{TEST_ENTITY_NAME}2", - CONF_ADDRESS: 17, + CONF_ADDRESS: 18, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, From 32a2c5d5dbdeacbeeb58e62458bbb40bee07d945 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 19 Aug 2021 10:11:20 +0200 Subject: [PATCH 531/903] Add support for Swedish smart electricity meters to DSMR (#54630) * Add support for Swedish smart electricity meters to DSMR * Use Swedish protocol support from dsmr_parser * Update tests * Bump dsmr_parser to 0.30 * Remove last_reset attribute from Swedish energy sensors --- homeassistant/components/dsmr/config_flow.py | 21 ++++--- homeassistant/components/dsmr/const.py | 31 ++++++++++ homeassistant/components/dsmr/manifest.json | 2 +- homeassistant/components/dsmr/sensor.py | 9 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dsmr/conftest.py | 6 ++ tests/components/dsmr/test_config_flow.py | 24 ++++++++ tests/components/dsmr/test_sensor.py | 65 ++++++++++++++++++++ 9 files changed, 149 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 72e854fe43a..9670aab21cf 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -28,6 +28,7 @@ from .const import ( CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE, DOMAIN, + DSMR_VERSIONS, LOGGER, ) @@ -70,6 +71,10 @@ class DSMRConnection: if self._equipment_identifier in telegram: self._telegram = telegram transport.close() + # Swedish meters have no equipment identifier + if self._dsmr_version == "5S" and obis_ref.P1_MESSAGE_TIMESTAMP in telegram: + self._telegram = telegram + transport.close() if self._host is None: reader_factory = partial( @@ -119,7 +124,7 @@ async def _validate_dsmr_connection( equipment_identifier_gas = conn.equipment_identifier_gas() # Check only for equipment identifier in case no gas meter is connected - if equipment_identifier is None: + if equipment_identifier is None and data[CONF_DSMR_VERSION] != "5S": raise CannotCommunicate return { @@ -203,7 +208,7 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): { vol.Required(CONF_HOST): str, vol.Required(CONF_PORT): int, - vol.Required(CONF_DSMR_VERSION): vol.In(["2.2", "4", "5", "5B", "5L"]), + vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), } ) return self.async_show_form( @@ -247,7 +252,7 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): schema = vol.Schema( { vol.Required(CONF_PORT): vol.In(list_of_ports), - vol.Required(CONF_DSMR_VERSION): vol.In(["2.2", "4", "5", "5B", "5L"]), + vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), } ) return self.async_show_form( @@ -288,8 +293,9 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data = {**data, **info} - await self.async_set_unique_id(info[CONF_SERIAL_ID]) - self._abort_if_unique_id_configured() + if info[CONF_SERIAL_ID]: + await self.async_set_unique_id(info[CONF_SERIAL_ID]) + self._abort_if_unique_id_configured() except CannotConnect: errors["base"] = "cannot_connect" except CannotCommunicate: @@ -316,8 +322,9 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): name = f"{host}:{port}" if host is not None else port data = {**import_config, **info} - await self.async_set_unique_id(info[CONF_SERIAL_ID]) - self._abort_if_unique_id_configured(data) + if info[CONF_SERIAL_ID]: + await self.async_set_unique_id(info[CONF_SERIAL_ID]) + self._abort_if_unique_id_configured(data) return self.async_create_entry(title=name, data=data) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 6c392526ee3..ba90fa9b697 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -44,6 +44,8 @@ DATA_TASK = "task" DEVICE_NAME_ENERGY = "Energy Meter" DEVICE_NAME_GAS = "Gas Meter" +DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S"} + SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.CURRENT_ELECTRICITY_USAGE, @@ -62,11 +64,13 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_ACTIVE_TARIFF, name="Power Tariff", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, icon="mdi:flash", ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_USED_TARIFF_1, name="Energy Consumption (tarif 1)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=DEVICE_CLASS_ENERGY, force_update=True, state_class=STATE_CLASS_TOTAL_INCREASING, @@ -74,6 +78,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_USED_TARIFF_2, name="Energy Consumption (tarif 2)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, @@ -81,6 +86,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, name="Energy Production (tarif 1)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, @@ -88,6 +94,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, name="Energy Production (tarif 2)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, @@ -137,45 +144,53 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.SHORT_POWER_FAILURE_COUNT, name="Short Power Failure Count", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:flash-off", ), DSMRSensorEntityDescription( key=obis_references.LONG_POWER_FAILURE_COUNT, name="Long Power Failure Count", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:flash-off", ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SAG_L1_COUNT, name="Voltage Sags Phase L1", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SAG_L2_COUNT, name="Voltage Sags Phase L2", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SAG_L3_COUNT, name="Voltage Sags Phase L3", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SWELL_L1_COUNT, name="Voltage Swells Phase L1", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SWELL_L2_COUNT, name="Voltage Swells Phase L2", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SWELL_L3_COUNT, name="Voltage Swells Phase L3", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", ), @@ -237,6 +252,22 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + DSMRSensorEntityDescription( + key=obis_references.SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL, + name="Energy Consumption (total)", + dsmr_versions={"5S"}, + force_update=True, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, + name="Energy Production (total)", + dsmr_versions={"5S"}, + force_update=True, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_IMPORTED_TOTAL, name="Energy Consumption (total)", diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index df738724ac0..fbbfac55959 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dsmr", "name": "DSMR Slimme Meter", "documentation": "https://www.home-assistant.io/integrations/dsmr", - "requirements": ["dsmr_parser==0.29"], + "requirements": ["dsmr_parser==0.30"], "codeowners": ["@Robbie1221", "@frenck"], "config_flow": true, "iot_class": "local_push" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index dbc29144719..1b38b2695ec 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -44,6 +44,7 @@ from .const import ( DEVICE_NAME_ENERGY, DEVICE_NAME_GAS, DOMAIN, + DSMR_VERSIONS, LOGGER, SENSORS, ) @@ -54,7 +55,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( - cv.string, vol.In(["5L", "5B", "5", "4", "2.2"]) + cv.string, vol.In(DSMR_VERSIONS) ), vol.Optional(CONF_RECONNECT_INTERVAL, default=DEFAULT_RECONNECT_INTERVAL): int, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), @@ -118,7 +119,7 @@ async def async_setup_entry( create_tcp_dsmr_reader, entry.data[CONF_HOST], entry.data[CONF_PORT], - entry.data[CONF_DSMR_VERSION], + dsmr_version, update_entities_telegram, loop=hass.loop, keep_alive_interval=60, @@ -127,7 +128,7 @@ async def async_setup_entry( reader_factory = partial( create_dsmr_reader, entry.data[CONF_PORT], - entry.data[CONF_DSMR_VERSION], + dsmr_version, update_entities_telegram, loop=hass.loop, ) @@ -217,6 +218,8 @@ class DSMREntity(SensorEntity): if entity_description.is_gas: device_serial = entry.data[CONF_SERIAL_ID_GAS] device_name = DEVICE_NAME_GAS + if device_serial is None: + device_serial = entry.entry_id self._attr_device_info = { "identifiers": {(DOMAIN, device_serial)}, diff --git a/requirements_all.txt b/requirements_all.txt index 96b6d6757b7..d5adf797311 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -532,7 +532,7 @@ doorbirdpy==2.1.0 dovado==0.4.1 # homeassistant.components.dsmr -dsmr_parser==0.29 +dsmr_parser==0.30 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7ac2def786..ef1e3ca0fc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -307,7 +307,7 @@ directv==0.4.0 doorbirdpy==2.1.0 # homeassistant.components.dsmr -dsmr_parser==0.29 +dsmr_parser==0.30 # homeassistant.components.dynalite dynalite_devices==0.1.46 diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index ab7b3a4d479..9ef6bccfab5 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -7,6 +7,7 @@ from dsmr_parser.obis_references import ( EQUIPMENT_IDENTIFIER, EQUIPMENT_IDENTIFIER_GAS, LUXEMBOURG_EQUIPMENT_IDENTIFIER, + P1_MESSAGE_TIMESTAMP, ) from dsmr_parser.objects import CosemObject import pytest @@ -44,6 +45,7 @@ async def dsmr_connection_send_validate_fixture(hass): protocol.telegram = { EQUIPMENT_IDENTIFIER: CosemObject([{"value": "12345678", "unit": ""}]), EQUIPMENT_IDENTIFIER_GAS: CosemObject([{"value": "123456789", "unit": ""}]), + P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), } async def connection_factory(*args, **kwargs): @@ -57,6 +59,10 @@ async def dsmr_connection_send_validate_fixture(hass): [{"value": "123456789", "unit": ""}] ), } + if args[1] == "5S": + protocol.telegram = { + P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), + } return (transport, protocol) diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 006893a81e8..d56cd3f2eb8 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -13,6 +13,7 @@ from homeassistant.components.dsmr import DOMAIN, config_flow from tests.common import MockConfigEntry SERIAL_DATA = {"serial_id": "12345678", "serial_id_gas": "123456789"} +SERIAL_DATA_SWEDEN = {"serial_id": None, "serial_id_gas": None} def com_port(): @@ -482,6 +483,29 @@ async def test_import_luxembourg(hass, dsmr_connection_send_validate_fixture): assert result["data"] == {**entry_data, **SERIAL_DATA} +async def test_import_sweden(hass, dsmr_connection_send_validate_fixture): + """Test we can import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5S", + "precision": 4, + "reconnect_interval": 30, + } + + with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=entry_data, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "/dev/ttyUSB0" + assert result["data"] == {**entry_data, **SERIAL_DATA_SWEDEN} + + def test_get_serial_by_id_no_dir(): """Test serial by id conversion if there's no /dev/serial/by-id.""" p1 = patch("os.path.isdir", MagicMock(return_value=False)) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 6d40437d87a..6accf7c40da 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -536,6 +536,71 @@ async def test_belgian_meter_low(hass, dsmr_connection_fixture): assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" +async def test_swedish_meter(hass, dsmr_connection_fixture): + """Test if v5 meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, + SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL, + ) + from dsmr_parser.objects import CosemObject + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5S", + "precision": 4, + "reconnect_interval": 30, + "serial_id": None, + "serial_id_gas": None, + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL: CosemObject( + [{"value": Decimal(123.456), "unit": ENERGY_KILO_WATT_HOUR}] + ), + SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL: CosemObject( + [{"value": Decimal(654.321), "unit": ENERGY_KILO_WATT_HOUR}] + ), + } + + 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 update + await asyncio.sleep(0) + + power_tariff = hass.states.get("sensor.energy_consumption_total") + assert power_tariff.state == "123.456" + assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert power_tariff.attributes.get(ATTR_ICON) is None + assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert ( + power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + ) + + power_tariff = hass.states.get("sensor.energy_production_total") + assert power_tariff.state == "654.321" + assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert ( + power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + ) + + async def test_tcp(hass, dsmr_connection_fixture): """If proper config provided TCP connection should be made.""" (connection_factory, transport, protocol) = dsmr_connection_fixture From 4903c1fbfd6b7ebcaa6b6a5c202a35939bac0de6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 19 Aug 2021 12:53:47 +0200 Subject: [PATCH 532/903] Minor cleanup of SensorEntity (#54624) --- homeassistant/components/sensor/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 94fb08c66b1..7551c971582 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -141,6 +141,7 @@ class SensorEntity(Entity): _attr_native_unit_of_measurement: str | None _attr_native_value: StateType = None _attr_state_class: str | None + _attr_state: None = None # Subclasses of SensorEntity should not set this _last_reset_reported = False _temperature_conversion_reported = False @@ -176,8 +177,7 @@ class SensorEntity(Entity): """Return state attributes.""" if last_reset := self.last_reset: if ( - last_reset is not None - and self.state_class == STATE_CLASS_MEASUREMENT + self.state_class == STATE_CLASS_MEASUREMENT and not self._last_reset_reported ): self._last_reset_reported = True @@ -211,6 +211,7 @@ class SensorEntity(Entity): return self.entity_description.native_unit_of_measurement return None + @final @property def unit_of_measurement(self) -> str | None: """Return the unit of measurement of the entity, after unit conversion.""" @@ -232,13 +233,10 @@ class SensorEntity(Entity): return native_unit_of_measurement + @final @property def state(self) -> Any: """Return the state of the sensor and perform unit conversions, if needed.""" - # Test if _attr_state has been set in this instance - if "_attr_state" in self.__dict__: - return self._attr_state - unit_of_measurement = self.native_unit_of_measurement value = self.native_value From 8103d9ae3c12664cdec27d742481cca28eee0796 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 19 Aug 2021 08:46:14 -0500 Subject: [PATCH 533/903] Add missing id to yeelights that were setup manually (#54855) --- homeassistant/components/yeelight/__init__.py | 50 +++++++++++-------- .../components/yeelight/config_flow.py | 6 ++- tests/components/yeelight/test_config_flow.py | 4 +- tests/components/yeelight/test_init.py | 22 ++++++++ 4 files changed, 58 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 0ea4eb8e84f..d315c3b5860 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -221,35 +221,43 @@ async def _async_initialize( @callback -def _async_populate_entry_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +def _async_normalize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Move options from data for imported entries. Initialize options with default values for other entries. - """ - if entry.options: - return - hass.config_entries.async_update_entry( - entry, - data={CONF_HOST: entry.data.get(CONF_HOST), CONF_ID: entry.data.get(CONF_ID)}, - options={ - CONF_NAME: entry.data.get(CONF_NAME, ""), - CONF_MODEL: entry.data.get(CONF_MODEL, ""), - CONF_TRANSITION: entry.data.get(CONF_TRANSITION, DEFAULT_TRANSITION), - CONF_MODE_MUSIC: entry.data.get(CONF_MODE_MUSIC, DEFAULT_MODE_MUSIC), - CONF_SAVE_ON_CHANGE: entry.data.get( - CONF_SAVE_ON_CHANGE, DEFAULT_SAVE_ON_CHANGE - ), - CONF_NIGHTLIGHT_SWITCH: entry.data.get( - CONF_NIGHTLIGHT_SWITCH, DEFAULT_NIGHTLIGHT_SWITCH - ), - }, - ) + Copy the unique id to CONF_ID if it is missing + """ + if not entry.options: + hass.config_entries.async_update_entry( + entry, + data={ + CONF_HOST: entry.data.get(CONF_HOST), + CONF_ID: entry.data.get(CONF_ID, entry.unique_id), + }, + options={ + CONF_NAME: entry.data.get(CONF_NAME, ""), + CONF_MODEL: entry.data.get(CONF_MODEL, ""), + CONF_TRANSITION: entry.data.get(CONF_TRANSITION, DEFAULT_TRANSITION), + CONF_MODE_MUSIC: entry.data.get(CONF_MODE_MUSIC, DEFAULT_MODE_MUSIC), + CONF_SAVE_ON_CHANGE: entry.data.get( + CONF_SAVE_ON_CHANGE, DEFAULT_SAVE_ON_CHANGE + ), + CONF_NIGHTLIGHT_SWITCH: entry.data.get( + CONF_NIGHTLIGHT_SWITCH, DEFAULT_NIGHTLIGHT_SWITCH + ), + }, + ) + elif entry.unique_id and not entry.data.get(CONF_ID): + hass.config_entries.async_update_entry( + entry, + data={CONF_HOST: entry.data.get(CONF_HOST), CONF_ID: entry.unique_id}, + ) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Yeelight from a config entry.""" - _async_populate_entry_options(hass, entry) + _async_normalize_config_entry(hass, entry) if entry.data.get(CONF_HOST): try: diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index d93f59535cf..651d41ff268 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -118,7 +118,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: self._abort_if_unique_id_configured() return self.async_create_entry( - title=f"{model} {self.unique_id}", data=user_input + title=f"{model} {self.unique_id}", + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_ID: self.unique_id, + }, ) user_input = user_input or {} diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 5bbfcc9283b..17902a08bfa 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -215,7 +215,7 @@ async def test_manual(hass: HomeAssistant): await hass.async_block_till_done() assert result4["type"] == "create_entry" assert result4["title"] == "color 0x000000000015243f" - assert result4["data"] == {CONF_HOST: IP_ADDRESS} + assert result4["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} # Duplicate result = await hass.config_entries.flow.async_init( @@ -298,7 +298,7 @@ async def test_manual_no_capabilities(hass: HomeAssistant): result["flow_id"], {CONF_HOST: IP_ADDRESS} ) assert result["type"] == "create_entry" - assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: None} async def test_discovered_by_homekit_and_dhcp(hass): diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index d7f4a05b436..68571fcce27 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -155,6 +155,9 @@ async def test_setup_import(hass: HomeAssistant): assert hass.states.get(f"binary_sensor.{name}_nightlight") is not None assert hass.states.get(f"light.{name}") is not None assert hass.states.get(f"light.{name}_nightlight") is not None + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.unique_id == "0x000000000015243f" + assert entry.data[CONF_ID] == "0x000000000015243f" async def test_unique_ids_device(hass: HomeAssistant): @@ -276,3 +279,22 @@ async def test_async_listen_error_has_host_without_id(hass: HomeAssistant): await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_async_setup_with_missing_id(hass: HomeAssistant): + """Test that setting adds the missing CONF_ID from unique_id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ID, + data={CONF_HOST: "127.0.0.1"}, + options={CONF_NAME: "Test name"}, + ) + config_entry.add_to_hass(hass) + + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb(cannot_connect=True) + ): + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data[CONF_ID] == ID From 4ae2a26aa3deb96272295684c14982ea90f5cc25 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 19 Aug 2021 09:22:30 -0700 Subject: [PATCH 534/903] Add config flow to Rainforest EAGLE-200 (#54846) Co-authored-by: Martin Hjelmare --- .coveragerc | 2 + .../components/rainforest_eagle/__init__.py | 29 ++- .../rainforest_eagle/config_flow.py | 69 ++++++ .../components/rainforest_eagle/const.py | 9 + .../components/rainforest_eagle/data.py | 173 ++++++++++++++ .../components/rainforest_eagle/manifest.json | 5 +- .../components/rainforest_eagle/sensor.py | 215 +++++++----------- .../components/rainforest_eagle/strings.json | 20 ++ .../rainforest_eagle/translations/en.json | 20 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/dhcp.py | 4 + homeassistant/helpers/update_coordinator.py | 3 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 + .../config_flow/tests/test_config_flow.py | 2 +- tests/components/rainforest_eagle/__init__.py | 1 + .../rainforest_eagle/test_config_flow.py | 129 +++++++++++ 17 files changed, 552 insertions(+), 142 deletions(-) create mode 100644 homeassistant/components/rainforest_eagle/config_flow.py create mode 100644 homeassistant/components/rainforest_eagle/const.py create mode 100644 homeassistant/components/rainforest_eagle/data.py create mode 100644 homeassistant/components/rainforest_eagle/strings.json create mode 100644 homeassistant/components/rainforest_eagle/translations/en.json create mode 100644 tests/components/rainforest_eagle/__init__.py create mode 100644 tests/components/rainforest_eagle/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index fd4f87da858..9410428a299 100644 --- a/.coveragerc +++ b/.coveragerc @@ -838,6 +838,8 @@ omit = homeassistant/components/rainmachine/binary_sensor.py homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/switch.py + homeassistant/components/rainforest_eagle/__init__.py + homeassistant/components/rainforest_eagle/data.py homeassistant/components/rainforest_eagle/sensor.py homeassistant/components/raspihats/* homeassistant/components/raspyrfm/* diff --git a/homeassistant/components/rainforest_eagle/__init__.py b/homeassistant/components/rainforest_eagle/__init__.py index 9de4d85797f..44a5624267e 100644 --- a/homeassistant/components/rainforest_eagle/__init__.py +++ b/homeassistant/components/rainforest_eagle/__init__.py @@ -1 +1,28 @@ -"""The rainforest_eagle component.""" +"""The Rainforest Eagle integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import data +from .const import DOMAIN + +PLATFORMS = ("sensor",) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Rainforest Eagle from a config entry.""" + coordinator = data.EagleDataCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/rainforest_eagle/config_flow.py b/homeassistant/components/rainforest_eagle/config_flow.py new file mode 100644 index 00000000000..acab5fc2070 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/config_flow.py @@ -0,0 +1,69 @@ +"""Config flow for Rainforest Eagle integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_TYPE +from homeassistant.data_entry_flow import FlowResult + +from . import data +from .const import CONF_CLOUD_ID, CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CLOUD_ID): str, + vol.Required(CONF_INSTALL_CODE): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rainforest Eagle.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + await self.async_set_unique_id(user_input[CONF_CLOUD_ID]) + errors = {} + + try: + eagle_type, hardware_address = await data.async_get_type( + self.hass, user_input[CONF_CLOUD_ID], user_input[CONF_INSTALL_CODE] + ) + except data.CannotConnect: + errors["base"] = "cannot_connect" + except data.InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + user_input[CONF_TYPE] = eagle_type + user_input[CONF_HARDWARE_ADDRESS] = hardware_address + return self.async_create_entry( + title=user_input[CONF_CLOUD_ID], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle the import step.""" + await self.async_set_unique_id(user_input[CONF_CLOUD_ID]) + self._abort_if_unique_id_configured() + return await self.async_step_user(user_input) diff --git a/homeassistant/components/rainforest_eagle/const.py b/homeassistant/components/rainforest_eagle/const.py new file mode 100644 index 00000000000..bbbe049a85a --- /dev/null +++ b/homeassistant/components/rainforest_eagle/const.py @@ -0,0 +1,9 @@ +"""Constants for the Rainforest Eagle integration.""" + +DOMAIN = "rainforest_eagle" +CONF_CLOUD_ID = "cloud_id" +CONF_INSTALL_CODE = "install_code" +CONF_HARDWARE_ADDRESS = "hardware_address" + +TYPE_EAGLE_100 = "eagle-100" +TYPE_EAGLE_200 = "eagle-200" diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py new file mode 100644 index 00000000000..b252993d888 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/data.py @@ -0,0 +1,173 @@ +"""Rainforest data.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import aioeagle +import aiohttp +import async_timeout +from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout +from uEagle import Eagle as Eagle100Reader + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + TYPE_EAGLE_100, + TYPE_EAGLE_200, +) + +_LOGGER = logging.getLogger(__name__) + +UPDATE_100_ERRORS = (ConnectError, HTTPError, Timeout, ValueError) + + +class RainforestError(HomeAssistantError): + """Base error.""" + + +class CannotConnect(RainforestError): + """Error to indicate a request failed.""" + + +class InvalidAuth(RainforestError): + """Error to indicate bad auth.""" + + +async def async_get_type(hass, cloud_id, install_code): + """Try API call 'get_network_info' to see if target device is Eagle-100 or Eagle-200.""" + reader = Eagle100Reader(cloud_id, install_code) + + try: + response = await hass.async_add_executor_job(reader.get_network_info) + except UPDATE_100_ERRORS as error: + _LOGGER.error("Failed to connect during setup: %s", error) + raise CannotConnect from error + + # Branch to test if target is Legacy Model + if ( + "NetworkInfo" in response + and response["NetworkInfo"].get("ModelId") == "Z109-EAGLE" + ): + return TYPE_EAGLE_100, None + + # Branch to test if target is not an Eagle-200 Model + if ( + "Response" not in response + or response["Response"].get("Command") != "get_network_info" + ): + # We don't support this + return None, None + + # For EAGLE-200, fetch the hardware address of the meter too. + hub = aioeagle.EagleHub( + aiohttp_client.async_get_clientsession(hass), cloud_id, install_code + ) + + try: + meters = await hub.get_device_list() + except aioeagle.BadAuth as err: + raise InvalidAuth from err + except aiohttp.ClientError as err: + raise CannotConnect from err + + if meters: + hardware_address = meters[0].hardware_address + else: + hardware_address = None + + return TYPE_EAGLE_200, hardware_address + + +class EagleDataCoordinator(DataUpdateCoordinator): + """Get the latest data from the Eagle device.""" + + eagle100_reader: Eagle100Reader | None = None + eagle200_meter: aioeagle.ElectricMeter | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + self.entry = entry + if self.type == TYPE_EAGLE_100: + self.model = "EAGLE-100" + update_method = self._async_update_data_100 + else: + self.model = "EAGLE-200" + update_method = self._async_update_data_200 + + super().__init__( + hass, + _LOGGER, + name=entry.data[CONF_CLOUD_ID], + update_interval=timedelta(seconds=30), + update_method=update_method, + ) + + @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] + + @property + def hardware_address(self): + """Return hardware address of meter.""" + return self.entry.data[CONF_HARDWARE_ADDRESS] + + async def _async_update_data_200(self): + """Get the latest data from the Eagle-200 device.""" + if self.eagle200_meter is None: + hub = aioeagle.EagleHub( + aiohttp_client.async_get_clientsession(self.hass), + self.cloud_id, + self.entry.data[CONF_INSTALL_CODE], + ) + self.eagle200_meter = aioeagle.ElectricMeter.create_instance( + hub, self.hardware_address + ) + + async with async_timeout.timeout(30): + data = await self.eagle200_meter.get_device_query() + + _LOGGER.debug("API data: %s", data) + return {var["Name"]: var["Value"] for var in data.values()} + + async def _async_update_data_100(self): + """Get the latest data from the Eagle-100 device.""" + try: + data = await self.hass.async_add_executor_job(self._fetch_data) + except UPDATE_100_ERRORS as error: + raise UpdateFailed from error + + _LOGGER.debug("API data: %s", data) + return data + + def _fetch_data(self): + """Fetch and return the four sensor values in a dict.""" + if self.eagle100_reader is None: + self.eagle100_reader = Eagle100Reader( + self.cloud_id, self.entry.data[CONF_INSTALL_CODE] + ) + + out = {} + + resp = self.eagle100_reader.get_instantaneous_demand()["InstantaneousDemand"] + out["zigbee:InstantaneousDemand"] = resp["Demand"] + + resp = self.eagle100_reader.get_current_summation()["CurrentSummation"] + out["zigbee:CurrentSummationDelivered"] = resp["SummationDelivered"] + out["zigbee:CurrentSummationReceived"] = resp["SummationReceived"] + + return out diff --git a/homeassistant/components/rainforest_eagle/manifest.json b/homeassistant/components/rainforest_eagle/manifest.json index 4b6268fd59a..10a7dc35ddc 100644 --- a/homeassistant/components/rainforest_eagle/manifest.json +++ b/homeassistant/components/rainforest_eagle/manifest.json @@ -1,10 +1,11 @@ { "domain": "rainforest_eagle", - "name": "Rainforest Eagle-200", + "name": "Rainforest Eagle", "documentation": "https://www.home-assistant.io/integrations/rainforest_eagle", - "requirements": ["eagle200_reader==0.2.4", "uEagle==0.0.2"], + "requirements": ["aioeagle==1.1.0", "uEagle==0.0.2"], "codeowners": ["@gtdiehl", "@jcalbert"], "iot_class": "local_polling", + "config_flow": true, "dhcp": [ { "macaddress": "D8D5B9*" diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 6e42d2a13a2..6946ee03974 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -1,74 +1,60 @@ -"""Support for the Rainforest Eagle-200 energy monitor.""" +"""Support for the Rainforest Eagle energy monitor.""" from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta import logging +from typing import Any -from eagle200_reader import EagleReader -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout -from uEagle import Eagle as LegacyReader import voluptuous as vol from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, PLATFORM_SCHEMA, - STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, + StateType, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_IP_ADDRESS, DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, + POWER_KILO_WATT, ) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -CONF_CLOUD_ID = "cloud_id" -CONF_INSTALL_CODE = "install_code" -POWER_KILO_WATT = "kW" +from .const import CONF_CLOUD_ID, CONF_INSTALL_CODE, DOMAIN +from .data import EagleDataCoordinator _LOGGER = logging.getLogger(__name__) -MIN_SCAN_INTERVAL = timedelta(seconds=30) - - -@dataclass -class SensorType: - """Rainforest sensor type.""" - - name: str - unit_of_measurement: str - device_class: str | None = None - state_class: str | None = None - - -SENSORS = { - "instantanous_demand": SensorType( - name="Eagle-200 Meter Power Demand", - unit_of_measurement=POWER_KILO_WATT, +SENSORS = ( + SensorEntityDescription( + key="zigbee:InstantaneousDemand", + name="Meter Power Demand", + native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, ), - "summation_delivered": SensorType( - name="Eagle-200 Total Meter Energy Delivered", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + SensorEntityDescription( + key="zigbee:CurrentSummationDelivered", + name="Total Meter Energy Delivered", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), - "summation_received": SensorType( - name="Eagle-200 Total Meter Energy Received", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + SensorEntityDescription( + key="zigbee:CurrentSummationReceived", + name="Total Meter Energy Received", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), - "summation_total": SensorType( - name="Eagle-200 Net Meter Energy (Delivered minus Received)", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - ), -} +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -79,104 +65,65 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def hwtest(cloud_id, install_code, ip_address): - """Try API call 'get_network_info' to see if target device is Legacy or Eagle-200.""" - reader = LeagleReader(cloud_id, install_code, ip_address) - response = reader.get_network_info() - - # Branch to test if target is Legacy Model - if ( - "NetworkInfo" in response - and response["NetworkInfo"].get("ModelId", None) == "Z109-EAGLE" - ): - return reader - - # Branch to test if target is Eagle-200 Model - if ( - "Response" in response - and response["Response"].get("Command", None) == "get_network_info" - ): - return EagleReader(ip_address, cloud_id, install_code) - - # Catch-all if hardware ID tests fail - raise ValueError("Couldn't determine device model.") +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +): + """Import config as config entry.""" + _LOGGER.warning( + "Configuration of the rainforest_eagle platform in YAML is deprecated " + "and will be removed in Home Assistant 2021.11; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_CLOUD_ID: config[CONF_CLOUD_ID], + CONF_INSTALL_CODE: config[CONF_INSTALL_CODE], + }, + ) + ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Create the Eagle-200 sensor.""" - ip_address = config[CONF_IP_ADDRESS] - cloud_id = config[CONF_CLOUD_ID] - install_code = config[CONF_INSTALL_CODE] - - try: - eagle_reader = hwtest(cloud_id, install_code, ip_address) - except (ConnectError, HTTPError, Timeout, ValueError) as error: - _LOGGER.error("Failed to connect during setup: %s", error) - return - - eagle_data = EagleData(eagle_reader) - eagle_data.update() - - add_entities(EagleSensor(eagle_data, condition) for condition in SENSORS) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities(EagleSensor(coordinator, description) for description in SENSORS) -class EagleSensor(SensorEntity): - """Implementation of the Rainforest Eagle-200 sensor.""" +class EagleSensor(CoordinatorEntity, SensorEntity): + """Implementation of the Rainforest Eagle sensor.""" - def __init__(self, eagle_data, sensor_type): + coordinator: EagleDataCoordinator + + def __init__(self, coordinator, entity_description): """Initialize the sensor.""" - self.eagle_data = eagle_data - self._type = sensor_type - sensor_info = SENSORS[sensor_type] - self._attr_name = sensor_info.name - self._attr_native_unit_of_measurement = sensor_info.unit_of_measurement - self._attr_device_class = sensor_info.device_class - self._attr_state_class = sensor_info.state_class + super().__init__(coordinator) + self.entity_description = entity_description - def update(self): - """Get the energy information from the Rainforest Eagle.""" - self.eagle_data.update() - self._attr_native_value = self.eagle_data.get_state(self._type) + @property + def unique_id(self) -> str | None: + """Return unique ID of entity.""" + return f"{self.coordinator.cloud_id}-{self.entity_description.key}" + @property + def native_value(self) -> StateType: + """Return native value of the sensor.""" + return self.coordinator.data.get(self.entity_description.key) -class EagleData: - """Get the latest data from the Eagle-200 device.""" - - def __init__(self, eagle_reader): - """Initialize the data object.""" - self._eagle_reader = eagle_reader - self.data = {} - - @Throttle(MIN_SCAN_INTERVAL) - def update(self): - """Get the latest data from the Eagle-200 device.""" - try: - self.data = self._eagle_reader.update() - _LOGGER.debug("API data: %s", self.data) - except (ConnectError, HTTPError, Timeout, ValueError) as error: - _LOGGER.error("Unable to connect during update: %s", error) - self.data = {} - - def get_state(self, sensor_type): - """Get the sensor value from the dictionary.""" - state = self.data.get(sensor_type) - _LOGGER.debug("Updating: %s - %s", sensor_type, state) - return state - - -class LeagleReader(LegacyReader, SensorEntity): - """Wraps uEagle to make it behave like eagle_reader, offering update().""" - - def update(self): - """Fetch and return the four sensor values in a dict.""" - out = {} - - resp = self.get_instantaneous_demand()["InstantaneousDemand"] - out["instantanous_demand"] = resp["Demand"] - - resp = self.get_current_summation()["CurrentSummation"] - out["summation_delivered"] = resp["SummationDelivered"] - out["summation_received"] = resp["SummationReceived"] - out["summation_total"] = out["summation_delivered"] - out["summation_received"] - - return out + @property + def device_info(self) -> DeviceInfo | None: + """Return device info.""" + return { + "name": self.coordinator.model, + "identifiers": {(DOMAIN, self.coordinator.cloud_id)}, + "manufacturer": "Rainforest Automation", + "model": self.coordinator.model, + } diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json new file mode 100644 index 00000000000..d8e587c98ca --- /dev/null +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "cloud_id": "Cloud ID", + "install_code": "Installation Code" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/rainforest_eagle/translations/en.json b/homeassistant/components/rainforest_eagle/translations/en.json new file mode 100644 index 00000000000..4307fc43a34 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "cloud_id": "Cloud ID", + "install_code": "Installation Code" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6be4f70b38e..1ebf71b369f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -215,6 +215,7 @@ FLOWS = [ "ps4", "pvpc_hourly_pricing", "rachio", + "rainforest_eagle", "rainmachine", "recollect_waste", "renault", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index d6b4fc4e457..cf442504121 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -161,6 +161,10 @@ DHCP = [ "hostname": "rachio-*", "macaddress": "74C63B*" }, + { + "domain": "rainforest_eagle", + "macaddress": "D8D5B9*" + }, { "domain": "ring", "hostname": "ring*", diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index e83a2d0edc3..69111b00885 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -254,9 +254,10 @@ class DataUpdateCoordinator(Generic[T]): finally: self.logger.debug( - "Finished fetching %s data in %.3f seconds", + "Finished fetching %s data in %.3f seconds (success: %s)", self.name, monotonic() - start, + self.last_update_success, ) if not auth_failed and self._listeners and not self.hass.is_stopping: self._schedule_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index d5adf797311..676539b7b01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -160,6 +160,9 @@ aiodns==3.0.0 # homeassistant.components.eafm aioeafm==0.1.2 +# homeassistant.components.rainforest_eagle +aioeagle==1.1.0 + # homeassistant.components.emonitor aioemonitor==1.0.5 @@ -543,9 +546,6 @@ dweepy==0.3.0 # homeassistant.components.dynalite dynalite_devices==0.1.46 -# homeassistant.components.rainforest_eagle -eagle200_reader==0.2.4 - # homeassistant.components.ebusd ebusdpy==0.0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef1e3ca0fc8..8665228d24f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -99,6 +99,9 @@ aiodns==3.0.0 # homeassistant.components.eafm aioeafm==0.1.2 +# homeassistant.components.rainforest_eagle +aioeagle==1.1.0 + # homeassistant.components.emonitor aioemonitor==1.0.5 @@ -1278,6 +1281,9 @@ twilio==6.32.0 # homeassistant.components.twinkly twinkly-client==0.0.2 +# homeassistant.components.rainforest_eagle +uEagle==0.0.2 + # homeassistant.components.upb upb_lib==0.4.12 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 e72d9eb7679..c6a6ec6b629 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) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {} + assert result["errors"] is None with patch( "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", diff --git a/tests/components/rainforest_eagle/__init__.py b/tests/components/rainforest_eagle/__init__.py new file mode 100644 index 00000000000..df4f1749d49 --- /dev/null +++ b/tests/components/rainforest_eagle/__init__.py @@ -0,0 +1 @@ +"""Tests for the Rainforest Eagle integration.""" diff --git a/tests/components/rainforest_eagle/test_config_flow.py b/tests/components/rainforest_eagle/test_config_flow.py new file mode 100644 index 00000000000..626069ed6c1 --- /dev/null +++ b/tests/components/rainforest_eagle/test_config_flow.py @@ -0,0 +1,129 @@ +"""Test the Rainforest Eagle config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, setup +from homeassistant.components.rainforest_eagle.const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_200, +) +from homeassistant.components.rainforest_eagle.data import CannotConnect, InvalidAuth +from homeassistant.const import CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.rainforest_eagle.data.async_get_type", + return_value=(TYPE_EAGLE_200, "mock-hw"), + ), patch( + "homeassistant.components.rainforest_eagle.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "abcdef" + assert result2["data"] == { + CONF_TYPE: TYPE_EAGLE_200, + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HARDWARE_ADDRESS: "mock-hw", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +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": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.rainforest_eagle.data.Eagle100Reader.get_network_info", + side_effect=InvalidAuth, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + ) + + assert result2["type"] == RESULT_TYPE_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.rainforest_eagle.data.Eagle100Reader.get_network_info", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_import(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.rainforest_eagle.data.async_get_type", + return_value=(TYPE_EAGLE_200, "mock-hw"), + ), patch( + "homeassistant.components.rainforest_eagle.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + data={CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + context={"source": config_entries.SOURCE_IMPORT}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "abcdef" + assert result["data"] == { + CONF_TYPE: TYPE_EAGLE_200, + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HARDWARE_ADDRESS: "mock-hw", + } + assert len(mock_setup_entry.mock_calls) == 1 + + # Second time we should get already_configured + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + data={CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + context={"source": config_entries.SOURCE_IMPORT}, + ) + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" From 6eadc0c3033473381cb70babf9f7c813e183d2df Mon Sep 17 00:00:00 2001 From: micha91 Date: Thu, 19 Aug 2021 20:42:11 +0200 Subject: [PATCH 535/903] Yamaha Musiccast Media Browser feature (#54864) --- .../components/yamaha_musiccast/__init__.py | 37 ++++- .../yamaha_musiccast/config_flow.py | 18 ++- .../components/yamaha_musiccast/const.py | 11 ++ .../components/yamaha_musiccast/manifest.json | 5 +- .../yamaha_musiccast/media_player.py | 126 ++++++++++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../yamaha_musiccast/test_config_flow.py | 59 +++++++- 8 files changed, 241 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 3a8275e98f0..0de8428b0dc 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -7,6 +7,7 @@ import logging from aiomusiccast import MusicCastConnectionException from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice +from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -19,7 +20,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import BRAND, DOMAIN +from .const import BRAND, CONF_SERIAL, CONF_UPNP_DESC, DOMAIN PLATFORMS = ["media_player"] @@ -27,10 +28,42 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) +async def get_upnp_desc(hass: HomeAssistant, host: str): + """Get the upnp description URL for a given host, using the SSPD scanner.""" + ssdp_entries = ssdp.async_get_discovery_info_by_st(hass, "upnp:rootdevice") + matches = [w for w in ssdp_entries if w.get("_host", "") == host] + upnp_desc = None + for match in matches: + if match.get(ssdp.ATTR_SSDP_LOCATION): + upnp_desc = match[ssdp.ATTR_SSDP_LOCATION] + break + + if not upnp_desc: + _LOGGER.warning( + "The upnp_description was not found automatically, setting a default one" + ) + upnp_desc = f"http://{host}:49154/MediaRenderer/desc.xml" + return upnp_desc + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up MusicCast from a config entry.""" - client = MusicCastDevice(entry.data[CONF_HOST], async_get_clientsession(hass)) + if entry.data.get(CONF_UPNP_DESC) is None: + hass.config_entries.async_update_entry( + entry, + data={ + CONF_HOST: entry.data[CONF_HOST], + CONF_SERIAL: entry.data["serial"], + CONF_UPNP_DESC: await get_upnp_desc(hass, entry.data[CONF_HOST]), + }, + ) + + client = MusicCastDevice( + entry.data[CONF_HOST], + async_get_clientsession(hass), + entry.data[CONF_UPNP_DESC], + ) coordinator = MusicCastDataUpdateCoordinator(hass, client=client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index 9645be3ddc8..f4ad455fb04 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -15,7 +15,8 @@ from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from . import get_upnp_desc +from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -27,6 +28,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): serial_number: str | None = None host: str + upnp_description: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -64,7 +66,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): title=host, data={ CONF_HOST: host, - "serial": serial_number, + CONF_SERIAL: serial_number, + CONF_UPNP_DESC: await get_upnp_desc(self.hass, host), }, ) @@ -89,8 +92,14 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): self.serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL] self.host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname + self.upnp_description = discovery_info[ssdp.ATTR_SSDP_LOCATION] await self.async_set_unique_id(self.serial_number) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) + self._abort_if_unique_id_configured( + { + CONF_HOST: self.host, + CONF_UPNP_DESC: self.upnp_description, + } + ) self.context.update( { "title_placeholders": { @@ -108,7 +117,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): title=self.host, data={ CONF_HOST: self.host, - "serial": self.serial_number, + CONF_SERIAL: self.serial_number, + CONF_UPNP_DESC: self.upnp_description, }, ) diff --git a/homeassistant/components/yamaha_musiccast/const.py b/homeassistant/components/yamaha_musiccast/const.py index d7daaab4117..55ce3920fa1 100644 --- a/homeassistant/components/yamaha_musiccast/const.py +++ b/homeassistant/components/yamaha_musiccast/const.py @@ -1,6 +1,8 @@ """Constants for the MusicCast integration.""" from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_TRACK, REPEAT_MODE_ALL, REPEAT_MODE_OFF, REPEAT_MODE_ONE, @@ -17,6 +19,9 @@ ATTR_MC_LINK = "mc_link" ATTR_MAIN_SYNC = "main_sync" ATTR_MC_LINK_SOURCES = [ATTR_MC_LINK, ATTR_MAIN_SYNC] +CONF_UPNP_DESC = "upnp_description" +CONF_SERIAL = "serial" + DEFAULT_ZONE = "main" HA_REPEAT_MODE_TO_MC_MAPPING = { REPEAT_MODE_OFF: "off", @@ -31,3 +36,9 @@ INTERVAL_SECONDS = "interval_seconds" MC_REPEAT_MODE_TO_HA_MAPPING = { val: key for key, val in HA_REPEAT_MODE_TO_MC_MAPPING.items() } + +MEDIA_CLASS_MAPPING = { + "track": MEDIA_CLASS_TRACK, + "directory": MEDIA_CLASS_DIRECTORY, + "categories": MEDIA_CLASS_DIRECTORY, +} diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 46fae870e5e..bd614e368dc 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -4,13 +4,16 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "requirements": [ - "aiomusiccast==0.8.2" + "aiomusiccast==0.9.1" ], "ssdp": [ { "manufacturer": "Yamaha Corporation" } ], + "dependencies": [ + "ssdp" + ], "iot_class": "local_push", "codeowners": [ "@vigonotion", diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index d08ba798bd8..5081a716357 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -3,17 +3,26 @@ from __future__ import annotations import logging -from aiomusiccast import MusicCastGroupException +from aiomusiccast import MusicCastGroupException, MusicCastMediaContent from aiomusiccast.features import ZoneFeature import voluptuous as vol -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, + BrowseMedia, + MediaPlayerEntity, +) from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_TRACK, + MEDIA_TYPE_MUSIC, REPEAT_MODE_OFF, + SUPPORT_BROWSE_MEDIA, SUPPORT_GROUPING, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_REPEAT_SET, SUPPORT_SELECT_SOUND_MODE, @@ -51,22 +60,19 @@ from .const import ( HA_REPEAT_MODE_TO_MC_MAPPING, INTERVAL_SECONDS, MC_REPEAT_MODE_TO_HA_MAPPING, + MEDIA_CLASS_MAPPING, NULL_GROUP, ) _LOGGER = logging.getLogger(__name__) MUSIC_PLAYER_BASE_SUPPORT = ( - SUPPORT_PAUSE - | SUPPORT_PLAY - | SUPPORT_SHUFFLE_SET + SUPPORT_SHUFFLE_SET | SUPPORT_REPEAT_SET - | SUPPORT_PREVIOUS_TRACK - | SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOUND_MODE | SUPPORT_SELECT_SOURCE - | SUPPORT_STOP | SUPPORT_GROUPING + | SUPPORT_PLAY_MEDIA ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -198,6 +204,16 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): def _is_tuner(self): return self.coordinator.data.zones[self._zone_id].input == "tuner" + @property + def media_content_id(self): + """Return the content ID of current playing media.""" + return None + + @property + def media_content_type(self): + """Return the content type of current playing media.""" + return MEDIA_TYPE_MUSIC + @property def state(self): """Return the state of the player.""" @@ -308,6 +324,88 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): "Service shuffle is not supported for non NetUSB sources." ) + async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None: + """Play media.""" + if self.state == STATE_OFF: + await self.async_turn_on() + + if media_id: + parts = media_id.split(":") + + if parts[0] == "list": + index = parts[3] + + if index == "-1": + index = "0" + + await self.coordinator.musiccast.play_list_media(index, self._zone_id) + return + + if parts[0] == "presets": + index = parts[1] + await self.coordinator.musiccast.recall_netusb_preset( + self._zone_id, index + ) + return + + if parts[0] == "http": + await self.coordinator.musiccast.play_url_media( + self._zone_id, media_id, "HomeAssistant" + ) + return + + raise HomeAssistantError( + "Only presets, media from media browser and http URLs are supported" + ) + + async def async_browse_media(self, media_content_type=None, media_content_id=None): + """Implement the websocket media browsing helper.""" + if self.state == STATE_OFF: + raise HomeAssistantError( + "The device has to be turned on to be able to browse media." + ) + + if media_content_id: + media_content_path = media_content_id.split(":") + media_content_provider = await MusicCastMediaContent.browse_media( + self.coordinator.musiccast, self._zone_id, media_content_path, 24 + ) + + else: + media_content_provider = MusicCastMediaContent.categories( + self.coordinator.musiccast, self._zone_id + ) + + def get_content_type(item): + if item.can_play: + return MEDIA_CLASS_TRACK + return MEDIA_CLASS_DIRECTORY + + children = [ + BrowseMedia( + title=child.title, + media_class=MEDIA_CLASS_MAPPING.get(child.content_type), + media_content_id=child.content_id, + media_content_type=get_content_type(child), + can_play=child.can_play, + can_expand=child.can_browse, + thumbnail=child.thumbnail, + ) + for child in media_content_provider.children + ] + + overview = BrowseMedia( + title=media_content_provider.title, + media_class=MEDIA_CLASS_MAPPING.get(media_content_provider.content_type), + media_content_id=media_content_provider.content_id, + media_content_type=get_content_type(media_content_provider), + can_play=False, + can_expand=media_content_provider.can_browse, + children=children, + ) + + return overview + async def async_select_sound_mode(self, sound_mode): """Select sound mode.""" await self.coordinator.musiccast.select_sound_mode(self._zone_id, sound_mode) @@ -366,6 +464,18 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): if ZoneFeature.MUTE in zone.features: supported_features |= SUPPORT_VOLUME_MUTE + if self._is_netusb or self._is_tuner: + supported_features |= SUPPORT_PREVIOUS_TRACK + supported_features |= SUPPORT_NEXT_TRACK + + if self._is_netusb: + supported_features |= SUPPORT_PAUSE + supported_features |= SUPPORT_PLAY + supported_features |= SUPPORT_STOP + + if self.state != STATE_OFF: + supported_features |= SUPPORT_BROWSE_MEDIA + return supported_features async def async_media_previous_track(self): diff --git a/requirements_all.txt b/requirements_all.txt index 676539b7b01..244b4e13188 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,7 +216,7 @@ aiolyric==1.0.7 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.8.2 +aiomusiccast==0.9.1 # homeassistant.components.keyboard_remote aionotify==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8665228d24f..6557b0d6ce9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -140,7 +140,7 @@ aiolyric==1.0.7 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.8.2 +aiomusiccast==0.9.1 # homeassistant.components.notion aionotion==3.0.2 diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 6f5709ec7cc..08900b1dfad 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -77,6 +77,30 @@ def mock_ssdp_no_yamaha(): yield +@pytest.fixture +def mock_valid_discovery_information(): + """Mock that the ssdp scanner returns a useful upnp description.""" + with patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[ + { + "ssdp_location": "http://127.0.0.1:9000/MediaRenderer/desc.xml", + "_host": "127.0.0.1", + } + ], + ): + yield + + +@pytest.fixture +def mock_empty_discovery_information(): + """Mock that the ssdp scanner returns no upnp description.""" + with patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[] + ): + yield + + # User Flows @@ -150,7 +174,9 @@ async def test_user_input_unknown_error(hass, mock_get_device_info_exception): assert result2["errors"] == {"base": "unknown"} -async def test_user_input_device_found(hass, mock_get_device_info_valid): +async def test_user_input_device_found( + hass, mock_get_device_info_valid, mock_valid_discovery_information +): """Test when user specifies an existing device.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -167,6 +193,30 @@ async def test_user_input_device_found(hass, mock_get_device_info_valid): assert result2["data"] == { "host": "127.0.0.1", "serial": "1234567890", + "upnp_description": "http://127.0.0.1:9000/MediaRenderer/desc.xml", + } + + +async def test_user_input_device_found_no_ssdp( + hass, mock_get_device_info_valid, mock_empty_discovery_information +): + """Test when user specifies an existing device, which no discovery data are present for.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "127.0.0.1"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert isinstance(result2["result"], ConfigEntry) + assert result2["data"] == { + "host": "127.0.0.1", + "serial": "1234567890", + "upnp_description": "http://127.0.0.1:49154/MediaRenderer/desc.xml", } @@ -201,7 +251,9 @@ async def test_import_error(hass, mock_get_device_info_exception): assert result["errors"] == {"base": "unknown"} -async def test_import_device_successful(hass, mock_get_device_info_valid): +async def test_import_device_successful( + hass, mock_get_device_info_valid, mock_valid_discovery_information +): """Test when the device was imported successfully.""" config = {"platform": "yamaha_musiccast", "host": "127.0.0.1", "port": 5006} @@ -214,6 +266,7 @@ async def test_import_device_successful(hass, mock_get_device_info_valid): assert result["data"] == { "host": "127.0.0.1", "serial": "1234567890", + "upnp_description": "http://127.0.0.1:9000/MediaRenderer/desc.xml", } @@ -262,6 +315,7 @@ async def test_ssdp_discovery_successful_add_device(hass, mock_ssdp_yamaha): assert result2["data"] == { "host": "127.0.0.1", "serial": "1234567890", + "upnp_description": "http://127.0.0.1/desc.xml", } @@ -285,3 +339,4 @@ async def test_ssdp_discovery_existing_device_update(hass, mock_ssdp_yamaha): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" assert mock_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_entry.data["upnp_description"] == "http://127.0.0.1/desc.xml" From f1a4ba8bb0a7b241e69eacc75f194a78d0490535 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 19 Aug 2021 13:19:31 -0700 Subject: [PATCH 536/903] Add Rainforest Eagle tests and price (#54887) --- .coveragerc | 3 - .../components/rainforest_eagle/data.py | 4 +- .../components/rainforest_eagle/sensor.py | 21 ++- homeassistant/helpers/update_coordinator.py | 7 +- tests/components/rainforest_eagle/__init__.py | 63 +++++++ .../rainforest_eagle/test_config_flow.py | 45 +---- .../rainforest_eagle/test_sensor.py | 158 ++++++++++++++++++ 7 files changed, 246 insertions(+), 55 deletions(-) create mode 100644 tests/components/rainforest_eagle/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 9410428a299..237e676c9ac 100644 --- a/.coveragerc +++ b/.coveragerc @@ -838,9 +838,6 @@ omit = homeassistant/components/rainmachine/binary_sensor.py homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/switch.py - homeassistant/components/rainforest_eagle/__init__.py - homeassistant/components/rainforest_eagle/data.py - homeassistant/components/rainforest_eagle/sensor.py homeassistant/components/raspihats/* homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/__init__.py diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index b252993d888..e4cfe144a5e 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -147,14 +147,14 @@ class EagleDataCoordinator(DataUpdateCoordinator): async def _async_update_data_100(self): """Get the latest data from the Eagle-100 device.""" try: - data = await self.hass.async_add_executor_job(self._fetch_data) + data = await self.hass.async_add_executor_job(self._fetch_data_100) except UPDATE_100_ERRORS as error: raise UpdateFailed from error _LOGGER.debug("API data: %s", data) return data - def _fetch_data(self): + def _fetch_data_100(self): """Fetch and return the four sensor values in a dict.""" if self.eagle100_reader is None: self.eagle100_reader = Eagle100Reader( diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 6946ee03974..67f61ffdc29 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, @@ -39,6 +40,7 @@ SENSORS = ( name="Meter Power Demand", native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="zigbee:CurrentSummationDelivered", @@ -95,7 +97,22 @@ async def async_setup_entry( ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities(EagleSensor(coordinator, description) for description in SENSORS) + entities = [EagleSensor(coordinator, description) for description in SENSORS] + + if coordinator.data.get("zigbee:Price") not in (None, "invalid"): + entities.append( + EagleSensor( + coordinator, + SensorEntityDescription( + key="zigbee:Price", + name="Meter Price", + native_unit_of_measurement=f"{coordinator.data['zigbee:PriceCurrency']}/{ENERGY_KILO_WATT_HOUR}", + state_class=STATE_CLASS_MEASUREMENT, + ), + ) + ) + + async_add_entities(entities) class EagleSensor(CoordinatorEntity, SensorEntity): @@ -111,7 +128,7 @@ class EagleSensor(CoordinatorEntity, SensorEntity): @property def unique_id(self) -> str | None: """Return unique ID of entity.""" - return f"{self.coordinator.cloud_id}-{self.entity_description.key}" + return f"{self.coordinator.cloud_id}-${self.coordinator.hardware_address}-{self.entity_description.key}" @property def native_value(self) -> StateType: diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 69111b00885..2203ab240ef 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -242,10 +242,9 @@ class DataUpdateCoordinator(Generic[T]): except Exception as err: # pylint: disable=broad-except self.last_exception = err self.last_update_success = False - if log_failures: - self.logger.exception( - "Unexpected error fetching %s data: %s", self.name, err - ) + self.logger.exception( + "Unexpected error fetching %s data: %s", self.name, err + ) else: if not self.last_update_success: diff --git a/tests/components/rainforest_eagle/__init__.py b/tests/components/rainforest_eagle/__init__.py index df4f1749d49..c5e41591789 100644 --- a/tests/components/rainforest_eagle/__init__.py +++ b/tests/components/rainforest_eagle/__init__.py @@ -1 +1,64 @@ """Tests for the Rainforest Eagle integration.""" +from unittest.mock import patch + +from homeassistant import config_entries, setup +from homeassistant.components.rainforest_eagle.const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_200, +) +from homeassistant.const import CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT +from homeassistant.setup import async_setup_component + + +async def test_import(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.rainforest_eagle.data.async_get_type", + return_value=(TYPE_EAGLE_200, "mock-hw"), + ), patch( + "homeassistant.components.rainforest_eagle.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": DOMAIN, + "ip_address": "192.168.1.55", + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + } + }, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + assert entry.title == "abcdef" + assert entry.data == { + CONF_TYPE: TYPE_EAGLE_200, + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HARDWARE_ADDRESS: "mock-hw", + } + assert len(mock_setup_entry.mock_calls) == 1 + + # Second time we should get already_configured + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + data={CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + context={"source": config_entries.SOURCE_IMPORT}, + ) + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/rainforest_eagle/test_config_flow.py b/tests/components/rainforest_eagle/test_config_flow.py index 626069ed6c1..0a294875f76 100644 --- a/tests/components/rainforest_eagle/test_config_flow.py +++ b/tests/components/rainforest_eagle/test_config_flow.py @@ -12,11 +12,7 @@ from homeassistant.components.rainforest_eagle.const import ( from homeassistant.components.rainforest_eagle.data import CannotConnect, InvalidAuth from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import ( - RESULT_TYPE_ABORT, - RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_FORM, -) +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM async def test_form(hass: HomeAssistant) -> None: @@ -88,42 +84,3 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_import(hass: HomeAssistant) -> None: - """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - with patch( - "homeassistant.components.rainforest_eagle.data.async_get_type", - return_value=(TYPE_EAGLE_200, "mock-hw"), - ), patch( - "homeassistant.components.rainforest_eagle.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - data={CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, - context={"source": config_entries.SOURCE_IMPORT}, - ) - await hass.async_block_till_done() - - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "abcdef" - assert result["data"] == { - CONF_TYPE: TYPE_EAGLE_200, - CONF_CLOUD_ID: "abcdef", - CONF_INSTALL_CODE: "123456", - CONF_HARDWARE_ADDRESS: "mock-hw", - } - assert len(mock_setup_entry.mock_calls) == 1 - - # Second time we should get already_configured - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - data={CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, - context={"source": config_entries.SOURCE_IMPORT}, - ) - - assert result2["type"] == RESULT_TYPE_ABORT - assert result2["reason"] == "already_configured" diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py new file mode 100644 index 00000000000..46621eb5fdc --- /dev/null +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -0,0 +1,158 @@ +"""Tests for rainforest eagle sensors.""" +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.rainforest_eagle.const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_100, + TYPE_EAGLE_200, +) +from homeassistant.const import CONF_TYPE +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +MOCK_CLOUD_ID = "12345" +MOCK_200_RESPONSE_WITH_PRICE = { + "zigbee:InstantaneousDemand": { + "Name": "zigbee:InstantaneousDemand", + "Value": "1.152000", + }, + "zigbee:CurrentSummationDelivered": { + "Name": "zigbee:CurrentSummationDelivered", + "Value": "45251.285000", + }, + "zigbee:CurrentSummationReceived": { + "Name": "zigbee:CurrentSummationReceived", + "Value": "232.232000", + }, + "zigbee:Price": {"Name": "zigbee:Price", "Value": "0.053990"}, + "zigbee:PriceCurrency": {"Name": "zigbee:PriceCurrency", "Value": "USD"}, +} +MOCK_200_RESPONSE_WITHOUT_PRICE = { + "zigbee:InstantaneousDemand": { + "Name": "zigbee:InstantaneousDemand", + "Value": "1.152000", + }, + "zigbee:CurrentSummationDelivered": { + "Name": "zigbee:CurrentSummationDelivered", + "Value": "45251.285000", + }, + "zigbee:CurrentSummationReceived": { + "Name": "zigbee:CurrentSummationReceived", + "Value": "232.232000", + }, + "zigbee:Price": {"Name": "zigbee:Price", "Value": "invalid"}, + "zigbee:PriceCurrency": {"Name": "zigbee:PriceCurrency", "Value": "USD"}, +} + + +@pytest.fixture +async def setup_rainforest_200(hass): + """Set up rainforest.""" + MockConfigEntry( + domain="rainforest_eagle", + data={ + CONF_CLOUD_ID: MOCK_CLOUD_ID, + CONF_INSTALL_CODE: "abcdefgh", + CONF_HARDWARE_ADDRESS: "mock-hw-address", + CONF_TYPE: TYPE_EAGLE_200, + }, + ).add_to_hass(hass) + with patch( + "aioeagle.ElectricMeter.get_device_query", + return_value=MOCK_200_RESPONSE_WITHOUT_PRICE, + ) as mock_update: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update + + +@pytest.fixture +async def setup_rainforest_100(hass): + """Set up rainforest.""" + MockConfigEntry( + domain="rainforest_eagle", + data={ + CONF_CLOUD_ID: MOCK_CLOUD_ID, + CONF_INSTALL_CODE: "abcdefgh", + CONF_HARDWARE_ADDRESS: None, + CONF_TYPE: TYPE_EAGLE_100, + }, + ).add_to_hass(hass) + with patch( + "homeassistant.components.rainforest_eagle.data.Eagle100Reader", + return_value=Mock( + get_instantaneous_demand=Mock( + return_value={"InstantaneousDemand": {"Demand": "1.152000"}} + ), + get_current_summation=Mock( + return_value={ + "CurrentSummation": { + "SummationDelivered": "45251.285000", + "SummationReceived": "232.232000", + } + } + ), + ), + ) as mock_update: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update + + +async def test_sensors_200(hass, setup_rainforest_200): + """Test the sensors.""" + assert len(hass.states.async_all()) == 3 + + demand = hass.states.get("sensor.meter_power_demand") + assert demand is not None + assert demand.state == "1.152000" + assert demand.attributes["unit_of_measurement"] == "kW" + + delivered = hass.states.get("sensor.total_meter_energy_delivered") + assert delivered is not None + assert delivered.state == "45251.285000" + assert delivered.attributes["unit_of_measurement"] == "kWh" + + received = hass.states.get("sensor.total_meter_energy_received") + assert received is not None + assert received.state == "232.232000" + assert received.attributes["unit_of_measurement"] == "kWh" + + setup_rainforest_200.return_value = MOCK_200_RESPONSE_WITH_PRICE + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 4 + + price = hass.states.get("sensor.meter_price") + assert price is not None + assert price.state == "0.053990" + assert price.attributes["unit_of_measurement"] == "USD/kWh" + + +async def test_sensors_100(hass, setup_rainforest_100): + """Test the sensors.""" + assert len(hass.states.async_all()) == 3 + + demand = hass.states.get("sensor.meter_power_demand") + assert demand is not None + assert demand.state == "1.152000" + assert demand.attributes["unit_of_measurement"] == "kW" + + delivered = hass.states.get("sensor.total_meter_energy_delivered") + assert delivered is not None + assert delivered.state == "45251.285000" + assert delivered.attributes["unit_of_measurement"] == "kWh" + + received = hass.states.get("sensor.total_meter_energy_received") + assert received is not None + assert received.state == "232.232000" + assert received.attributes["unit_of_measurement"] == "kWh" From fc6d45a63bb53d315e29c95ddcfe02c8a23131a7 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 20 Aug 2021 00:16:03 +0000 Subject: [PATCH 537/903] [ci skip] Translation update --- .../components/airtouch4/translations/hu.json | 19 ++++++++++++++++ .../components/airtouch4/translations/pl.json | 19 ++++++++++++++++ .../binary_sensor/translations/pl.json | 8 +++++++ .../rainforest_eagle/translations/ca.json | 20 +++++++++++++++++ .../rainforest_eagle/translations/et.json | 20 +++++++++++++++++ .../rainforest_eagle/translations/pl.json | 20 +++++++++++++++++ .../translations/zh-Hant.json | 20 +++++++++++++++++ .../components/sensor/translations/pl.json | 22 +++++++++++++++++-- .../components/tractive/translations/pl.json | 4 +++- .../uptimerobot/translations/pl.json | 13 ++++++++++- .../xiaomi_miio/translations/pl.json | 2 +- 11 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/airtouch4/translations/hu.json create mode 100644 homeassistant/components/airtouch4/translations/pl.json create mode 100644 homeassistant/components/rainforest_eagle/translations/ca.json create mode 100644 homeassistant/components/rainforest_eagle/translations/et.json create mode 100644 homeassistant/components/rainforest_eagle/translations/pl.json create mode 100644 homeassistant/components/rainforest_eagle/translations/zh-Hant.json diff --git a/homeassistant/components/airtouch4/translations/hu.json b/homeassistant/components/airtouch4/translations/hu.json new file mode 100644 index 00000000000..c5d54de31de --- /dev/null +++ b/homeassistant/components/airtouch4/translations/hu.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen kapcsol\u00f3d\u00e1s", + "no_units": "Nem tal\u00e1lhat\u00f3 AirTouch 4 csoport." + }, + "step": { + "user": { + "data": { + "host": "Gazdag\u00e9p" + }, + "title": "\u00c1ll\u00edtsa be az AirTouch 4 csatlakoz\u00e1si adatait." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/pl.json b/homeassistant/components/airtouch4/translations/pl.json new file mode 100644 index 00000000000..55f0b72b1a7 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "no_units": "Nie mo\u017cna znale\u017a\u0107 \u017cadnych grup AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "title": "Konfiguracja po\u0142\u0105czenia AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/pl.json b/homeassistant/components/binary_sensor/translations/pl.json index 726765aea02..6e6b272d869 100644 --- a/homeassistant/components/binary_sensor/translations/pl.json +++ b/homeassistant/components/binary_sensor/translations/pl.json @@ -17,6 +17,7 @@ "is_no_problem": "sensor {entity_name} nie wykrywa problemu", "is_no_smoke": "sensor {entity_name} nie wykrywa dymu", "is_no_sound": "sensor {entity_name} nie wykrywa d\u017awi\u0119ku", + "is_no_update": "{entity_name} jest aktualny(-a)", "is_no_vibration": "sensor {entity_name} nie wykrywa wibracji", "is_not_bat_low": "bateria {entity_name} nie jest roz\u0142adowana", "is_not_cold": "sensor {entity_name} nie wykrywa zimna", @@ -42,6 +43,7 @@ "is_smoke": "sensor {entity_name} wykrywa dym", "is_sound": "sensor {entity_name} wykrywa d\u017awi\u0119k", "is_unsafe": "sensor {entity_name} wykrywa zagro\u017cenie", + "is_update": "{entity_name} ma dost\u0119pn\u0105 aktualizacj\u0119", "is_vibration": "sensor {entity_name} wykrywa wibracje" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "sensor {entity_name} przestanie wykrywa\u0107 problem", "no_smoke": "sensor {entity_name} przestanie wykrywa\u0107 dym", "no_sound": "sensor {entity_name} przestanie wykrywa\u0107 d\u017awi\u0119k", + "no_update": "{entity_name} zosta\u0142 zaktualizowany(-a)", "no_vibration": "sensor {entity_name} przestanie wykrywa\u0107 wibracje", "not_bat_low": "nast\u0105pi na\u0142adowanie baterii {entity_name}", "not_cold": "sensor {entity_name} przestanie wykrywa\u0107 zimno", @@ -86,6 +89,7 @@ "turned_off": "nast\u0105pi wy\u0142\u0105czenie {entity_name}", "turned_on": "nast\u0105pi w\u0142\u0105czenie {entity_name}", "unsafe": "sensor {entity_name} wykryje zagro\u017cenie", + "update": "{entity_name} ma dost\u0119pn\u0105 aktualizacj\u0119", "vibration": "sensor {entity_name} wykryje wibracje" } }, @@ -178,6 +182,10 @@ "off": "brak", "on": "wykryto" }, + "update": { + "off": "Aktualny(-a)", + "on": "Dost\u0119pna aktualizacja" + }, "vibration": { "off": "brak", "on": "wykryto" diff --git a/homeassistant/components/rainforest_eagle/translations/ca.json b/homeassistant/components/rainforest_eagle/translations/ca.json new file mode 100644 index 00000000000..5670a555d5e --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "cloud_id": "ID del n\u00favol", + "install_code": "Codi d'instal\u00b7laci\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/et.json b/homeassistant/components/rainforest_eagle/translations/et.json new file mode 100644 index 00000000000..e6e0c2fbe5f --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "cloud_id": "Pilveteenuse ID", + "install_code": "Paigalduskood" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/pl.json b/homeassistant/components/rainforest_eagle/translations/pl.json new file mode 100644 index 00000000000..acbbaf044d3 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "cloud_id": "Identyfikator chmury", + "install_code": "Kod instalacji" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/zh-Hant.json b/homeassistant/components/rainforest_eagle/translations/zh-Hant.json new file mode 100644 index 00000000000..f306750fa29 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "cloud_id": "Cloud ID", + "install_code": "\u5b89\u88dd\u78bc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/translations/pl.json b/homeassistant/components/sensor/translations/pl.json index fe47bfd902c..2a82919e42e 100644 --- a/homeassistant/components/sensor/translations/pl.json +++ b/homeassistant/components/sensor/translations/pl.json @@ -2,16 +2,25 @@ "device_automation": { "condition_type": { "is_battery_level": "obecny poziom na\u0142adowania baterii {entity_name}", - "is_carbon_dioxide": "Bie\u017c\u0105cy poziom st\u0119\u017cenia dwutlenku w\u0119gla w {entity_name}", - "is_carbon_monoxide": "Bie\u017c\u0105cy poziom st\u0119\u017cenia tlenku w\u0119gla w {entity_name}", + "is_carbon_dioxide": "obecny poziom st\u0119\u017cenia dwutlenku w\u0119gla w {entity_name}", + "is_carbon_monoxide": "obecny poziom st\u0119\u017cenia tlenku w\u0119gla w {entity_name}", "is_current": "obecne nat\u0119\u017cenie pr\u0105du {entity_name}", "is_energy": "obecna energia {entity_name}", + "is_gas": "obecny poziom gazu {entity_name}", "is_humidity": "obecna wilgotno\u015b\u0107 {entity_name}", "is_illuminance": "obecne nat\u0119\u017cenie o\u015bwietlenia {entity_name}", + "is_nitrogen_dioxide": "obecny poziom st\u0119\u017cenia dwutlenku azotu {entity_name}", + "is_nitrogen_monoxide": "obecny poziom st\u0119\u017cenia tlenku azotu {entity_name}", + "is_nitrous_oxide": "obecny poziom st\u0119\u017cenia podtlenku azotu {entity_name}", + "is_ozone": "obecny poziom st\u0119\u017cenia ozonu {entity_name}", + "is_pm1": "obecny poziom st\u0119\u017cenia PM1 {entity_name}", + "is_pm10": "obecny poziom st\u0119\u017cenia PM10 {entity_name}", + "is_pm25": "obecny poziom st\u0119\u017cenia PM2.5 {entity_name}", "is_power": "obecna moc {entity_name}", "is_power_factor": "obecny wsp\u00f3\u0142czynnik mocy {entity_name}", "is_pressure": "obecne ci\u015bnienie {entity_name}", "is_signal_strength": "obecna si\u0142a sygna\u0142u {entity_name}", + "is_sulphur_dioxide": "obecny poziom st\u0119\u017cenia dwutlenku siarki {entity_name}", "is_temperature": "obecna temperatura {entity_name}", "is_value": "obecna warto\u015b\u0107 {entity_name}", "is_voltage": "obecne napi\u0119cie {entity_name}" @@ -22,12 +31,21 @@ "carbon_monoxide": "Zmiana st\u0119\u017cenia tlenku w\u0119gla w {entity_name}", "current": "zmieni si\u0119 nat\u0119\u017cenie pr\u0105du w {entity_name}", "energy": "zmieni si\u0119 energia {entity_name}", + "gas": "zmieni si\u0119 poziom gazu w {entity_name}", "humidity": "zmieni si\u0119 wilgotno\u015b\u0107 {entity_name}", "illuminance": "zmieni si\u0119 nat\u0119\u017cenie o\u015bwietlenia {entity_name}", + "nitrogen_dioxide": "zmieni si\u0119 st\u0119\u017cenie dwutlenku azotu w {entity_name}", + "nitrogen_monoxide": "zmieni si\u0119 st\u0119\u017cenie tlenku azotu w {entity_name}", + "nitrous_oxide": "zmieni si\u0119 st\u0119\u017cenie podtlenku azotu w {entity_name}", + "ozone": "zmieni si\u0119 st\u0119\u017cenie ozonu w {entity_name}", + "pm1": "zmieni si\u0119 st\u0119\u017cenie PM1 w {entity_name}", + "pm10": "zmieni si\u0119 st\u0119\u017cenie PM10 w {entity_name}", + "pm25": "zmieni si\u0119 st\u0119\u017cenie PM2.5 w {entity_name}", "power": "zmieni si\u0119 moc {entity_name}", "power_factor": "zmieni si\u0119 wsp\u00f3\u0142czynnik mocy w {entity_name}", "pressure": "zmieni si\u0119 ci\u015bnienie {entity_name}", "signal_strength": "zmieni si\u0119 si\u0142a sygna\u0142u {entity_name}", + "sulphur_dioxide": "zmieni si\u0119 st\u0119\u017cenie dwutlenku siarki w {entity_name}", "temperature": "zmieni si\u0119 temperatura {entity_name}", "value": "zmieni si\u0119 warto\u015b\u0107 {entity_name}", "voltage": "zmieni si\u0119 napi\u0119cie w {entity_name}" diff --git a/homeassistant/components/tractive/translations/pl.json b/homeassistant/components/tractive/translations/pl.json index da4e71dc1b7..99379115ef1 100644 --- a/homeassistant/components/tractive/translations/pl.json +++ b/homeassistant/components/tractive/translations/pl.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_failed_existing": "Nie mo\u017cna zaktualizowa\u0107 wpisu konfiguracji, usu\u0144 integracj\u0119 i skonfiguruj j\u0105 ponownie.", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { "invalid_auth": "Niepoprawne uwierzytelnienie", diff --git a/homeassistant/components/uptimerobot/translations/pl.json b/homeassistant/components/uptimerobot/translations/pl.json index ac413226e98..18c40afec1e 100644 --- a/homeassistant/components/uptimerobot/translations/pl.json +++ b/homeassistant/components/uptimerobot/translations/pl.json @@ -2,18 +2,29 @@ "config": { "abort": { "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_failed_existing": "Nie mo\u017cna zaktualizowa\u0107 wpisu konfiguracji, usu\u0144 integracj\u0119 i skonfiguruj j\u0105 ponownie.", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "invalid_api_key": "Nieprawid\u0142owy klucz API", + "reauth_failed_matching_account": "Podany klucz API nie jest zgodny z identyfikatorem konta istniej\u0105cej konfiguracji.", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Klucz API" + }, + "description": "Musisz poda\u0107 nowy, tylko do odczytu, klucz API od Uptime Robot", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, "user": { "data": { "api_key": "Klucz API" - } + }, + "description": "Musisz poda\u0107 klucz API (tylko do odczytu) od Uptime Robot" } } } diff --git a/homeassistant/components/xiaomi_miio/translations/pl.json b/homeassistant/components/xiaomi_miio/translations/pl.json index dacb0f3f3ec..879d0b8d7ba 100644 --- a/homeassistant/components/xiaomi_miio/translations/pl.json +++ b/homeassistant/components/xiaomi_miio/translations/pl.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "cloud_credentials_incomplete": "Dane logowania do chmury niekompletne, prosz\u0119 poda\u0107 nazw\u0119 u\u017cytkownika, has\u0142o i kraj", - "cloud_login_error": "Nie mo\u017cna zalogowa\u0107 si\u0119 do chmury Xioami Miio, sprawd\u017a po\u015bwiadczenia.", + "cloud_login_error": "Nie mo\u017cna zalogowa\u0107 si\u0119 do chmury Xiaomi Miio, sprawd\u017a po\u015bwiadczenia.", "cloud_no_devices": "Na tym koncie Xiaomi Miio nie znaleziono \u017cadnych urz\u0105dze\u0144.", "no_device_selected": "Nie wybrano \u017cadnego urz\u0105dzenia, wybierz jedno urz\u0105dzenie", "unknown_device": "Model urz\u0105dzenia nie jest znany, nie mo\u017cna skonfigurowa\u0107 urz\u0105dzenia przy u\u017cyciu interfejsu u\u017cytkownika." From 4bb2c6e00fd60e768a7f52c2745fb62c88748b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 20 Aug 2021 07:13:25 +0300 Subject: [PATCH 538/903] Improve device action type hinting (#54850) * Improve device action type hinting * More precise _async_get_automations type hints Co-authored-by: Martin Hjelmare --- homeassistant/components/climate/device_action.py | 4 +++- homeassistant/components/cover/device_action.py | 9 +++++++-- .../components/device_automation/toggle_entity.py | 13 +++++++------ homeassistant/components/fan/device_action.py | 4 +++- .../components/humidifier/device_action.py | 6 +++++- homeassistant/components/light/device_action.py | 10 ++++++++-- homeassistant/components/lock/device_action.py | 4 +++- .../components/mobile_app/device_action.py | 4 +++- homeassistant/components/number/device_action.py | 13 ++++++++----- homeassistant/components/remote/device_action.py | 6 +++++- homeassistant/components/select/device_action.py | 8 ++++---- homeassistant/components/switch/device_action.py | 6 +++++- homeassistant/components/vacuum/device_action.py | 4 +++- .../components/water_heater/device_action.py | 4 +++- homeassistant/components/zha/device_action.py | 6 +++++- .../device_action/integration/device_action.py | 4 +++- 16 files changed, 75 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 6afc4d294cb..34217e8872d 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -38,7 +38,9 @@ SET_PRESET_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ACTION_SCHEMA = vol.Any(SET_HVAC_MODE_SCHEMA, SET_PRESET_MODE_SCHEMA) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Climate devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index 13ef4523f5b..debb2368cf2 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -21,6 +21,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features +from homeassistant.helpers.typing import ConfigType from . import ( ATTR_POSITION, @@ -58,7 +59,9 @@ POSITION_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Cover devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] @@ -98,7 +101,9 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: return actions -async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List action capabilities.""" if config[CONF_TYPE] not in POSITION_ACTION_TYPES: return {} diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index cf41fc93d83..6ad2264b516 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -1,8 +1,6 @@ """Device automation helpers for toggle entity.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -169,10 +167,13 @@ async def async_attach_trigger( async def _async_get_automations( - hass: HomeAssistant, device_id: str, automation_templates: list[dict], domain: str -) -> list[dict]: + hass: HomeAssistant, + device_id: str, + automation_templates: list[dict[str, str]], + domain: str, +) -> list[dict[str, str]]: """List device automations.""" - automations: list[dict[str, Any]] = [] + automations: list[dict[str, str]] = [] entity_registry = await hass.helpers.entity_registry.async_get_registry() entries = [ @@ -197,7 +198,7 @@ async def _async_get_automations( async def async_get_actions( hass: HomeAssistant, device_id: str, domain: str -) -> list[dict]: +) -> list[dict[str, str]]: """List device actions.""" return await _async_get_automations(hass, device_id, ENTITY_ACTIONS, domain) diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py index ddf6a76d3c8..0482c31b929 100644 --- a/homeassistant/components/fan/device_action.py +++ b/homeassistant/components/fan/device_action.py @@ -28,7 +28,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Fan devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 81df6938236..3ad4b22dcec 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -19,6 +19,8 @@ from homeassistant.helpers.entity import get_capability, get_supported_features from . import DOMAIN, const +# mypy: disallow-any-generics + SET_HUMIDITY_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): "set_humidity", @@ -40,7 +42,9 @@ ONOFF_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DO ACTION_SCHEMA = vol.Any(SET_HUMIDITY_SCHEMA, SET_MODE_SCHEMA, ONOFF_SCHEMA) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Humidifier devices.""" registry = await entity_registry.async_get_registry(hass) actions = await toggle_entity.async_get_actions(hass, device_id, DOMAIN) diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 2180bdd3094..a933d04066e 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -32,6 +32,8 @@ from . import ( get_supported_color_modes, ) +# mypy: disallow-any-generics + TYPE_BRIGHTNESS_INCREASE = "brightness_increase" TYPE_BRIGHTNESS_DECREASE = "brightness_decrease" TYPE_FLASH = "flash" @@ -86,7 +88,9 @@ async def async_call_action_from_config( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions.""" actions = await toggle_entity.async_get_actions(hass, device_id, DOMAIN) @@ -119,7 +123,9 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: return actions -async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List action capabilities.""" if config[CONF_TYPE] != toggle_entity.CONF_TURN_ON: return {} diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index 6c0eb2a41d4..50c205d113a 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -30,7 +30,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Lock devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/homeassistant/components/mobile_app/device_action.py b/homeassistant/components/mobile_app/device_action.py index 33a7510da21..193c25e482c 100644 --- a/homeassistant/components/mobile_app/device_action.py +++ b/homeassistant/components/mobile_app/device_action.py @@ -22,7 +22,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Mobile App devices.""" webhook_id = webhook_id_from_device_id(hass, device_id) diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index 77b36b49f20..77ca633d947 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -1,8 +1,6 @@ """Provides device actions for Number.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.const import ( @@ -15,6 +13,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from . import DOMAIN, const @@ -29,10 +28,12 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Number.""" registry = await entity_registry.async_get_registry(hass) - actions: list[dict[str, Any]] = [] + actions: list[dict[str, str]] = [] # Get all the integrations entities for this device for entry in entity_registry.async_entries_for_device(registry, device_id): @@ -67,7 +68,9 @@ async def async_call_action_from_config( ) -async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List action capabilities.""" fields = {vol.Required(const.ATTR_VALUE): vol.Coerce(float)} diff --git a/homeassistant/components/remote/device_action.py b/homeassistant/components/remote/device_action.py index aa34eb33224..a337f3275eb 100644 --- a/homeassistant/components/remote/device_action.py +++ b/homeassistant/components/remote/device_action.py @@ -10,6 +10,8 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN +# mypy: disallow-any-generics + ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) @@ -25,6 +27,8 @@ async def async_call_action_from_config( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions.""" return await toggle_entity.async_get_actions(hass, device_id, DOMAIN) diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index ece3c981690..ca9c1963782 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -1,8 +1,6 @@ """Provides device actions for Select.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.const import ( @@ -31,7 +29,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Select devices.""" registry = await entity_registry.async_get_registry(hass) return [ @@ -64,7 +64,7 @@ async def async_call_action_from_config( async def async_get_action_capabilities( hass: HomeAssistant, config: ConfigType -) -> dict[str, Any]: +) -> dict[str, vol.Schema]: """List action capabilities.""" try: options = get_capability(hass, config[CONF_ENTITY_ID], ATTR_OPTIONS) or [] diff --git a/homeassistant/components/switch/device_action.py b/homeassistant/components/switch/device_action.py index 0f3890d329f..6947656406b 100644 --- a/homeassistant/components/switch/device_action.py +++ b/homeassistant/components/switch/device_action.py @@ -10,6 +10,8 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN +# mypy: disallow-any-generics + ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) @@ -25,6 +27,8 @@ async def async_call_action_from_config( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions.""" return await toggle_entity.async_get_actions(hass, device_id, DOMAIN) diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index a4df68c3b93..702f3fe7439 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -26,7 +26,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Vacuum devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 3662dee9a5e..dae9e4d579b 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -28,7 +28,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Water Heater devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index de39ff50511..36696517eb6 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -13,6 +13,8 @@ from .api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN from .core.const import CHANNEL_IAS_WD from .core.helpers import async_get_zha_device +# mypy: disallow-any-generics + ACTION_SQUAWK = "squawk" ACTION_WARN = "warn" ATTR_DATA = "data" @@ -54,7 +56,9 @@ async def async_call_action_from_config( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions.""" try: zha_device = await async_get_zha_device(hass, device_id) diff --git a/script/scaffold/templates/device_action/integration/device_action.py b/script/scaffold/templates/device_action/integration/device_action.py index 720e472851c..5eb5249211b 100644 --- a/script/scaffold/templates/device_action/integration/device_action.py +++ b/script/scaffold/templates/device_action/integration/device_action.py @@ -29,7 +29,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for NEW_NAME devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] From 036e99e91e9b407dbed377a4a51c8cc21bad71e9 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 20 Aug 2021 00:43:04 -0400 Subject: [PATCH 539/903] Allow integrations to define trigger platforms with a subtype (#54861) --- homeassistant/helpers/trigger.py | 3 ++- tests/helpers/test_trigger.py | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index b5a82c3c020..29f344a6fa0 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -21,7 +21,8 @@ _PLATFORM_ALIASES = { async def _async_get_trigger_platform(hass: HomeAssistant, config: ConfigType) -> Any: - platform = config[CONF_PLATFORM] + platform_and_sub_type = config[CONF_PLATFORM].split(".") + platform = platform_and_sub_type[0] for alias, triggers in _PLATFORM_ALIASES.items(): if platform in triggers: platform = alias diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index b4bfb881186..7afdb629792 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1,8 +1,13 @@ """The tests for the trigger helper.""" +from unittest.mock import MagicMock, call, patch + import pytest import voluptuous as vol -from homeassistant.helpers.trigger import async_validate_trigger_config +from homeassistant.helpers.trigger import ( + _async_get_trigger_platform, + async_validate_trigger_config, +) async def test_bad_trigger_platform(hass): @@ -10,3 +15,12 @@ async def test_bad_trigger_platform(hass): with pytest.raises(vol.Invalid) as ex: await async_validate_trigger_config(hass, [{"platform": "not_a_platform"}]) assert "Invalid platform 'not_a_platform' specified" in str(ex) + + +async def test_trigger_subtype(hass): + """Test trigger subtypes.""" + with patch( + "homeassistant.helpers.trigger.async_get_integration", return_value=MagicMock() + ) as integration_mock: + await _async_get_trigger_platform(hass, {"platform": "test.subtype"}) + assert integration_mock.call_args == call(hass, "test") From 32e297f4a0e59ef44e12604fc7552b1ee37ad00d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 20 Aug 2021 07:10:45 +0200 Subject: [PATCH 540/903] Compile missing statistics (#54690) --- homeassistant/components/recorder/__init__.py | 37 +++++++++- .../components/recorder/migration.py | 15 ++++ homeassistant/components/recorder/models.py | 22 +++++- .../components/recorder/statistics.py | 8 +++ homeassistant/components/recorder/util.py | 4 +- tests/components/recorder/test_init.py | 71 ++++++++++++++++++- tests/components/recorder/test_statistics.py | 33 +++++++++ 7 files changed, 183 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index e6c15729d24..897a4eb3c94 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -11,7 +11,7 @@ import threading import time from typing import Any, Callable, NamedTuple -from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select +from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.pool import StaticPool @@ -50,7 +50,14 @@ import homeassistant.util.dt as dt_util from . import history, migration, purge, statistics from .const import CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX -from .models import Base, Events, RecorderRuns, States +from .models import ( + Base, + Events, + RecorderRuns, + States, + StatisticsRuns, + process_timestamp, +) from .pool import RecorderPool from .util import ( dburl_to_path, @@ -567,11 +574,35 @@ class Recorder(threading.Thread): async_track_time_change( self.hass, self.async_nightly_tasks, hour=4, minute=12, second=0 ) + # Compile hourly statistics every hour at *:12 async_track_time_change( self.hass, self.async_hourly_statistics, minute=12, second=0 ) + # Add tasks for missing statistics runs + now = dt_util.utcnow() + last_hour = now.replace(minute=0, second=0, microsecond=0) + start = now - timedelta(days=self.keep_days) + start = start.replace(minute=0, second=0, microsecond=0) + + if not self.get_session: + # Home Assistant is shutting down + return + + # Find the newest statistics run, if any + with session_scope(session=self.get_session()) as session: + last_run = session.query(func.max(StatisticsRuns.start)).scalar() + if last_run: + start = max(start, process_timestamp(last_run) + timedelta(hours=1)) + + # Add tasks + while start < last_hour: + end = start + timedelta(hours=1) + _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) + self.queue.put(StatisticsTask(start)) + start = start + timedelta(hours=1) + def run(self): """Start processing events to save.""" shutdown_task = object() @@ -606,7 +637,7 @@ class Recorder(threading.Thread): if not schema_is_current: if self._migrate_schema_and_setup_run(current_version): if not self._event_listener: - # If the schema migration takes so longer that the end + # If the schema migration takes so long that the end # queue watcher safety kicks in because MAX_QUEUE_BACKLOG # is reached, we need to reinitialize the listener. self.hass.add_job(self.async_initialize) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 06391f2864d..211e1646cca 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,4 +1,5 @@ """Schema migration helpers.""" +from datetime import timedelta import logging import sqlalchemy @@ -11,6 +12,8 @@ from sqlalchemy.exc import ( ) from sqlalchemy.schema import AddConstraint, DropConstraint +import homeassistant.util.dt as dt_util + from .models import ( SCHEMA_VERSION, TABLE_STATES, @@ -18,6 +21,7 @@ from .models import ( SchemaChanges, Statistics, StatisticsMeta, + StatisticsRuns, ) from .util import session_scope @@ -475,6 +479,13 @@ def _apply_update(engine, session, new_version, old_version): StatisticsMeta.__table__.create(engine) Statistics.__table__.create(engine) + elif new_version == 19: + # This adds the statistic runs table, insert a fake run to prevent duplicating + # statistics. + now = dt_util.utcnow() + start = now.replace(minute=0, second=0, microsecond=0) + start = start - timedelta(hours=1) + session.add(StatisticsRuns(start=start)) else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -494,6 +505,10 @@ def _inspect_schema_version(engine, session): for index in indexes: if index["column_names"] == ["time_fired"]: # Schema addition from version 1 detected. New DB. + now = dt_util.utcnow() + start = now.replace(minute=0, second=0, microsecond=0) + start = start - timedelta(hours=1) + session.add(StatisticsRuns(start=start)) session.add(SchemaChanges(schema_version=SCHEMA_VERSION)) return SCHEMA_VERSION diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index fe75ba1cb50..1c56e9c8f79 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -39,7 +39,7 @@ import homeassistant.util.dt as dt_util # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 18 +SCHEMA_VERSION = 19 _LOGGER = logging.getLogger(__name__) @@ -51,6 +51,7 @@ TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" TABLE_STATISTICS_META = "statistics_meta" +TABLE_STATISTICS_RUNS = "statistics_runs" ALL_TABLES = [ TABLE_STATES, @@ -59,6 +60,7 @@ ALL_TABLES = [ TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, TABLE_STATISTICS_META, + TABLE_STATISTICS_RUNS, ] DATETIME_TYPE = DateTime(timezone=True).with_variant( @@ -110,7 +112,7 @@ class Events(Base): # type: ignore ) def to_native(self, validate_entity_id=True): - """Convert to a natve HA Event.""" + """Convert to a native HA Event.""" context = Context( id=self.context_id, user_id=self.context_user_id, @@ -359,6 +361,22 @@ class SchemaChanges(Base): # type: ignore ) +class StatisticsRuns(Base): # type: ignore + """Representation of statistics run.""" + + __tablename__ = TABLE_STATISTICS_RUNS + run_id = Column(Integer, primary_key=True) + start = Column(DateTime(timezone=True)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + def process_timestamp(ts): """Process a timestamp into datetime object.""" if ts is None: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 6017f050419..f8a9e3a6c89 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -30,6 +30,7 @@ from .models import ( StatisticMetaData, Statistics, StatisticsMeta, + StatisticsRuns, process_timestamp_to_utc_isoformat, ) from .util import execute, retryable_database_job, session_scope @@ -156,6 +157,12 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: """Compile statistics.""" start = dt_util.as_utc(start) end = start + timedelta(hours=1) + + with session_scope(session=instance.get_session()) as session: # type: ignore + if session.query(StatisticsRuns).filter_by(start=start).first(): + _LOGGER.debug("Statistics already compiled for %s-%s", start, end) + return True + _LOGGER.debug("Compiling statistics for %s-%s", start, end) platform_stats = [] for domain, platform in instance.hass.data[DOMAIN].items(): @@ -173,6 +180,7 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: instance.hass, session, entity_id, stat["meta"] ) session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) + session.add(StatisticsRuns(start=start)) return True diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index e3af39b217a..f492b754125 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -24,6 +24,7 @@ from .models import ( TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, TABLE_STATISTICS_META, + TABLE_STATISTICS_RUNS, RecorderRuns, process_timestamp, ) @@ -183,7 +184,8 @@ def basic_sanity_check(cursor): """Check tables to make sure select does not fail.""" for table in ALL_TABLES: - if table in [TABLE_STATISTICS, TABLE_STATISTICS_META]: + # The statistics tables may not be present in old databases + if table in [TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS]: continue if table in (TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES): cursor.execute(f"SELECT * FROM {table};") # nosec # not injection diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 195e56dc748..fa0e8b7349b 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -25,7 +25,13 @@ from homeassistant.components.recorder import ( run_information_with_session, ) from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import Events, RecorderRuns, States +from homeassistant.components.recorder.models import ( + Events, + RecorderRuns, + States, + StatisticsRuns, + process_timestamp, +) from homeassistant.components.recorder.util import session_scope from homeassistant.const import ( EVENT_HOMEASSISTANT_FINAL_WRITE, @@ -735,6 +741,69 @@ def test_auto_statistics(hass_recorder): dt_util.set_default_time_zone(original_tz) +def test_statistics_runs_initiated(hass_recorder): + """Test statistics_runs is initiated when DB is created.""" + now = dt_util.utcnow() + with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=now): + hass = hass_recorder() + + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + statistics_runs = list(session.query(StatisticsRuns)) + assert len(statistics_runs) == 1 + last_run = process_timestamp(statistics_runs[0].start) + assert process_timestamp(last_run) == now.replace( + minute=0, second=0, microsecond=0 + ) - timedelta(hours=1) + + +def test_compile_missing_statistics(tmpdir): + """Test missing statistics are compiled on startup.""" + now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=now): + + hass = get_test_home_assistant() + setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + statistics_runs = list(session.query(StatisticsRuns)) + assert len(statistics_runs) == 1 + last_run = process_timestamp(statistics_runs[0].start) + assert last_run == now - timedelta(hours=1) + + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() + + with patch( + "homeassistant.components.recorder.dt_util.utcnow", + return_value=now + timedelta(hours=1), + ): + + hass = get_test_home_assistant() + setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + statistics_runs = list(session.query(StatisticsRuns)) + assert len(statistics_runs) == 2 + last_run = process_timestamp(statistics_runs[1].start) + assert last_run == now + + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() + + def test_saving_sets_old_state(hass_recorder): """Test saving sets old state.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 83995b0c0ac..995ad537ab4 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -148,6 +148,39 @@ def test_rename_entity(hass_recorder): assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} +def test_statistics_duplicated(hass_recorder, caplog): + """Test statistics with same start time is not compiled.""" + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + with patch( + "homeassistant.components.sensor.recorder.compile_statistics" + ) as compile_statistics: + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + assert compile_statistics.called + compile_statistics.reset_mock() + assert "Compiling statistics for" in caplog.text + assert "Statistics already compiled" not in caplog.text + caplog.clear() + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + assert not compile_statistics.called + compile_statistics.reset_mock() + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" in caplog.text + caplog.clear() + + def record_states(hass): """Record some test states. From 68fbc0792a1ec5d44ce4afccdeaf158d8c594a4e Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Fri, 20 Aug 2021 08:45:04 +0200 Subject: [PATCH 541/903] Add P1 Monitor integration (#54738) * Init integration P1 Monitor * Fix build error * Add quality scale * Remove last_reset and icon * Change list to tuple * Close client on connection exception * Change min value to 5 (seconds) * the used python package will close it * Remove the options flow * Add session and close client * Smash to a single DataUpdateCoordinator * Make a custom update coordinator class * await the coordinator close * Add second await the coordinator close * Close when exit scope * Removed unused code * Fix test_sensor on entity_id change * Fix test on test_sensor * Transfer SENSOR dict to sensor platform * device class for cost entity update entity_name * Revert name in unique id and update sensor test * Update code based on suggestions * Fix typing * Change code to fix mypy errors Co-authored-by: Martin Hjelmare --- CODEOWNERS | 1 + .../components/p1_monitor/__init__.py | 91 ++++++ .../components/p1_monitor/config_flow.py | 57 ++++ homeassistant/components/p1_monitor/const.py | 23 ++ .../components/p1_monitor/manifest.json | 10 + homeassistant/components/p1_monitor/sensor.py | 287 ++++++++++++++++++ .../components/p1_monitor/strings.json | 17 ++ .../p1_monitor/translations/en.json | 17 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/p1_monitor/__init__.py | 1 + tests/components/p1_monitor/conftest.py | 59 ++++ .../components/p1_monitor/test_config_flow.py | 62 ++++ tests/components/p1_monitor/test_init.py | 44 +++ tests/components/p1_monitor/test_sensor.py | 201 ++++++++++++ tests/fixtures/p1_monitor/phases.json | 74 +++++ tests/fixtures/p1_monitor/settings.json | 27 ++ tests/fixtures/p1_monitor/smartmeter.json | 15 + 19 files changed, 993 insertions(+) create mode 100644 homeassistant/components/p1_monitor/__init__.py create mode 100644 homeassistant/components/p1_monitor/config_flow.py create mode 100644 homeassistant/components/p1_monitor/const.py create mode 100644 homeassistant/components/p1_monitor/manifest.json create mode 100644 homeassistant/components/p1_monitor/sensor.py create mode 100644 homeassistant/components/p1_monitor/strings.json create mode 100644 homeassistant/components/p1_monitor/translations/en.json create mode 100644 tests/components/p1_monitor/__init__.py create mode 100644 tests/components/p1_monitor/conftest.py create mode 100644 tests/components/p1_monitor/test_config_flow.py create mode 100644 tests/components/p1_monitor/test_init.py create mode 100644 tests/components/p1_monitor/test_sensor.py create mode 100644 tests/fixtures/p1_monitor/phases.json create mode 100644 tests/fixtures/p1_monitor/settings.json create mode 100644 tests/fixtures/p1_monitor/smartmeter.json diff --git a/CODEOWNERS b/CODEOWNERS index 3606fade468..6c6a248cdc9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -374,6 +374,7 @@ homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu homeassistant/components/ovo_energy/* @timmo001 homeassistant/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare +homeassistant/components/p1_monitor/* @klaasnicolaas homeassistant/components/panel_custom/* @home-assistant/frontend homeassistant/components/panel_iframe/* @home-assistant/frontend homeassistant/components/pcal9535a/* @Shulyaka diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py new file mode 100644 index 00000000000..5d649fa6d63 --- /dev/null +++ b/homeassistant/components/p1_monitor/__init__.py @@ -0,0 +1,91 @@ +"""The P1 Monitor integration.""" +from __future__ import annotations + +from typing import TypedDict + +from p1monitor import P1Monitor, Phases, Settings, SmartMeter + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + DOMAIN, + LOGGER, + SCAN_INTERVAL, + SERVICE_PHASES, + SERVICE_SETTINGS, + SERVICE_SMARTMETER, +) + +PLATFORMS = (SENSOR_DOMAIN,) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up P1 Monitor from a config entry.""" + + coordinator = P1MonitorDataUpdateCoordinator(hass) + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + await coordinator.p1monitor.close() + raise + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload P1 Monitor config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + coordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.p1monitor.close() + return unload_ok + + +class P1MonitorData(TypedDict): + """Class for defining data in dict.""" + + smartmeter: SmartMeter + phases: Phases + settings: Settings + + +class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): + """Class to manage fetching P1 Monitor data from single endpoint.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize global P1 Monitor data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + self.p1monitor = P1Monitor( + self.config_entry.data[CONF_HOST], session=async_get_clientsession(hass) + ) + + async def _async_update_data(self) -> P1MonitorData: + """Fetch data from P1 Monitor.""" + data: P1MonitorData = { + SERVICE_SMARTMETER: await self.p1monitor.smartmeter(), + SERVICE_PHASES: await self.p1monitor.phases(), + SERVICE_SETTINGS: await self.p1monitor.settings(), + } + + return data diff --git a/homeassistant/components/p1_monitor/config_flow.py b/homeassistant/components/p1_monitor/config_flow.py new file mode 100644 index 00000000000..9e9d695f5e9 --- /dev/null +++ b/homeassistant/components/p1_monitor/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for P1 Monitor integration.""" +from __future__ import annotations + +from typing import Any + +from p1monitor import P1Monitor, P1MonitorError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class P1MonitorFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for P1 Monitor.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + + errors = {} + + if user_input is not None: + session = async_get_clientsession(self.hass) + try: + async with P1Monitor( + host=user_input[CONF_HOST], session=session + ) as client: + await client.smartmeter() + except P1MonitorError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_NAME, default=self.hass.config.location_name + ): str, + vol.Required(CONF_HOST): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/p1_monitor/const.py b/homeassistant/components/p1_monitor/const.py new file mode 100644 index 00000000000..1af76d49176 --- /dev/null +++ b/homeassistant/components/p1_monitor/const.py @@ -0,0 +1,23 @@ +"""Constants for the P1 Monitor integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "p1_monitor" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=5) + +ATTR_ENTRY_TYPE: Final = "entry_type" +ENTRY_TYPE_SERVICE: Final = "service" + +SERVICE_SMARTMETER: Final = "smartmeter" +SERVICE_PHASES: Final = "phases" +SERVICE_SETTINGS: Final = "settings" + +SERVICES: dict[str, str] = { + SERVICE_SMARTMETER: "SmartMeter", + SERVICE_PHASES: "Phases", + SERVICE_SETTINGS: "Settings", +} diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json new file mode 100644 index 00000000000..9e61bca3089 --- /dev/null +++ b/homeassistant/components/p1_monitor/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "p1_monitor", + "name": "P1 Monitor", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/p1_monitor", + "requirements": ["p1monitor == 0.2.0"], + "codeowners": ["@klaasnicolaas"], + "quality_scale": "platinum", + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py new file mode 100644 index 00000000000..36a991c7333 --- /dev/null +++ b/homeassistant/components/p1_monitor/sensor.py @@ -0,0 +1,287 @@ +"""Support for P1 Monitor sensors.""" +from __future__ import annotations + +from typing import Literal + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_NAME, + CURRENCY_EURO, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + VOLUME_CUBIC_METERS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import P1MonitorDataUpdateCoordinator +from .const import ( + ATTR_ENTRY_TYPE, + DOMAIN, + ENTRY_TYPE_SERVICE, + SERVICE_PHASES, + SERVICE_SETTINGS, + SERVICE_SMARTMETER, + SERVICES, +) + +SENSORS: dict[ + Literal["smartmeter", "phases", "settings"], tuple[SensorEntityDescription, ...] +] = { + SERVICE_SMARTMETER: ( + SensorEntityDescription( + key="gas_consumption", + name="Gas Consumption", + entity_registry_enabled_default=False, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power_consumption", + name="Power Consumption", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="energy_consumption_high", + name="Energy Consumption - High Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_consumption_low", + name="Energy Consumption - Low Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power_production", + name="Power Production", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="energy_production_high", + name="Energy Production - High Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_production_low", + name="Energy Production - Low Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_tariff_period", + name="Energy Tariff Period", + icon="mdi:calendar-clock", + ), + ), + SERVICE_PHASES: ( + SensorEntityDescription( + key="voltage_phase_l1", + name="Voltage Phase L1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_phase_l2", + name="Voltage Phase L2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_phase_l3", + name="Voltage Phase L3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_phase_l1", + name="Current Phase L1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_phase_l2", + name="Current Phase L2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_phase_l3", + name="Current Phase L3", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_consumed_phase_l1", + name="Power Consumed Phase L1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_consumed_phase_l2", + name="Power Consumed Phase L2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_consumed_phase_l3", + name="Power Consumed Phase L3", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_produced_phase_l1", + name="Power Produced Phase L1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_produced_phase_l2", + name="Power Produced Phase L2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_produced_phase_l3", + name="Power Produced Phase L3", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + SERVICE_SETTINGS: ( + SensorEntityDescription( + key="gas_consumption_tariff", + name="Gas Consumption - Tariff", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_consumption_low_tariff", + name="Energy Consumption - Low Tariff", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_consumption_high_tariff", + name="Energy Consumption - High Tariff", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_production_low_tariff", + name="Energy Production - Low Tariff", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_production_high_tariff", + name="Energy Production - High Tariff", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up P1 Monitor Sensors based on a config entry.""" + async_add_entities( + P1MonitorSensorEntity( + coordinator=hass.data[DOMAIN][entry.entry_id], + description=description, + service_key=service_key, + name=entry.title, + service=SERVICES[service_key], + ) + for service_key, service_sensors in SENSORS.items() + for description in service_sensors + ) + + +class P1MonitorSensorEntity(CoordinatorEntity, SensorEntity): + """Defines an P1 Monitor sensor.""" + + coordinator: P1MonitorDataUpdateCoordinator + + def __init__( + self, + *, + coordinator: P1MonitorDataUpdateCoordinator, + description: SensorEntityDescription, + service_key: Literal["smartmeter", "phases", "settings"], + name: str, + service: str, + ) -> None: + """Initialize P1 Monitor sensor.""" + super().__init__(coordinator=coordinator) + self._service_key = service_key + + self.entity_id = f"{SENSOR_DOMAIN}.{name}_{description.key}" + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{service_key}_{description.key}" + ) + + self._attr_device_info = { + ATTR_IDENTIFIERS: { + (DOMAIN, f"{coordinator.config_entry.entry_id}_{service_key}") + }, + ATTR_NAME: service, + ATTR_MANUFACTURER: "P1 Monitor", + ATTR_ENTRY_TYPE: ENTRY_TYPE_SERVICE, + } + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + value = getattr( + self.coordinator.data[self._service_key], self.entity_description.key + ) + if isinstance(value, str): + return value.lower() + return value diff --git a/homeassistant/components/p1_monitor/strings.json b/homeassistant/components/p1_monitor/strings.json new file mode 100644 index 00000000000..c28a7129006 --- /dev/null +++ b/homeassistant/components/p1_monitor/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up P1 Monitor to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "name": "[%key:common::config_flow::data::name%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/en.json b/homeassistant/components/p1_monitor/translations/en.json new file mode 100644 index 00000000000..34b64082b43 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "Set up P1 Monitor to integrate with Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1ebf71b369f..339bbb1ede3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -198,6 +198,7 @@ FLOWS = [ "ovo_energy", "owntracks", "ozw", + "p1_monitor", "panasonic_viera", "philips_js", "pi_hole", diff --git a/requirements_all.txt b/requirements_all.txt index 244b4e13188..667a1803924 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,6 +1133,9 @@ orvibo==1.1.1 # homeassistant.components.ovo_energy ovoenergy==1.1.12 +# homeassistant.components.p1_monitor +p1monitor == 0.2.0 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6557b0d6ce9..f4b934e5a6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -635,6 +635,9 @@ openerz-api==0.1.0 # homeassistant.components.ovo_energy ovoenergy==1.1.12 +# homeassistant.components.p1_monitor +p1monitor == 0.2.0 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.1 diff --git a/tests/components/p1_monitor/__init__.py b/tests/components/p1_monitor/__init__.py new file mode 100644 index 00000000000..53a063c5f5b --- /dev/null +++ b/tests/components/p1_monitor/__init__.py @@ -0,0 +1 @@ +"""Tests for the P1 Monitor integration.""" diff --git a/tests/components/p1_monitor/conftest.py b/tests/components/p1_monitor/conftest.py new file mode 100644 index 00000000000..dbdf572c6de --- /dev/null +++ b/tests/components/p1_monitor/conftest.py @@ -0,0 +1,59 @@ +"""Fixtures for P1 Monitor integration tests.""" +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from p1monitor import Phases, Settings, SmartMeter +import pytest + +from homeassistant.components.p1_monitor.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="monitor", + domain=DOMAIN, + data={CONF_HOST: "example"}, + unique_id="unique_thingy", + ) + + +@pytest.fixture +def mock_p1monitor(): + """Return a mocked P1 Monitor client.""" + with patch("homeassistant.components.p1_monitor.P1Monitor") as p1monitor_mock: + client = p1monitor_mock.return_value + client.smartmeter = AsyncMock( + return_value=SmartMeter.from_dict( + json.loads(load_fixture("p1_monitor/smartmeter.json")) + ) + ) + client.phases = AsyncMock( + return_value=Phases.from_dict( + json.loads(load_fixture("p1_monitor/phases.json")) + ) + ) + client.settings = AsyncMock( + return_value=Settings.from_dict( + json.loads(load_fixture("p1_monitor/settings.json")) + ) + ) + yield p1monitor_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_p1monitor: MagicMock +) -> MockConfigEntry: + """Set up the P1 Monitor integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/p1_monitor/test_config_flow.py b/tests/components/p1_monitor/test_config_flow.py new file mode 100644 index 00000000000..f6ce5fe5d9d --- /dev/null +++ b/tests/components/p1_monitor/test_config_flow.py @@ -0,0 +1,62 @@ +"""Test the P1 Monitor config flow.""" +from unittest.mock import patch + +from p1monitor import P1MonitorError + +from homeassistant.components.p1_monitor.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + with patch( + "homeassistant.components.p1_monitor.config_flow.P1Monitor.smartmeter" + ) as mock_p1monitor, patch( + "homeassistant.components.p1_monitor.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "Name", + CONF_HOST: "example.com", + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "Name" + assert result2.get("data") == { + CONF_HOST: "example.com", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_p1monitor.mock_calls) == 1 + + +async def test_api_error(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.p1_monitor.P1Monitor.smartmeter", + side_effect=P1MonitorError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_NAME: "Name", + CONF_HOST: "example.com", + }, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} diff --git a/tests/components/p1_monitor/test_init.py b/tests/components/p1_monitor/test_init.py new file mode 100644 index 00000000000..bddaff137e6 --- /dev/null +++ b/tests/components/p1_monitor/test_init.py @@ -0,0 +1,44 @@ +"""Tests for the P1 Monitor integration.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from p1monitor import P1MonitorConnectionError + +from homeassistant.components.p1_monitor.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_p1monitor: AsyncMock +) -> None: + """Test the P1 Monitor configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + + +@patch( + "homeassistant.components.p1_monitor.P1Monitor.request", + side_effect=P1MonitorConnectionError, +) +async def test_config_entry_not_ready( + mock_request: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the P1 Monitor configuration entry not ready.""" + 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_request.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/p1_monitor/test_sensor.py b/tests/components/p1_monitor/test_sensor.py new file mode 100644 index 00000000000..baf73811636 --- /dev/null +++ b/tests/components/p1_monitor/test_sensor.py @@ -0,0 +1,201 @@ +"""Tests for the sensors provided by the P1 Monitor integration.""" +import pytest + +from homeassistant.components.p1_monitor.const import DOMAIN, ENTRY_TYPE_SERVICE +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CURRENCY_EURO, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_smartmeter( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the P1 Monitor - SmartMeter sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.monitor_power_consumption") + entry = entity_registry.async_get("sensor.monitor_power_consumption") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_smartmeter_power_consumption" + assert state.state == "877" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumption" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_energy_consumption_high") + entry = entity_registry.async_get("sensor.monitor_energy_consumption_high") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_smartmeter_energy_consumption_high" + assert state.state == "2770.133" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption - High Tariff" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_energy_tariff_period") + entry = entity_registry.async_get("sensor.monitor_energy_tariff_period") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_smartmeter_energy_tariff_period" + assert state.state == "high" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Tariff Period" + assert state.attributes.get(ATTR_ICON) == "mdi:calendar-clock" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_smartmeter")} + assert device_entry.manufacturer == "P1 Monitor" + assert device_entry.name == "SmartMeter" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +async def test_phases( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the P1 Monitor - Phases sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.monitor_voltage_phase_l1") + entry = entity_registry.async_get("sensor.monitor_voltage_phase_l1") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_phases_voltage_phase_l1" + assert state.state == "233.6" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Voltage Phase L1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_POTENTIAL_VOLT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_VOLTAGE + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_current_phase_l1") + entry = entity_registry.async_get("sensor.monitor_current_phase_l1") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_phases_current_phase_l1" + assert state.state == "1.6" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Current Phase L1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_AMPERE + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_power_consumed_phase_l1") + entry = entity_registry.async_get("sensor.monitor_power_consumed_phase_l1") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_phases_power_consumed_phase_l1" + assert state.state == "315" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumed Phase L1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_phases")} + assert device_entry.manufacturer == "P1 Monitor" + assert device_entry.name == "Phases" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +async def test_settings( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the P1 Monitor - Settings sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.monitor_energy_consumption_low_tariff") + entry = entity_registry.async_get("sensor.monitor_energy_consumption_low_tariff") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_settings_energy_consumption_low_tariff" + assert state.state == "0.20522" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption - Low Tariff" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO + + state = hass.states.get("sensor.monitor_energy_production_low_tariff") + entry = entity_registry.async_get("sensor.monitor_energy_production_low_tariff") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_settings_energy_production_low_tariff" + assert state.state == "0.20522" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production - Low Tariff" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_settings")} + assert device_entry.manufacturer == "P1 Monitor" + assert device_entry.name == "Settings" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +@pytest.mark.parametrize( + "entity_id", + ("sensor.monitor_gas_consumption",), +) +async def test_smartmeter_disabled_by_default( + hass: HomeAssistant, init_integration: MockConfigEntry, entity_id: str +) -> None: + """Test the P1 Monitor - SmartMeter sensors that are disabled by default.""" + entity_registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state is None + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by == er.DISABLED_INTEGRATION diff --git a/tests/fixtures/p1_monitor/phases.json b/tests/fixtures/p1_monitor/phases.json new file mode 100644 index 00000000000..b756f092c05 --- /dev/null +++ b/tests/fixtures/p1_monitor/phases.json @@ -0,0 +1,74 @@ +[ + { + "LABEL": "Huidige KW verbruik L1 (21.7.0)", + "SECURITY": 0, + "STATUS": "0.315", + "STATUS_ID": 74 + }, + { + "LABEL": "Huidige KW verbruik L2 (41.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 75 + }, + { + "LABEL": "Huidige KW verbruik L3 (61.7.0)", + "SECURITY": 0, + "STATUS": "0.624", + "STATUS_ID": 76 + }, + { + "LABEL": "Huidige KW levering L1 (22.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 77 + }, + { + "LABEL": "Huidige KW levering L2 (42.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 78 + }, + { + "LABEL": "Huidige KW levering L3 (62.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 79 + }, + { + "LABEL": "Huidige Amperage L1 (31.7.0)", + "SECURITY": 0, + "STATUS": "1.6", + "STATUS_ID": 100 + }, + { + "LABEL": "Huidige Amperage L2 (51.7.0)", + "SECURITY": 0, + "STATUS": "4.44", + "STATUS_ID": 101 + }, + { + "LABEL": "Huidige Amperage L2 (71.7.0)", + "SECURITY": 0, + "STATUS": "3.51", + "STATUS_ID": 102 + }, + { + "LABEL": "Huidige Voltage L1 (32.7.0)", + "SECURITY": 0, + "STATUS": "233.6", + "STATUS_ID": 103 + }, + { + "LABEL": "Huidige Voltage L2 (52.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 104 + }, + { + "LABEL": "Huidige Voltage L2 (72.7.0)", + "SECURITY": 0, + "STATUS": "233.0", + "STATUS_ID": 105 + } +] \ No newline at end of file diff --git a/tests/fixtures/p1_monitor/settings.json b/tests/fixtures/p1_monitor/settings.json new file mode 100644 index 00000000000..eaa14765566 --- /dev/null +++ b/tests/fixtures/p1_monitor/settings.json @@ -0,0 +1,27 @@ +[ + { + "CONFIGURATION_ID": 1, + "LABEL": "Verbruik tarief elektriciteit dal/nacht in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 2, + "LABEL": "Verbruik tarief elektriciteit piek/dag in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 3, + "LABEL": "Geleverd tarief elektriciteit dal/nacht in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 4, + "LABEL": "Geleverd tarief elektriciteit piek/dag in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 15, + "LABEL": "Verbruik tarief gas in euro.", + "PARAMETER": "0.64" + } +] \ No newline at end of file diff --git a/tests/fixtures/p1_monitor/smartmeter.json b/tests/fixtures/p1_monitor/smartmeter.json new file mode 100644 index 00000000000..d2ca0b38002 --- /dev/null +++ b/tests/fixtures/p1_monitor/smartmeter.json @@ -0,0 +1,15 @@ +[ + { + "CONSUMPTION_GAS_M3": 2273.447, + "CONSUMPTION_KWH_HIGH": 2770.133, + "CONSUMPTION_KWH_LOW": 4988.071, + "CONSUMPTION_W": 877, + "PRODUCTION_KWH_HIGH": 3971.604, + "PRODUCTION_KWH_LOW": 1432.279, + "PRODUCTION_W": 0, + "RECORD_IS_PROCESSED": 0, + "TARIFCODE": "P", + "TIMESTAMP_UTC": 1629134632, + "TIMESTAMP_lOCAL": "2021-08-16 19:23:52" + } +] \ No newline at end of file From 017b8d361543352d5adf3a367fb667428d3980d3 Mon Sep 17 00:00:00 2001 From: Geoffrey Date: Fri, 20 Aug 2021 10:53:48 +0200 Subject: [PATCH 542/903] Add energy management support to Growatt server integration (#54174) Co-authored-by: Chris Straffon --- .../components/growatt_server/sensor.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 671631c5406..9e1fec2e144 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -9,7 +9,11 @@ import re import growattServer -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -88,6 +92,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="totalEnergy", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="total_maximum_output", @@ -114,6 +119,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, precision=1, + state_class=STATE_CLASS_TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="inverter_voltage_input_1", @@ -474,6 +480,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_battery_discharge_today", @@ -488,6 +495,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatDisChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_solar_generation_today", @@ -502,6 +510,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="epvTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_battery_discharge_w", @@ -545,6 +554,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="elocalLoadTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_export_to_grid_today", @@ -559,6 +569,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="etogridTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), # Values from 'mix_system_status' API call GrowattSensorEntityDescription( @@ -675,6 +686,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="etouser_combined", # This id is not present in the raw API data, it is added by the sensor native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), ) @@ -853,7 +865,9 @@ class GrowattData: # Dashboard values have units e.g. "kWh" as part of their returned string, so we remove it dashboard_values_for_mix = { # etouser is already used by the results from 'mix_detail' so we rebrand it as 'etouser_combined' - "etouser_combined": dashboard_data["etouser"].replace("kWh", "") + "etouser_combined": float( + dashboard_data["etouser"].replace("kWh", "") + ) } self.data = { **mix_info, From 3a2afb8bde2b8be06c8e10b224122ff087141d1b Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 20 Aug 2021 05:18:19 -0400 Subject: [PATCH 543/903] Support group entities in zwave_js service calls (#54903) --- homeassistant/components/zwave_js/services.py | 4 +- tests/components/zwave_js/test_services.py | 213 +++++++++++++++++- 2 files changed, 213 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index fa0e93a72aa..a24f8461873 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -17,6 +17,7 @@ from zwave_js_server.util.node import ( async_set_config_parameter, ) +from homeassistant.components.group import expand_entity_ids from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv @@ -95,7 +96,7 @@ class ZWaveServices: def get_nodes_from_service_data(val: dict[str, Any]) -> dict[str, Any]: """Get nodes set from service data.""" nodes: set[ZwaveNode] = set() - for entity_id in val.pop(ATTR_ENTITY_ID, []): + for entity_id in expand_entity_ids(self._hass, val.pop(ATTR_ENTITY_ID, [])): try: nodes.add( async_get_node_from_entity_id( @@ -152,6 +153,7 @@ class ZWaveServices: @callback def validate_entities(val: dict[str, Any]) -> dict[str, Any]: """Validate entities exist and are from the zwave_js platform.""" + val[ATTR_ENTITY_ID] = expand_entity_ids(self._hass, val[ATTR_ENTITY_ID]) for entity_id in val[ATTR_ENTITY_ID]: entry = self._ent_reg.async_get(entity_id) if entry is None or entry.platform != const.DOMAIN: diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 4cc5b599f19..275a2dbb403 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -5,6 +5,7 @@ import pytest import voluptuous as vol from zwave_js_server.exceptions import SetValueFailed +from homeassistant.components.group import Group from homeassistant.components.zwave_js.const import ( ATTR_BROADCAST, ATTR_COMMAND_CLASS, @@ -31,6 +32,7 @@ from homeassistant.helpers.device_registry import ( async_get as async_get_dev_reg, ) from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg +from homeassistant.setup import async_setup_component from .common import ( AEON_SMART_SWITCH_LIGHT_ENTITY, @@ -268,6 +270,52 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): client.async_send_command_no_wait.reset_mock() + # Test groups get expanded + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group(hass, "test", [AIR_TEMPERATURE_SENSOR]) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: "group.test", + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: "0x01", + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 102, + "propertyName": "Group 2: Send battery reports", + "propertyKey": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": True, + "label": "Group 2: Send battery reports", + "description": "Include battery information in periodic reports to Group 2", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command_no_wait.reset_mock() + # Test that we can't include a bitmask value if parameter is a string with pytest.raises(vol.Invalid): await hass.services.async_call( @@ -550,11 +598,43 @@ async def test_bulk_set_config_parameters(hass, client, multisensor_6, integrati client.async_send_command.reset_mock() + # Test groups get expanded + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group(hass, "test", [AIR_TEMPERATURE_SENSOR]) + await hass.services.async_call( + DOMAIN, + SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, + { + ATTR_ENTITY_ID: "group.test", + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_VALUE: { + 1: 1, + 16: 1, + 32: 1, + 64: 1, + 128: 1, + }, + }, + blocking=True, + ) -async def test_poll_value( + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClass": 112, + "property": 102, + } + assert args["value"] == 241 + + client.async_send_command.reset_mock() + + +async def test_refresh_value( hass, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration ): - """Test the poll_value service.""" + """Test the refresh_value service.""" # Test polling the primary value client.async_send_command.return_value = {"result": 2} await hass.services.async_call( @@ -620,6 +700,25 @@ async def test_poll_value( ) assert len(client.async_send_command.call_args_list) == 8 + client.async_send_command.reset_mock() + + # Test groups get expanded + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group(hass, "test", [CLIMATE_RADIO_THERMOSTAT_ENTITY]) + client.async_send_command.return_value = {"result": 2} + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_VALUE, + { + ATTR_ENTITY_ID: "group.test", + ATTR_REFRESH_ALL_VALUES: "true", + }, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 8 + + client.async_send_command.reset_mock() + # Test polling against an invalid entity raises MultipleInvalid with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( @@ -709,6 +808,46 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): client.async_send_command.reset_mock() + # Test groups get expanded + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group(hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY]) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "group.test", + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: "0x2", + ATTR_WAIT_FOR_RESULT: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 5 + assert args["valueId"] == { + "commandClassName": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Local protection state", + "states": {"0": "Unprotected", "2": "NoOperationPossible"}, + }, + "value": 0, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + # Test that when a command fails we raise an exception client.async_send_command.return_value = {"success": False} @@ -878,6 +1017,40 @@ async def test_multicast_set_value( client.async_send_command.reset_mock() + # Test groups get expanded for multicast call + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group( + hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY] + ) + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_ENTITY_ID: "group.test", + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, + ATTR_VALUE: "0x2", + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "multicast_group.set_value" + assert args["nodeIDs"] == [ + climate_eurotronic_spirit_z.node_id, + climate_danfoss_lc_13.node_id, + ] + assert args["valueId"] == { + "commandClass": 67, + "property": "setpoint", + "propertyKey": 1, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + # Test successful broadcast call await hass.services.async_call( DOMAIN, @@ -1070,8 +1243,42 @@ async def test_ping( blocking=True, ) + # assert client.async_send_command.call_args_list is None assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args[0][0] + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.ping" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.ping" + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + + # Test groups get expanded for multicast call + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group( + hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_RADIO_THERMOSTAT_ENTITY] + ) + await hass.services.async_call( + DOMAIN, + SERVICE_PING, + { + ATTR_ENTITY_ID: "group.test", + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.ping" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + args = client.async_send_command.call_args_list[1][0][0] assert args["command"] == "node.ping" assert args["nodeId"] == climate_danfoss_lc_13.node_id From e5f914bbdbd1308e6be5ba69d0c9a33931ead1d5 Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Fri, 20 Aug 2021 12:20:39 +0200 Subject: [PATCH 544/903] Clean up AsusWRT if check (#54896) --- homeassistant/components/asuswrt/config_flow.py | 7 +------ homeassistant/components/asuswrt/sensor.py | 5 ++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index 0ffa674e054..c48ea4d57fe 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -49,12 +49,7 @@ _LOGGER = logging.getLogger(__name__) def _is_file(value) -> bool: """Validate that the value is an existing file.""" file_in = os.path.expanduser(str(value)) - - if not os.path.isfile(file_in): - return False - if not os.access(file_in, os.R_OK): - return False - return True + return os.path.isfile(file_in) and os.access(file_in, os.R_OK) def _get_ip(host): diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index a9a005b9837..287ea3e8938 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -161,7 +161,6 @@ class AsusWrtSensor(CoordinatorEntity, SensorEntity): """Return current state.""" descr = self.entity_description state = self.coordinator.data.get(descr.key) - if state is not None: - if descr.factor and isinstance(state, Number): - return round(state / descr.factor, descr.precision) + if state is not None and descr.factor and isinstance(state, Number): + return round(state / descr.factor, descr.precision) return state From 2ac0aea765906d7e538df0a47b4bbc783849e8fe Mon Sep 17 00:00:00 2001 From: muchtall Date: Fri, 20 Aug 2021 05:50:28 -0500 Subject: [PATCH 545/903] Fix Lyric cool mode (#54856) * fixing Cool mode in lyric * Use climate integration constants I believe this fixes this issue: https://github.com/home-assistant/core/pull/51760#discussion_r650372737 * Run through black * Delint Co-authored-by: Yadu Raghu Co-authored-by: Martin Hjelmare --- homeassistant/components/lyric/climate.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index a13f0381499..955afe140c9 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -190,6 +190,8 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): """Return the temperature we try to reach.""" device = self.device if not device.hasDualSetpointStatus: + if self.hvac_mode == HVAC_MODE_COOL: + return device.changeableValues.coolSetpoint return device.changeableValues.heatSetpoint return None @@ -266,7 +268,14 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): temp = kwargs.get(ATTR_TEMPERATURE) _LOGGER.debug("Set temperature: %s", temp) try: - await self._update_thermostat(self.location, device, heatSetpoint=temp) + if self.hvac_mode == HVAC_MODE_COOL: + await self._update_thermostat( + self.location, device, coolSetpoint=temp + ) + else: + await self._update_thermostat( + self.location, device, heatSetpoint=temp + ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) await self.coordinator.async_refresh() From 6218cd648db45e3d6b20e7640ce9a40feeeaade9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Aug 2021 06:01:55 -0500 Subject: [PATCH 546/903] Update nmap_tracker to use the network integration (#54877) * Update nmap_tracker to use the network integration * fix redefine variable inner scope --- .../components/nmap_tracker/config_flow.py | 41 ++++++++----------- .../components/nmap_tracker/manifest.json | 2 +- requirements_all.txt | 1 - requirements_test_all.txt | 1 - 4 files changed, 18 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index eaea87e775a..a6d7d3ee74e 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -4,16 +4,16 @@ from __future__ import annotations from ipaddress import ip_address, ip_network, summarize_address_range from typing import Any -import ifaddr import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import network from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.network.const import MDNS_TARGET_IP from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from homeassistant.util import get_local_ip from .const import ( CONF_HOME_INTERVAL, @@ -26,24 +26,20 @@ from .const import ( DEFAULT_NETWORK_PREFIX = 24 -def get_network(): +async def async_get_network(hass: HomeAssistant) -> str: """Search adapters for the network.""" - adapters = ifaddr.get_adapters() - local_ip = get_local_ip() - network_prefix = ( - get_ip_prefix_from_adapters(local_ip, adapters) or DEFAULT_NETWORK_PREFIX - ) + # We want the local ip that is most likely to be + # on the LAN and not the WAN so we use MDNS_TARGET_IP + local_ip = await network.async_get_source_ip(hass, MDNS_TARGET_IP) + network_prefix = DEFAULT_NETWORK_PREFIX + for adapter in await network.async_get_adapters(hass): + for ipv4 in adapter["ipv4"]: + if ipv4["address"] == local_ip: + network_prefix = ipv4["network_prefix"] + break return str(ip_network(f"{local_ip}/{network_prefix}", False)) -def get_ip_prefix_from_adapters(local_ip, adapters): - """Find the network prefix for an adapter.""" - for adapter in adapters: - for ip_cfg in adapter.ips: - if local_ip == ip_cfg.ip: - return ip_cfg.network_prefix - - def _normalize_ips_and_network(hosts_str): """Check if a list of hosts are all ips or ip networks.""" @@ -64,19 +60,16 @@ def _normalize_ips_and_network(hosts_str): continue try: - ip_addr = ip_address(host) + normalized_hosts.append(str(ip_address(host))) except ValueError: pass else: - normalized_hosts.append(str(ip_addr)) continue try: - network = ip_network(host) + normalized_hosts.append(str(ip_network(host))) except ValueError: return None - else: - normalized_hosts.append(str(network)) return normalized_hosts @@ -100,9 +93,9 @@ def normalize_input(user_input): async def _async_build_schema_with_user_input(hass, user_input, include_options): - hosts = user_input.get(CONF_HOSTS, await hass.async_add_executor_job(get_network)) + hosts = user_input.get(CONF_HOSTS, await async_get_network(hass)) exclude = user_input.get( - CONF_EXCLUDE, await hass.async_add_executor_job(get_local_ip) + CONF_EXCLUDE, await network.async_get_source_ip(hass, MDNS_TARGET_IP) ) schema = { vol.Required(CONF_HOSTS, default=hosts): str, diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index ee05843c4fe..bbd15834ef2 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -2,10 +2,10 @@ "domain": "nmap_tracker", "name": "Nmap Tracker", "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", + "dependencies": ["network"], "requirements": [ "netmap==0.7.0.2", "getmac==0.8.2", - "ifaddr==0.1.7", "mac-vendor-lookup==0.1.11" ], "codeowners": ["@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index 667a1803924..8ddac2bd76b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -841,7 +841,6 @@ ibmiotf==0.3.4 icmplib==3.0 # homeassistant.components.network -# homeassistant.components.nmap_tracker ifaddr==0.1.7 # homeassistant.components.iglo diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4b934e5a6b..5a03aaca96f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -490,7 +490,6 @@ iaqualink==0.3.90 icmplib==3.0 # homeassistant.components.network -# homeassistant.components.nmap_tracker ifaddr==0.1.7 # homeassistant.components.influxdb From 1f4c12195e55cafc7f8128086f2204d0f00a5e55 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 20 Aug 2021 13:41:36 +0200 Subject: [PATCH 547/903] =?UTF-8?q?Fj=C3=A4r=C3=A5skupan=20kitchen=20fan?= =?UTF-8?q?=20(#53140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add fjäråskupan fan control * Update tests/components/fjaraskupan/conftest.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/fjaraskupan/config_flow.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/fjaraskupan/config_flow.py Co-authored-by: Martin Hjelmare * Increase manual update to 2 minutes * Address review comments * Switch to discovery flow * Address more review comments Co-authored-by: Martin Hjelmare --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/fjaraskupan/__init__.py | 143 +++++++++++++ .../components/fjaraskupan/config_flow.py | 38 ++++ homeassistant/components/fjaraskupan/const.py | 5 + homeassistant/components/fjaraskupan/fan.py | 192 ++++++++++++++++++ .../components/fjaraskupan/manifest.json | 13 ++ .../components/fjaraskupan/strings.json | 13 ++ .../fjaraskupan/translations/en.json | 13 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/fjaraskupan/__init__.py | 1 + tests/components/fjaraskupan/conftest.py | 41 ++++ .../fjaraskupan/test_config_flow.py | 59 ++++++ 15 files changed, 529 insertions(+) create mode 100644 homeassistant/components/fjaraskupan/__init__.py create mode 100644 homeassistant/components/fjaraskupan/config_flow.py create mode 100644 homeassistant/components/fjaraskupan/const.py create mode 100644 homeassistant/components/fjaraskupan/fan.py create mode 100644 homeassistant/components/fjaraskupan/manifest.json create mode 100644 homeassistant/components/fjaraskupan/strings.json create mode 100644 homeassistant/components/fjaraskupan/translations/en.json create mode 100644 tests/components/fjaraskupan/__init__.py create mode 100644 tests/components/fjaraskupan/conftest.py create mode 100644 tests/components/fjaraskupan/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 237e676c9ac..9c27fd4c728 100644 --- a/.coveragerc +++ b/.coveragerc @@ -317,6 +317,9 @@ omit = homeassistant/components/firmata/switch.py homeassistant/components/fitbit/* homeassistant/components/fixer/sensor.py + homeassistant/components/fjaraskupan/__init__.py + homeassistant/components/fjaraskupan/const.py + homeassistant/components/fjaraskupan/fan.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py homeassistant/components/flic/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 6c6a248cdc9..62b5b70648b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -163,6 +163,7 @@ homeassistant/components/filter/* @dgomes homeassistant/components/fireservicerota/* @cyberjunky homeassistant/components/firmata/* @DaAwesomeP homeassistant/components/fixer/* @fabaff +homeassistant/components/fjaraskupan/* @elupus homeassistant/components/flick_electric/* @ZephireNZ homeassistant/components/flipr/* @cnico homeassistant/components/flo/* @dmulcahey diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py new file mode 100644 index 00000000000..598fefe30c8 --- /dev/null +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -0,0 +1,143 @@ +"""The Fjäråskupan integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Callable + +from bleak import BleakScanner +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from fjaraskupan import Device, State, device_filter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DISPATCH_DETECTION, DOMAIN + +PLATFORMS = ["fan"] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DeviceState: + """Store state of a device.""" + + device: Device + coordinator: DataUpdateCoordinator[State] + device_info: DeviceInfo + + +@dataclass +class EntryState: + """Store state of config entry.""" + + scanner: BleakScanner + devices: dict[str, DeviceState] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Fjäråskupan from a config entry.""" + + scanner = BleakScanner() + + state = EntryState(scanner, {}) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = state + + async def detection_callback( + ble_device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + if not device_filter(ble_device, advertisement_data): + return + + _LOGGER.debug( + "Detection: %s %s - %s", ble_device.name, ble_device, advertisement_data + ) + + data = state.devices.get(ble_device.address) + + if data: + data.device.detection_callback(ble_device, advertisement_data) + data.coordinator.async_set_updated_data(data.device.state) + else: + + device = Device(ble_device) + device.detection_callback(ble_device, advertisement_data) + + async def async_update_data(): + """Handle an explicit update request.""" + await device.update() + return device.state + + coordinator: DataUpdateCoordinator[State] = DataUpdateCoordinator( + hass, + logger=_LOGGER, + name="Fjaraskupan Updater", + update_interval=timedelta(seconds=120), + update_method=async_update_data, + ) + coordinator.async_set_updated_data(device.state) + + device_info: DeviceInfo = { + "identifiers": {(DOMAIN, ble_device.address)}, + "manufacturer": "Fjäråskupan", + "name": "Fjäråskupan", + } + device_state = DeviceState(device, coordinator, device_info) + state.devices[ble_device.address] = device_state + async_dispatcher_send( + hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", device_state + ) + + scanner.register_detection_callback(detection_callback) + await scanner.start() + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +@callback +def async_setup_entry_platform( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + constructor: Callable[[DeviceState], list[Entity]], +) -> None: + """Set up a platform with added entities.""" + + entry_state: EntryState = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + entity + for device_state in entry_state.devices.values() + for entity in constructor(device_state) + ) + + @callback + def _detection(device_state: DeviceState) -> None: + async_add_entities(constructor(device_state)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", _detection + ) + ) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + entry_state: EntryState = hass.data[DOMAIN].pop(entry.entry_id) + await entry_state.scanner.stop() + + return unload_ok diff --git a/homeassistant/components/fjaraskupan/config_flow.py b/homeassistant/components/fjaraskupan/config_flow.py new file mode 100644 index 00000000000..9b82ae1199b --- /dev/null +++ b/homeassistant/components/fjaraskupan/config_flow.py @@ -0,0 +1,38 @@ +"""Config flow for Fjäråskupan integration.""" +from __future__ import annotations + +import asyncio + +import async_timeout +from bleak import BleakScanner +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from fjaraskupan import device_filter + +from homeassistant.helpers.config_entry_flow import register_discovery_flow + +from .const import DOMAIN + +CONST_WAIT_TIME = 5.0 + + +async def _async_has_devices(hass) -> bool: + """Return if there are devices that can be discovered.""" + + event = asyncio.Event() + + def detection(device: BLEDevice, advertisement_data: AdvertisementData): + if device_filter(device, advertisement_data): + event.set() + + async with BleakScanner(detection_callback=detection): + try: + async with async_timeout.timeout(CONST_WAIT_TIME): + await event.wait() + except asyncio.TimeoutError: + return False + + return True + + +register_discovery_flow(DOMAIN, "Fjäråskupan", _async_has_devices) diff --git a/homeassistant/components/fjaraskupan/const.py b/homeassistant/components/fjaraskupan/const.py new file mode 100644 index 00000000000..957ac518293 --- /dev/null +++ b/homeassistant/components/fjaraskupan/const.py @@ -0,0 +1,5 @@ +"""Constants for the Fjäråskupan integration.""" + +DOMAIN = "fjaraskupan" + +DISPATCH_DETECTION = f"{DOMAIN}.detection" diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py new file mode 100644 index 00000000000..4a81e70b848 --- /dev/null +++ b/homeassistant/components/fjaraskupan/fan.py @@ -0,0 +1,192 @@ +"""Support for Fjäråskupan fans.""" +from __future__ import annotations + +from fjaraskupan import ( + COMMAND_AFTERCOOKINGTIMERAUTO, + COMMAND_AFTERCOOKINGTIMERMANUAL, + COMMAND_AFTERCOOKINGTIMEROFF, + COMMAND_STOP_FAN, + Device, + State, +) + +from homeassistant.components.fan import ( + SUPPORT_PRESET_MODE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from . import DeviceState, async_setup_entry_platform + +ORDERED_NAMED_FAN_SPEEDS = ["1", "2", "3", "4", "5", "6", "7", "8"] + +PRESET_MODE_NORMAL = "normal" +PRESET_MODE_AFTER_COOKING_MANUAL = "after_cooking_manual" +PRESET_MODE_AFTER_COOKING_AUTO = "after_cooking_auto" +PRESET_MODES = [ + PRESET_MODE_NORMAL, + PRESET_MODE_AFTER_COOKING_AUTO, + PRESET_MODE_AFTER_COOKING_MANUAL, +] + +PRESET_TO_COMMAND = { + PRESET_MODE_AFTER_COOKING_MANUAL: COMMAND_AFTERCOOKINGTIMERMANUAL, + PRESET_MODE_AFTER_COOKING_AUTO: COMMAND_AFTERCOOKINGTIMERAUTO, + PRESET_MODE_NORMAL: COMMAND_AFTERCOOKINGTIMEROFF, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors dynamically through discovery.""" + + def _constructor(device_state: DeviceState): + return [ + Fan(device_state.coordinator, device_state.device, device_state.device_info) + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class Fan(CoordinatorEntity[State], FanEntity): + """Fan entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + ) -> None: + """Init fan entity.""" + super().__init__(coordinator) + self._device = device + self._default_on_speed = 25 + self._attr_name = device_info["name"] + self._attr_unique_id = device.address + self._attr_device_info = device_info + self._percentage = 0 + self._preset_mode = PRESET_MODE_NORMAL + self._update_from_device_data(coordinator.data) + + async def async_set_percentage(self, percentage: int) -> None: + """Set speed.""" + new_speed = percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, percentage + ) + await self._device.send_fan_speed(int(new_speed)) + self.coordinator.async_set_updated_data(self._device.state) + + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn on the fan.""" + + if preset_mode is None: + preset_mode = self._preset_mode + + if percentage is None: + percentage = self._default_on_speed + + new_speed = percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, percentage + ) + + async with self._device: + if preset_mode != self._preset_mode: + await self._device.send_command(PRESET_TO_COMMAND[preset_mode]) + + if preset_mode == PRESET_MODE_NORMAL: + await self._device.send_fan_speed(int(new_speed)) + elif preset_mode == PRESET_MODE_AFTER_COOKING_MANUAL: + await self._device.send_after_cooking(int(new_speed)) + elif preset_mode == PRESET_MODE_AFTER_COOKING_AUTO: + await self._device.send_after_cooking(0) + + self.coordinator.async_set_updated_data(self._device.state) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self._device.send_command(PRESET_TO_COMMAND[preset_mode]) + self.coordinator.async_set_updated_data(self._device.state) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + await self._device.send_command(COMMAND_STOP_FAN) + self.coordinator.async_set_updated_data(self._device.state) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) + + @property + def percentage(self) -> int | None: + """Return the current speed.""" + return self._percentage + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE + + @property + def is_on(self) -> bool: + """Return true if fan is on.""" + return self._percentage != 0 + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes.""" + return PRESET_MODES + + def _update_from_device_data(self, data: State | None) -> None: + """Handle data update.""" + if not data: + self._percentage = 0 + return + + if data.fan_speed: + self._percentage = ordered_list_item_to_percentage( + ORDERED_NAMED_FAN_SPEEDS, str(data.fan_speed) + ) + else: + self._percentage = 0 + + if data.after_cooking_on: + if data.after_cooking_fan_speed: + self._preset_mode = PRESET_MODE_AFTER_COOKING_MANUAL + else: + self._preset_mode = PRESET_MODE_AFTER_COOKING_AUTO + else: + self._preset_mode = PRESET_MODE_NORMAL + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + + self._update_from_device_data(self.coordinator.data) + self.async_write_ha_state() diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json new file mode 100644 index 00000000000..68158776afe --- /dev/null +++ b/homeassistant/components/fjaraskupan/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "fjaraskupan", + "name": "Fj\u00e4r\u00e5skupan", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fjaraskupan", + "requirements": [ + "fjaraskupan==1.0.0" + ], + "codeowners": [ + "@elupus" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/strings.json b/homeassistant/components/fjaraskupan/strings.json new file mode 100644 index 00000000000..c72fc777772 --- /dev/null +++ b/homeassistant/components/fjaraskupan/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "Do you want to set up Fjäråskupan?" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/en.json b/homeassistant/components/fjaraskupan/translations/en.json new file mode 100644 index 00000000000..206d0c9cbdb --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No devices found on the network", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "confirm": { + "description": "Do you want to set up Fjäråskupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 339bbb1ede3..3d43e4cbccb 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -77,6 +77,7 @@ FLOWS = [ "ezviz", "faa_delays", "fireservicerota", + "fjaraskupan", "flick_electric", "flipr", "flo", diff --git a/requirements_all.txt b/requirements_all.txt index 8ddac2bd76b..a1b9869e126 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,6 +625,9 @@ fitbit==0.3.1 # homeassistant.components.fixer fixerio==1.0.0a0 +# homeassistant.components.fjaraskupan +fjaraskupan==1.0.0 + # homeassistant.components.flipr flipr-api==1.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a03aaca96f..cacc3ed3f26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -345,6 +345,9 @@ faadelays==0.0.7 # homeassistant.components.feedreader feedparser==6.0.2 +# homeassistant.components.fjaraskupan +fjaraskupan==1.0.0 + # homeassistant.components.flipr flipr-api==1.4.1 diff --git a/tests/components/fjaraskupan/__init__.py b/tests/components/fjaraskupan/__init__.py new file mode 100644 index 00000000000..26a5ecd6605 --- /dev/null +++ b/tests/components/fjaraskupan/__init__.py @@ -0,0 +1 @@ +"""Tests for the Fjäråskupan integration.""" diff --git a/tests/components/fjaraskupan/conftest.py b/tests/components/fjaraskupan/conftest.py new file mode 100644 index 00000000000..d60abcdb9ad --- /dev/null +++ b/tests/components/fjaraskupan/conftest.py @@ -0,0 +1,41 @@ +"""Standard fixtures for the Fjäråskupan integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData, BaseBleakScanner +from pytest import fixture + + +@fixture(name="scanner", autouse=True) +def fixture_scanner(hass): + """Fixture for scanner.""" + + devices = [BLEDevice("1.1.1.1", "COOKERHOOD_FJAR")] + + class MockScanner(BaseBleakScanner): + """Mock Scanner.""" + + async def start(self): + """Start scanning for devices.""" + for device in devices: + self._callback(device, AdvertisementData()) + + async def stop(self): + """Stop scanning for devices.""" + + @property + def discovered_devices(self) -> list[BLEDevice]: + """Return discovered devices.""" + return devices + + def set_scanning_filter(self, **kwargs): + """Set the scanning filter.""" + + with patch( + "homeassistant.components.fjaraskupan.config_flow.BleakScanner", new=MockScanner + ), patch( + "homeassistant.components.fjaraskupan.config_flow.CONST_WAIT_TIME", new=0.01 + ): + yield devices diff --git a/tests/components/fjaraskupan/test_config_flow.py b/tests/components/fjaraskupan/test_config_flow.py new file mode 100644 index 00000000000..7244042d356 --- /dev/null +++ b/tests/components/fjaraskupan/test_config_flow.py @@ -0,0 +1,59 @@ +"""Test the Fjäråskupan config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +from bleak.backends.device import BLEDevice +from pytest import fixture + +from homeassistant import config_entries, setup +from homeassistant.components.fjaraskupan.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + + +@fixture(name="mock_setup_entry", autouse=True) +async def fixture_mock_setup_entry(hass): + """Fixture for config entry.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.fjaraskupan.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_configure(hass: HomeAssistant, mock_setup_entry) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Fjäråskupan" + assert result["data"] == {} + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_scan_no_devices(hass: HomeAssistant, scanner: list[BLEDevice]) -> None: + """Test we get the form.""" + scanner.clear() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" From 12ad6b8a2bdeb95085128a23d3f5f78d9363e605 Mon Sep 17 00:00:00 2001 From: JasperPlant <78851352+JasperPlant@users.noreply.github.com> Date: Fri, 20 Aug 2021 14:08:28 +0200 Subject: [PATCH 548/903] Add growatt total state_class for storage (#54913) --- homeassistant/components/growatt_server/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 9e1fec2e144..fb271afb68a 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -272,6 +272,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatDisChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="storage_grid_discharge_today", @@ -293,6 +294,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eopDischrTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="storage_grid_charged_today", @@ -307,6 +309,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="storage_solar_production", @@ -362,6 +365,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eToUserTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="storage_load_consumption", From 5e8c873d5f2020f4e202a2a48a415f1ae6e0c4c1 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 20 Aug 2021 08:20:01 -0400 Subject: [PATCH 549/903] Fix image_processing selectors (#54915) --- homeassistant/components/image_processing/services.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/image_processing/services.yaml b/homeassistant/components/image_processing/services.yaml index ed4be6047e0..620bd351806 100644 --- a/homeassistant/components/image_processing/services.yaml +++ b/homeassistant/components/image_processing/services.yaml @@ -4,3 +4,5 @@ scan: name: Scan description: Process an image immediately target: + entity: + domain: image_processing From fe6c8967540d4a26f769b35608fc232841e14399 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 20 Aug 2021 15:12:29 +0200 Subject: [PATCH 550/903] Add `switch` platform for Xiaomi Miio fans (#54834) --- .../components/xiaomi_miio/__init__.py | 2 +- homeassistant/components/xiaomi_miio/const.py | 31 +- homeassistant/components/xiaomi_miio/fan.py | 268 ++---------------- .../components/xiaomi_miio/services.yaml | 110 ------- .../components/xiaomi_miio/switch.py | 95 ++++++- 5 files changed, 123 insertions(+), 383 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 9d854607213..97e52f84dfc 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -46,7 +46,7 @@ _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] SWITCH_PLATFORMS = ["switch"] -FAN_PLATFORMS = ["fan", "select", "sensor"] +FAN_PLATFORMS = ["fan", "select", "sensor", "switch"] HUMIDIFIER_PLATFORMS = [ "binary_sensor", "humidifier", diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 184629fa2fb..af32e8daafa 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -154,19 +154,8 @@ MODELS_ALL_DEVICES = ( MODELS_ALL = MODELS_ALL_DEVICES + MODELS_GATEWAY # Fan/Humidifier Services -SERVICE_SET_BUZZER_ON = "fan_set_buzzer_on" -SERVICE_SET_BUZZER_OFF = "fan_set_buzzer_off" -SERVICE_SET_FAN_LED_ON = "fan_set_led_on" -SERVICE_SET_FAN_LED_OFF = "fan_set_led_off" -SERVICE_SET_FAN_LED = "fan_set_led" -SERVICE_SET_CHILD_LOCK_ON = "fan_set_child_lock_on" -SERVICE_SET_CHILD_LOCK_OFF = "fan_set_child_lock_off" SERVICE_SET_FAVORITE_LEVEL = "fan_set_favorite_level" SERVICE_SET_FAN_LEVEL = "fan_set_fan_level" -SERVICE_SET_AUTO_DETECT_ON = "fan_set_auto_detect_on" -SERVICE_SET_AUTO_DETECT_OFF = "fan_set_auto_detect_off" -SERVICE_SET_LEARN_MODE_ON = "fan_set_learn_mode_on" -SERVICE_SET_LEARN_MODE_OFF = "fan_set_learn_mode_off" SERVICE_SET_VOLUME = "fan_set_volume" SERVICE_RESET_FILTER = "fan_reset_filter" SERVICE_SET_EXTRA_FEATURES = "fan_set_extra_features" @@ -220,7 +209,7 @@ FEATURE_SET_FAN_LEVEL = 4096 FEATURE_SET_MOTOR_SPEED = 8192 FEATURE_SET_CLEAN = 16384 -FEATURE_FLAGS_AIRPURIFIER = ( +FEATURE_FLAGS_AIRPURIFIER_MIIO = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED @@ -230,11 +219,18 @@ FEATURE_FLAGS_AIRPURIFIER = ( | FEATURE_SET_EXTRA_FEATURES ) +FEATURE_FLAGS_AIRPURIFIER_MIOT = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_FAVORITE_LEVEL + | FEATURE_SET_FAN_LEVEL + | FEATURE_SET_LED_BRIGHTNESS +) + FEATURE_FLAGS_AIRPURIFIER_PRO = ( FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_AUTO_DETECT | FEATURE_SET_VOLUME ) @@ -252,13 +248,7 @@ FEATURE_FLAGS_AIRPURIFIER_2S = ( | FEATURE_SET_FAVORITE_LEVEL ) -FEATURE_FLAGS_AIRPURIFIER_3 = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_FAN_LEVEL -) +FEATURE_FLAGS_AIRPURIFIER_V1 = FEATURE_FLAGS_AIRPURIFIER_MIIO | FEATURE_SET_AUTO_DETECT FEATURE_FLAGS_AIRPURIFIER_V3 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED @@ -287,6 +277,7 @@ FEATURE_FLAGS_AIRFRESH = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED + | FEATURE_SET_LED_BRIGHTNESS | FEATURE_RESET_FILTER | FEATURE_SET_EXTRA_FEATURES ) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 87e8fa0ca2a..57a4f004529 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -34,15 +34,17 @@ from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, + FEATURE_FLAGS_AIRFRESH, + FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_MIIO, + FEATURE_FLAGS_AIRPURIFIER_MIOT, + FEATURE_FLAGS_AIRPURIFIER_PRO, + FEATURE_FLAGS_AIRPURIFIER_PRO_V7, + FEATURE_FLAGS_AIRPURIFIER_V3, FEATURE_RESET_FILTER, - FEATURE_SET_AUTO_DETECT, - FEATURE_SET_BUZZER, - FEATURE_SET_CHILD_LOCK, FEATURE_SET_EXTRA_FEATURES, FEATURE_SET_FAN_LEVEL, FEATURE_SET_FAVORITE_LEVEL, - FEATURE_SET_LEARN_MODE, - FEATURE_SET_LED, FEATURE_SET_VOLUME, KEY_COORDINATOR, KEY_DEVICE, @@ -54,19 +56,9 @@ from .const import ( MODELS_FAN, MODELS_PURIFIER_MIOT, SERVICE_RESET_FILTER, - SERVICE_SET_AUTO_DETECT_OFF, - SERVICE_SET_AUTO_DETECT_ON, - SERVICE_SET_BUZZER_OFF, - SERVICE_SET_BUZZER_ON, - SERVICE_SET_CHILD_LOCK_OFF, - SERVICE_SET_CHILD_LOCK_ON, SERVICE_SET_EXTRA_FEATURES, - SERVICE_SET_FAN_LED_OFF, - SERVICE_SET_FAN_LED_ON, SERVICE_SET_FAN_LEVEL, SERVICE_SET_FAVORITE_LEVEL, - SERVICE_SET_LEARN_MODE_OFF, - SERVICE_SET_LEARN_MODE_ON, SERVICE_SET_VOLUME, ) from .device import XiaomiCoordinatedMiioEntity @@ -91,21 +83,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ATTR_MODEL = "model" # Air Purifier -ATTR_FILTER_LIFE = "filter_life_remaining" ATTR_FAVORITE_LEVEL = "favorite_level" -ATTR_BUZZER = "buzzer" -ATTR_CHILD_LOCK = "child_lock" -ATTR_LED = "led" ATTR_BRIGHTNESS = "brightness" ATTR_LEVEL = "level" ATTR_FAN_LEVEL = "fan_level" -ATTR_LEARN_MODE = "learn_mode" ATTR_SLEEP_TIME = "sleep_time" ATTR_SLEEP_LEARN_COUNT = "sleep_mode_learn_count" ATTR_EXTRA_FEATURES = "extra_features" ATTR_FEATURES = "features" ATTR_TURBO_MODE_SUPPORTED = "turbo_mode_supported" -ATTR_AUTO_DETECT = "auto_detect" ATTR_SLEEP_MODE = "sleep_mode" ATTR_VOLUME = "volume" ATTR_USE_TIME = "use_time" @@ -115,9 +101,6 @@ ATTR_BUTTON_PRESSED = "button_pressed" AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { ATTR_MODE: "mode", ATTR_FAVORITE_LEVEL: "favorite_level", - ATTR_CHILD_LOCK: "child_lock", - ATTR_LED: "led", - ATTR_LEARN_MODE: "learn_mode", ATTR_EXTRA_FEATURES: "extra_features", ATTR_TURBO_MODE_SUPPORTED: "turbo_mode_supported", ATTR_BUTTON_PRESSED: "button_pressed", @@ -127,9 +110,7 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", - ATTR_AUTO_DETECT: "auto_detect", ATTR_USE_TIME: "use_time", - ATTR_BUZZER: "buzzer", ATTR_SLEEP_MODE: "sleep_mode", } @@ -137,57 +118,41 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, ATTR_USE_TIME: "use_time", ATTR_VOLUME: "volume", - ATTR_AUTO_DETECT: "auto_detect", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", } +AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT = { + ATTR_MODE: "mode", + ATTR_FAVORITE_LEVEL: "favorite_level", + ATTR_USE_TIME: "use_time", + ATTR_FAN_LEVEL: "fan_level", +} + AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, ATTR_VOLUME: "volume", } -AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S = { - **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, - ATTR_BUZZER: "buzzer", -} - -AVAILABLE_ATTRIBUTES_AIRPURIFIER_3 = { - ATTR_MODE: "mode", - ATTR_FAVORITE_LEVEL: "favorite_level", - ATTR_CHILD_LOCK: "child_lock", - ATTR_LED: "led", - ATTR_USE_TIME: "use_time", - ATTR_BUZZER: "buzzer", - ATTR_FAN_LEVEL: "fan_level", -} - AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { # Common set isn't used here. It's a very basic version of the device. ATTR_MODE: "mode", - ATTR_LED: "led", - ATTR_BUZZER: "buzzer", - ATTR_CHILD_LOCK: "child_lock", ATTR_VOLUME: "volume", - ATTR_LEARN_MODE: "learn_mode", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", ATTR_EXTRA_FEATURES: "extra_features", - ATTR_AUTO_DETECT: "auto_detect", ATTR_USE_TIME: "use_time", ATTR_BUTTON_PRESSED: "button_pressed", } AVAILABLE_ATTRIBUTES_AIRFRESH = { ATTR_MODE: "mode", - ATTR_LED: "led", - ATTR_BUZZER: "buzzer", - ATTR_CHILD_LOCK: "child_lock", ATTR_USE_TIME: "use_time", ATTR_EXTRA_FEATURES: "extra_features", } PRESET_MODES_AIRPURIFIER = ["Auto", "Silent", "Favorite", "Idle"] +PRESET_MODES_AIRPURIFIER_MIOT = ["Auto", "Silent", "Favorite", "Fan"] OPERATION_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] OPERATION_MODES_AIRPURIFIER_PRO_V7 = OPERATION_MODES_AIRPURIFIER_PRO @@ -195,7 +160,6 @@ PRESET_MODES_AIRPURIFIER_PRO_V7 = PRESET_MODES_AIRPURIFIER_PRO OPERATION_MODES_AIRPURIFIER_2S = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_2S = ["Auto", "Silent", "Favorite"] OPERATION_MODES_AIRPURIFIER_3 = ["Auto", "Silent", "Favorite", "Fan"] -PRESET_MODES_AIRPURIFIER_3 = ["Auto", "Silent", "Favorite", "Fan"] OPERATION_MODES_AIRPURIFIER_V3 = [ "Auto", "Silent", @@ -217,58 +181,6 @@ PRESET_MODES_AIRPURIFIER_V3 = [ OPERATION_MODES_AIRFRESH = ["Auto", "Silent", "Interval", "Low", "Middle", "Strong"] PRESET_MODES_AIRFRESH = ["Auto", "Interval"] -FEATURE_FLAGS_AIRPURIFIER = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_LEARN_MODE - | FEATURE_RESET_FILTER - | FEATURE_SET_EXTRA_FEATURES -) - -FEATURE_FLAGS_AIRPURIFIER_PRO = ( - FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_AUTO_DETECT - | FEATURE_SET_VOLUME -) - -FEATURE_FLAGS_AIRPURIFIER_PRO_V7 = ( - FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_VOLUME -) - -FEATURE_FLAGS_AIRPURIFIER_2S = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL -) - -FEATURE_FLAGS_AIRPURIFIER_3 = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_FAN_LEVEL -) - -FEATURE_FLAGS_AIRPURIFIER_V3 = ( - FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED -) - -FEATURE_FLAGS_AIRFRESH = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_RESET_FILTER - | FEATURE_SET_EXTRA_FEATURES -) - AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) SERVICE_SCHEMA_FAVORITE_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend( @@ -288,16 +200,6 @@ SERVICE_SCHEMA_EXTRA_FEATURES = AIRPURIFIER_SERVICE_SCHEMA.extend( ) SERVICE_TO_METHOD = { - SERVICE_SET_BUZZER_ON: {"method": "async_set_buzzer_on"}, - SERVICE_SET_BUZZER_OFF: {"method": "async_set_buzzer_off"}, - SERVICE_SET_FAN_LED_ON: {"method": "async_set_led_on"}, - SERVICE_SET_FAN_LED_OFF: {"method": "async_set_led_off"}, - SERVICE_SET_CHILD_LOCK_ON: {"method": "async_set_child_lock_on"}, - SERVICE_SET_CHILD_LOCK_OFF: {"method": "async_set_child_lock_off"}, - SERVICE_SET_AUTO_DETECT_ON: {"method": "async_set_auto_detect_on"}, - SERVICE_SET_AUTO_DETECT_OFF: {"method": "async_set_auto_detect_off"}, - SERVICE_SET_LEARN_MODE_ON: {"method": "async_set_learn_mode_on"}, - SERVICE_SET_LEARN_MODE_OFF: {"method": "async_set_learn_mode_off"}, SERVICE_RESET_FILTER: {"method": "async_reset_filter"}, SERVICE_SET_FAVORITE_LEVEL: { "method": "async_set_favorite_level", @@ -416,7 +318,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): self._mode = None self._fan_level = None self._state_attrs = {ATTR_MODEL: self._model} - self._device_features = FEATURE_SET_CHILD_LOCK + self._device_features = 0 self._supported_features = 0 self._speed_count = 100 self._preset_modes = [] @@ -528,50 +430,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): self._state = False self.async_write_ha_state() - async def async_set_buzzer_on(self): - """Turn the buzzer on.""" - if self._device_features & FEATURE_SET_BUZZER == 0: - return - - await self._try_command( - "Turning the buzzer of the miio device on failed.", - self._device.set_buzzer, - True, - ) - - async def async_set_buzzer_off(self): - """Turn the buzzer off.""" - if self._device_features & FEATURE_SET_BUZZER == 0: - return - - await self._try_command( - "Turning the buzzer of the miio device off failed.", - self._device.set_buzzer, - False, - ) - - async def async_set_child_lock_on(self): - """Turn the child lock on.""" - if self._device_features & FEATURE_SET_CHILD_LOCK == 0: - return - - await self._try_command( - "Turning the child lock of the miio device on failed.", - self._device.set_child_lock, - True, - ) - - async def async_set_child_lock_off(self): - """Turn the child lock off.""" - if self._device_features & FEATURE_SET_CHILD_LOCK == 0: - return - - await self._try_command( - "Turning the child lock of the miio device off failed.", - self._device.set_child_lock, - False, - ) - class XiaomiAirPurifier(XiaomiGenericDevice): """Representation of a Xiaomi Air Purifier.""" @@ -610,14 +468,14 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._speed_count = 1 elif self._model in [MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H]: self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S - self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON self._preset_modes = PRESET_MODES_AIRPURIFIER_2S self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 elif self._model in MODELS_PURIFIER_MIOT: - self._device_features = FEATURE_FLAGS_AIRPURIFIER_3 - self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_3 - self._preset_modes = PRESET_MODES_AIRPURIFIER_3 + self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIOT + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT + self._preset_modes = PRESET_MODES_AIRPURIFIER_MIOT self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE self._speed_count = 3 elif self._model == MODEL_AIRPURIFIER_V3: @@ -627,7 +485,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 else: - self._device_features = FEATURE_FLAGS_AIRPURIFIER + self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIIO self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER self._preset_modes = PRESET_MODES_AIRPURIFIER self._supported_features = SUPPORT_PRESET_MODE @@ -689,26 +547,6 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self.PRESET_MODE_MAPPING[preset_mode], ) - async def async_set_led_on(self): - """Turn the led on.""" - if self._device_features & FEATURE_SET_LED == 0: - return - - await self._try_command( - "Turning the led of the miio device off failed.", self._device.set_led, True - ) - - async def async_set_led_off(self): - """Turn the led off.""" - if self._device_features & FEATURE_SET_LED == 0: - return - - await self._try_command( - "Turning the led of the miio device off failed.", - self._device.set_led, - False, - ) - async def async_set_favorite_level(self, level: int = 1): """Set the favorite level.""" if self._device_features & FEATURE_SET_FAVORITE_LEVEL == 0: @@ -731,50 +569,6 @@ class XiaomiAirPurifier(XiaomiGenericDevice): level, ) - async def async_set_auto_detect_on(self): - """Turn the auto detect on.""" - if self._device_features & FEATURE_SET_AUTO_DETECT == 0: - return - - await self._try_command( - "Turning the auto detect of the miio device on failed.", - self._device.set_auto_detect, - True, - ) - - async def async_set_auto_detect_off(self): - """Turn the auto detect off.""" - if self._device_features & FEATURE_SET_AUTO_DETECT == 0: - return - - await self._try_command( - "Turning the auto detect of the miio device off failed.", - self._device.set_auto_detect, - False, - ) - - async def async_set_learn_mode_on(self): - """Turn the learn mode on.""" - if self._device_features & FEATURE_SET_LEARN_MODE == 0: - return - - await self._try_command( - "Turning the learn mode of the miio device on failed.", - self._device.set_learn_mode, - True, - ) - - async def async_set_learn_mode_off(self): - """Turn the learn mode off.""" - if self._device_features & FEATURE_SET_LEARN_MODE == 0: - return - - await self._try_command( - "Turning the learn mode of the miio device off failed.", - self._device.set_learn_mode, - False, - ) - async def async_set_volume(self, volume: int = 50): """Set the sound volume.""" if self._device_features & FEATURE_SET_VOLUME == 0: @@ -955,26 +749,6 @@ class XiaomiAirFresh(XiaomiGenericDevice): self._mode = self.PRESET_MODE_MAPPING[preset_mode].value self.async_write_ha_state() - async def async_set_led_on(self): - """Turn the led on.""" - if self._device_features & FEATURE_SET_LED == 0: - return - - await self._try_command( - "Turning the led of the miio device off failed.", self._device.set_led, True - ) - - async def async_set_led_off(self): - """Turn the led off.""" - if self._device_features & FEATURE_SET_LED == 0: - return - - await self._try_command( - "Turning the led of the miio device off failed.", - self._device.set_led, - False, - ) - async def async_set_extra_features(self, features: int = 1): """Set the extra features.""" if self._device_features & FEATURE_SET_EXTRA_FEATURES == 0: diff --git a/homeassistant/components/xiaomi_miio/services.yaml b/homeassistant/components/xiaomi_miio/services.yaml index 43300f8381a..250b0404a41 100644 --- a/homeassistant/components/xiaomi_miio/services.yaml +++ b/homeassistant/components/xiaomi_miio/services.yaml @@ -1,69 +1,3 @@ -fan_set_buzzer_on: - name: Fan set buzzer on - description: Turn the buzzer on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_buzzer_off: - name: Fan set buzzer off - description: Turn the buzzer off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_led_on: - name: Fan set LED on - description: Turn the led on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_led_off: - name: Fan set LED off - description: Turn the led off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_child_lock_on: - name: Fan set child lock on - description: Turn the child lock on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_child_lock_off: - name: Fan set child lock off - description: Turn the child lock off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - fan_set_favorite_level: name: Fan set favorite level description: Set the favorite level. @@ -101,50 +35,6 @@ fan_set_fan_level: min: 1 max: 3 -fan_set_auto_detect_on: - name: Fan set auto detect on - description: Turn the auto detect on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_auto_detect_off: - name: Fan set auto detect off - description: Turn the auto detect off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_learn_mode_on: - name: Fan set learn mode on - description: Turn the learn mode on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_learn_mode_off: - name: Fan set learn mode off - description: Turn the learn mode off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - fan_set_volume: name: Fan set volume description: Set the sound volume. diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index a82d091dee8..4a6471a20b9 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -35,22 +35,42 @@ from .const import ( CONF_GATEWAY, CONF_MODEL, DOMAIN, + FEATURE_FLAGS_AIRFRESH, FEATURE_FLAGS_AIRHUMIDIFIER, FEATURE_FLAGS_AIRHUMIDIFIER_CA4, FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ, + FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_MIIO, + FEATURE_FLAGS_AIRPURIFIER_MIOT, + FEATURE_FLAGS_AIRPURIFIER_PRO, + FEATURE_FLAGS_AIRPURIFIER_PRO_V7, + FEATURE_FLAGS_AIRPURIFIER_V1, + FEATURE_FLAGS_AIRPURIFIER_V3, + FEATURE_SET_AUTO_DETECT, FEATURE_SET_BUZZER, FEATURE_SET_CHILD_LOCK, FEATURE_SET_CLEAN, FEATURE_SET_DRY, + FEATURE_SET_LEARN_MODE, FEATURE_SET_LED, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRFRESH_VA2, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, + MODEL_AIRPURIFIER_2H, + MODEL_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_PRO, + MODEL_AIRPURIFIER_PRO_V7, + MODEL_AIRPURIFIER_V1, + MODEL_AIRPURIFIER_V3, + MODELS_FAN, MODELS_HUMIDIFIER, MODELS_HUMIDIFIER_MJJSQ, + MODELS_PURIFIER_MIIO, + MODELS_PURIFIER_MIOT, SERVICE_SET_POWER_MODE, SERVICE_SET_POWER_PRICE, SERVICE_SET_WIFI_LED_OFF, @@ -98,10 +118,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) +ATTR_AUTO_DETECT = "auto_detect" ATTR_BUZZER = "buzzer" ATTR_CHILD_LOCK = "child_lock" ATTR_CLEAN = "clean_mode" ATTR_DRY = "dry" +ATTR_LEARN_MODE = "learn_mode" ATTR_LED = "led" ATTR_LOAD_POWER = "load_power" ATTR_MODEL = "model" @@ -148,6 +170,19 @@ SERVICE_TO_METHOD = { }, } +MODEL_TO_FEATURES_MAP = { + MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH, + MODEL_AIRHUMIDIFIER_CA1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + MODEL_AIRHUMIDIFIER_CA4: FEATURE_FLAGS_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + MODEL_AIRPURIFIER_2H: FEATURE_FLAGS_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_2S: FEATURE_FLAGS_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_PRO: FEATURE_FLAGS_AIRPURIFIER_PRO, + MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, + MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, + MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, +} + @dataclass class XiaomiMiioSwitchDescription(SwitchEntityDescription): @@ -201,6 +236,21 @@ SWITCH_TYPES = ( method_on="async_set_led_on", method_off="async_set_led_off", ), + XiaomiMiioSwitchDescription( + key=ATTR_LEARN_MODE, + feature=FEATURE_SET_LEARN_MODE, + name="Learn Mode", + icon="mdi:school-outline", + method_on="async_set_learn_mode_on", + method_off="async_set_learn_mode_off", + ), + XiaomiMiioSwitchDescription( + key=ATTR_AUTO_DETECT, + feature=FEATURE_SET_AUTO_DETECT, + name="Auto Detect", + method_on="async_set_auto_detect_on", + method_off="async_set_auto_detect_off", + ), ) @@ -220,7 +270,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the switch from a config entry.""" - if config_entry.data[CONF_MODEL] in MODELS_HUMIDIFIER: + model = config_entry.data[CONF_MODEL] + if model in (*MODELS_HUMIDIFIER, *MODELS_FAN): await async_setup_coordinated_entry(hass, config_entry, async_add_entities) else: await async_setup_other_entry(hass, config_entry, async_add_entities) @@ -239,14 +290,16 @@ async def async_setup_coordinated_entry(hass, config_entry, async_add_entities): device_features = 0 - if model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: - device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB - elif model in [MODEL_AIRHUMIDIFIER_CA4]: - device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA4 + if model in MODEL_TO_FEATURES_MAP: + device_features = MODEL_TO_FEATURES_MAP[model] elif model in MODELS_HUMIDIFIER_MJJSQ: device_features = FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ elif model in MODELS_HUMIDIFIER: device_features = FEATURE_FLAGS_AIRHUMIDIFIER + elif model in MODELS_PURIFIER_MIIO: + device_features = FEATURE_FLAGS_AIRPURIFIER_MIIO + elif model in MODELS_PURIFIER_MIOT: + device_features = FEATURE_FLAGS_AIRPURIFIER_MIOT for description in SWITCH_TYPES: if description.feature & device_features: @@ -519,6 +572,38 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): False, ) + async def async_set_learn_mode_on(self) -> bool: + """Turn the learn mode on.""" + return await self._try_command( + "Turning the learn mode of the miio device on failed.", + self._device.set_learn_mode, + True, + ) + + async def async_set_learn_mode_off(self) -> bool: + """Turn the learn mode off.""" + return await self._try_command( + "Turning the learn mode of the miio device off failed.", + self._device.set_learn_mode, + False, + ) + + async def async_set_auto_detect_on(self) -> bool: + """Turn auto detect on.""" + return await self._try_command( + "Turning auto detect of the miio device on failed.", + self._device.set_auto_detect, + True, + ) + + async def async_set_auto_detect_off(self) -> bool: + """Turn auto detect off.""" + return await self._try_command( + "Turning auto detect of the miio device off failed.", + self._device.set_auto_detect, + False, + ) + class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity): """Representation of a XiaomiGatewaySwitch.""" From e134246cbd946d7537a71e5448c599b31fda8be4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 20 Aug 2021 15:54:05 +0200 Subject: [PATCH 551/903] Improve DSMR shutdown (#54922) --- homeassistant/components/dsmr/sensor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 1b38b2695ec..f19f04072cb 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, VOLUME_CUBIC_METERS, ) -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, StateType @@ -139,7 +139,7 @@ async def async_setup_entry( transport = None protocol = None - while hass.state != CoreState.stopping: + while hass.is_running: # Start DSMR asyncio.Protocol reader try: transport, protocol = await hass.loop.create_task(reader_factory()) @@ -154,7 +154,7 @@ async def async_setup_entry( await protocol.wait_closed() # Unexpected disconnect - if not hass.is_stopping: + if hass.is_running: stop_listener() transport = None @@ -181,7 +181,7 @@ async def async_setup_entry( entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) ) except CancelledError: - if stop_listener: + if stop_listener and hass.is_running: stop_listener() # pylint: disable=not-callable if transport: From 2fa07777cde7f5190825a25be85b67c6ca6e5d5c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 20 Aug 2021 15:54:57 +0200 Subject: [PATCH 552/903] Warn if unit_of_measurement is set on instances of SensorEntityDescription (#54867) * Add class BaseEntityDescription without unit_of_measurement * Refactor according to review comments * Tweak * Fix offending integrations * Fix offending integrations --- homeassistant/components/arlo/sensor.py | 8 +++--- homeassistant/components/goalzero/sensor.py | 24 ++++++++--------- homeassistant/components/sensor/__init__.py | 30 ++++++++++++++++----- homeassistant/components/tplink/sensor.py | 10 +++---- homeassistant/components/wemo/sensor.py | 4 +-- tests/components/sensor/test_init.py | 10 +++++++ 6 files changed, 57 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index cc08cd133e4..57c897cfd59 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -49,7 +49,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="battery_level", name="Battery Level", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_BATTERY, ), SensorEntityDescription( @@ -60,19 +60,19 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", name="Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, ), SensorEntityDescription( key="humidity", name="Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, ), SensorEntityDescription( key="air_quality", name="Air Quality", - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, icon="mdi:biohazard", ), ) diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 8890c7db69c..b422b317601 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -39,14 +39,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="wattsIn", name="Watts In", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="ampsIn", name="Amps In", device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, entity_registry_enabled_default=False, ), @@ -54,14 +54,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="wattsOut", name="Watts Out", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="ampsOut", name="Amps Out", device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, entity_registry_enabled_default=False, ), @@ -69,7 +69,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="whOut", name="WH Out", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_TOTAL_INCREASING, entity_registry_enabled_default=False, ), @@ -77,44 +77,44 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="whStored", name="WH Stored", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="volts", name="Volts", device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, entity_registry_enabled_default=False, ), SensorEntityDescription( key="socPercent", name="State of Charge Percent", device_class=DEVICE_CLASS_BATTERY, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="timeToEmptyFull", name="Time to Empty/Full", device_class=TIME_MINUTES, - unit_of_measurement=TIME_MINUTES, + native_unit_of_measurement=TIME_MINUTES, ), SensorEntityDescription( key="temperature", name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, ), SensorEntityDescription( key="wifiStrength", name="Wifi Strength", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, ), SensorEntityDescription( key="timestamp", name="Total Run Time", - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, entity_registry_enabled_default=False, ), SensorEntityDescription( diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 7551c971582..8063129d7be 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -5,6 +5,7 @@ from collections.abc import Mapping from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta +import inspect import logging from typing import Any, Final, cast, final @@ -128,9 +129,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class SensorEntityDescription(EntityDescription): """A class that describes sensor entities.""" - state_class: str | None = None last_reset: datetime | None = None # Deprecated, to be removed in 2021.11 native_unit_of_measurement: str | None = None + state_class: str | None = None + unit_of_measurement: None = None # Type override, use native_unit_of_measurement + + def __post_init__(self) -> None: + """Post initialisation processing.""" + if self.unit_of_measurement: + caller = inspect.stack()[2] # type: ignore[unreachable] + module = inspect.getmodule(caller[0]) + if "custom_components" in module.__file__: + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + _LOGGER.warning( + "%s is setting 'unit_of_measurement' on an instance of " + "SensorEntityDescription, this is not valid and will be unsupported " + "from Home Assistant 2021.11. Please %s", + module.__name__, + report_issue, + ) + self.native_unit_of_measurement = self.unit_of_measurement class SensorEntity(Entity): @@ -220,11 +243,6 @@ class SensorEntity(Entity): and self._attr_unit_of_measurement is not None ): return self._attr_unit_of_measurement - if ( - hasattr(self, "entity_description") - and self.entity_description.unit_of_measurement is not None - ): - return self.entity_description.unit_of_measurement native_unit_of_measurement = self.native_unit_of_measurement diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index b38fa763ee9..4d2ed5eee30 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -53,35 +53,35 @@ ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" ENERGY_SENSORS: Final[list[SensorEntityDescription]] = [ SensorEntityDescription( key=ATTR_CURRENT_POWER_W, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, name="Current Consumption", ), SensorEntityDescription( key=ATTR_TOTAL_ENERGY_KWH, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, name="Total Consumption", ), SensorEntityDescription( key=ATTR_TODAY_ENERGY_KWH, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, name="Today's Consumption", ), SensorEntityDescription( key=ATTR_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, name="Voltage", ), SensorEntityDescription( key=ATTR_CURRENT_A, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, name="Current", diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index f1f32e8b909..1fd55e4142e 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -95,7 +95,7 @@ class InsightCurrentPower(InsightSensor): name="Current Power", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ) @property @@ -115,7 +115,7 @@ class InsightTodayEnergy(InsightSensor): name="Today Energy", device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ) @property diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 5ff2cad9edc..7463cc6755a 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1,4 +1,5 @@ """The test for sensor device automation.""" +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -49,3 +50,12 @@ async def test_deprecated_last_reset(hass, caplog, enable_custom_integrations): "update your configuration if state_class is manually configured, otherwise " "report it to the custom component author." ) in caplog.text + + +async def test_deprecated_unit_of_measurement(hass, caplog, enable_custom_integrations): + """Test warning on deprecated unit_of_measurement.""" + SensorEntityDescription("catsensor", unit_of_measurement="cats") + assert ( + "tests.components.sensor.test_init is setting 'unit_of_measurement' on an " + "instance of SensorEntityDescription" + ) in caplog.text From 2ddeb0c013b859bc8d17585522570881188ef2d6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 Aug 2021 07:52:30 -0700 Subject: [PATCH 553/903] Ask for host because EAGLE mdns doesn't work in HA OS (#54905) --- .../rainforest_eagle/config_flow.py | 31 ++++++--- .../components/rainforest_eagle/data.py | 65 ++++++++++--------- .../components/rainforest_eagle/sensor.py | 2 + .../components/rainforest_eagle/strings.json | 1 + .../rainforest_eagle/translations/en.json | 1 + tests/components/rainforest_eagle/__init__.py | 63 ------------------ .../rainforest_eagle/test_config_flow.py | 25 +++++-- .../components/rainforest_eagle/test_init.py | 65 +++++++++++++++++++ .../rainforest_eagle/test_sensor.py | 4 +- 9 files changed, 146 insertions(+), 111 deletions(-) create mode 100644 tests/components/rainforest_eagle/test_init.py diff --git a/homeassistant/components/rainforest_eagle/config_flow.py b/homeassistant/components/rainforest_eagle/config_flow.py index acab5fc2070..be921c31bf7 100644 --- a/homeassistant/components/rainforest_eagle/config_flow.py +++ b/homeassistant/components/rainforest_eagle/config_flow.py @@ -7,7 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.data_entry_flow import FlowResult from . import data @@ -15,12 +15,20 @@ from .const import CONF_CLOUD_ID, CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMA _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_CLOUD_ID): str, - vol.Required(CONF_INSTALL_CODE): str, - } -) + +def create_schema(user_input: dict[str, Any] | None) -> vol.Schema: + """Create user schema with passed in defaults if available.""" + if user_input is None: + user_input = {} + return vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str, + vol.Required(CONF_CLOUD_ID, default=user_input.get(CONF_CLOUD_ID)): str, + vol.Required( + CONF_INSTALL_CODE, default=user_input.get(CONF_INSTALL_CODE) + ): str, + } + ) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -34,7 +42,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", data_schema=create_schema(user_input) ) await self.async_set_unique_id(user_input[CONF_CLOUD_ID]) @@ -42,7 +50,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: eagle_type, hardware_address = await data.async_get_type( - self.hass, user_input[CONF_CLOUD_ID], user_input[CONF_INSTALL_CODE] + self.hass, + user_input[CONF_CLOUD_ID], + user_input[CONF_INSTALL_CODE], + user_input[CONF_HOST], ) except data.CannotConnect: errors["base"] = "cannot_connect" @@ -59,7 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", data_schema=create_schema(user_input), errors=errors ) async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index e4cfe144a5e..07835212666 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -11,7 +11,7 @@ from requests.exceptions import ConnectionError as ConnectError, HTTPError, Time from uEagle import Eagle as Eagle100Reader from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client @@ -27,7 +27,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -UPDATE_100_ERRORS = (ConnectError, HTTPError, Timeout, ValueError) +UPDATE_100_ERRORS = (ConnectError, HTTPError, Timeout) class RainforestError(HomeAssistantError): @@ -42,12 +42,37 @@ class InvalidAuth(RainforestError): """Error to indicate bad auth.""" -async def async_get_type(hass, cloud_id, install_code): +async def async_get_type(hass, cloud_id, install_code, host): """Try API call 'get_network_info' to see if target device is Eagle-100 or Eagle-200.""" - reader = Eagle100Reader(cloud_id, install_code) + # For EAGLE-200, fetch the hardware address of the meter too. + hub = aioeagle.EagleHub( + aiohttp_client.async_get_clientsession(hass), cloud_id, install_code, host=host + ) + + try: + with async_timeout.timeout(30): + meters = await hub.get_device_list() + except aioeagle.BadAuth as err: + raise InvalidAuth from err + except aiohttp.ClientError: + # This can happen if it's an eagle-100 + meters = None + + if meters is not None: + if meters: + hardware_address = meters[0].hardware_address + else: + hardware_address = None + + return TYPE_EAGLE_200, hardware_address + + reader = Eagle100Reader(cloud_id, install_code, host) try: response = await hass.async_add_executor_job(reader.get_network_info) + except ValueError as err: + # This could be invalid auth because it doesn't check 401 and tries to read JSON. + raise InvalidAuth from err except UPDATE_100_ERRORS as error: _LOGGER.error("Failed to connect during setup: %s", error) raise CannotConnect from error @@ -59,32 +84,7 @@ async def async_get_type(hass, cloud_id, install_code): ): return TYPE_EAGLE_100, None - # Branch to test if target is not an Eagle-200 Model - if ( - "Response" not in response - or response["Response"].get("Command") != "get_network_info" - ): - # We don't support this - return None, None - - # For EAGLE-200, fetch the hardware address of the meter too. - hub = aioeagle.EagleHub( - aiohttp_client.async_get_clientsession(hass), cloud_id, install_code - ) - - try: - meters = await hub.get_device_list() - except aioeagle.BadAuth as err: - raise InvalidAuth from err - except aiohttp.ClientError as err: - raise CannotConnect from err - - if meters: - hardware_address = meters[0].hardware_address - else: - hardware_address = None - - return TYPE_EAGLE_200, hardware_address + return None, None class EagleDataCoordinator(DataUpdateCoordinator): @@ -133,6 +133,7 @@ class EagleDataCoordinator(DataUpdateCoordinator): aiohttp_client.async_get_clientsession(self.hass), self.cloud_id, self.entry.data[CONF_INSTALL_CODE], + host=self.entry.data[CONF_HOST], ) self.eagle200_meter = aioeagle.ElectricMeter.create_instance( hub, self.hardware_address @@ -158,7 +159,9 @@ class EagleDataCoordinator(DataUpdateCoordinator): """Fetch and return the four sensor values in a dict.""" if self.eagle100_reader is None: self.eagle100_reader = Eagle100Reader( - self.cloud_id, self.entry.data[CONF_INSTALL_CODE] + self.cloud_id, + self.entry.data[CONF_INSTALL_CODE], + self.entry.data[CONF_HOST], ) out = {} diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 67f61ffdc29..e3250dff30d 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -17,6 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONF_HOST, CONF_IP_ADDRESS, DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, @@ -85,6 +86,7 @@ async def async_setup_platform( DOMAIN, context={"source": SOURCE_IMPORT}, data={ + CONF_HOST: config[CONF_IP_ADDRESS], CONF_CLOUD_ID: config[CONF_CLOUD_ID], CONF_INSTALL_CODE: config[CONF_INSTALL_CODE], }, diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index d8e587c98ca..b32f38302f4 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "host": "[%key:common::config_flow::data::host%]", "cloud_id": "Cloud ID", "install_code": "Installation Code" } diff --git a/homeassistant/components/rainforest_eagle/translations/en.json b/homeassistant/components/rainforest_eagle/translations/en.json index 4307fc43a34..633d6551bd0 100644 --- a/homeassistant/components/rainforest_eagle/translations/en.json +++ b/homeassistant/components/rainforest_eagle/translations/en.json @@ -12,6 +12,7 @@ "user": { "data": { "cloud_id": "Cloud ID", + "host": "Host", "install_code": "Installation Code" } } diff --git a/tests/components/rainforest_eagle/__init__.py b/tests/components/rainforest_eagle/__init__.py index c5e41591789..df4f1749d49 100644 --- a/tests/components/rainforest_eagle/__init__.py +++ b/tests/components/rainforest_eagle/__init__.py @@ -1,64 +1 @@ """Tests for the Rainforest Eagle integration.""" -from unittest.mock import patch - -from homeassistant import config_entries, setup -from homeassistant.components.rainforest_eagle.const import ( - CONF_CLOUD_ID, - CONF_HARDWARE_ADDRESS, - CONF_INSTALL_CODE, - DOMAIN, - TYPE_EAGLE_200, -) -from homeassistant.const import CONF_TYPE -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import RESULT_TYPE_ABORT -from homeassistant.setup import async_setup_component - - -async def test_import(hass: HomeAssistant) -> None: - """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - with patch( - "homeassistant.components.rainforest_eagle.data.async_get_type", - return_value=(TYPE_EAGLE_200, "mock-hw"), - ), patch( - "homeassistant.components.rainforest_eagle.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - await async_setup_component( - hass, - "sensor", - { - "sensor": { - "platform": DOMAIN, - "ip_address": "192.168.1.55", - CONF_CLOUD_ID: "abcdef", - CONF_INSTALL_CODE: "123456", - } - }, - ) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - - assert entry.title == "abcdef" - assert entry.data == { - CONF_TYPE: TYPE_EAGLE_200, - CONF_CLOUD_ID: "abcdef", - CONF_INSTALL_CODE: "123456", - CONF_HARDWARE_ADDRESS: "mock-hw", - } - assert len(mock_setup_entry.mock_calls) == 1 - - # Second time we should get already_configured - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - data={CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, - context={"source": config_entries.SOURCE_IMPORT}, - ) - - assert result2["type"] == RESULT_TYPE_ABORT - assert result2["reason"] == "already_configured" diff --git a/tests/components/rainforest_eagle/test_config_flow.py b/tests/components/rainforest_eagle/test_config_flow.py index 0a294875f76..a54fbdac4db 100644 --- a/tests/components/rainforest_eagle/test_config_flow.py +++ b/tests/components/rainforest_eagle/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.rainforest_eagle.const import ( TYPE_EAGLE_200, ) from homeassistant.components.rainforest_eagle.data import CannotConnect, InvalidAuth -from homeassistant.const import CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM @@ -33,7 +33,11 @@ async def test_form(hass: HomeAssistant) -> None: ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, ) await hass.async_block_till_done() @@ -41,6 +45,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["title"] == "abcdef" assert result2["data"] == { CONF_TYPE: TYPE_EAGLE_200, + CONF_HOST: "192.168.1.55", CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456", CONF_HARDWARE_ADDRESS: "mock-hw", @@ -55,12 +60,16 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.rainforest_eagle.data.Eagle100Reader.get_network_info", + "aioeagle.EagleHub.get_device_list", side_effect=InvalidAuth, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, ) assert result2["type"] == RESULT_TYPE_FORM @@ -74,12 +83,16 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.rainforest_eagle.data.Eagle100Reader.get_network_info", + "aioeagle.EagleHub.get_device_list", side_effect=CannotConnect, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, ) assert result2["type"] == RESULT_TYPE_FORM diff --git a/tests/components/rainforest_eagle/test_init.py b/tests/components/rainforest_eagle/test_init.py new file mode 100644 index 00000000000..0c3305732cb --- /dev/null +++ b/tests/components/rainforest_eagle/test_init.py @@ -0,0 +1,65 @@ +"""Tests for the Rainforest Eagle integration.""" +from unittest.mock import patch + +from homeassistant import config_entries, setup +from homeassistant.components.rainforest_eagle.const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_200, +) +from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT +from homeassistant.setup import async_setup_component + + +async def test_import(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.rainforest_eagle.data.async_get_type", + return_value=(TYPE_EAGLE_200, "mock-hw"), + ), patch( + "homeassistant.components.rainforest_eagle.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": DOMAIN, + "ip_address": "192.168.1.55", + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + } + }, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + assert entry.title == "abcdef" + assert entry.data == { + CONF_TYPE: TYPE_EAGLE_200, + CONF_HOST: "192.168.1.55", + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HARDWARE_ADDRESS: "mock-hw", + } + assert len(mock_setup_entry.mock_calls) == 1 + + # Second time we should get already_configured + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + data={CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + context={"source": config_entries.SOURCE_IMPORT}, + ) + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py index 46621eb5fdc..cf7e4a1d011 100644 --- a/tests/components/rainforest_eagle/test_sensor.py +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.rainforest_eagle.const import ( TYPE_EAGLE_100, TYPE_EAGLE_200, ) -from homeassistant.const import CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -58,6 +58,7 @@ async def setup_rainforest_200(hass): domain="rainforest_eagle", data={ CONF_CLOUD_ID: MOCK_CLOUD_ID, + CONF_HOST: "192.168.1.55", CONF_INSTALL_CODE: "abcdefgh", CONF_HARDWARE_ADDRESS: "mock-hw-address", CONF_TYPE: TYPE_EAGLE_200, @@ -79,6 +80,7 @@ async def setup_rainforest_100(hass): domain="rainforest_eagle", data={ CONF_CLOUD_ID: MOCK_CLOUD_ID, + CONF_HOST: "192.168.1.55", CONF_INSTALL_CODE: "abcdefgh", CONF_HARDWARE_ADDRESS: None, CONF_TYPE: TYPE_EAGLE_100, From 725b316ec6aaac8b8a690a5db802d67aa600c190 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Aug 2021 10:02:03 -0500 Subject: [PATCH 554/903] Add HomeKit and DHCP to DISCOVERY_SOURCES in config_entries (#54923) --- homeassistant/config_entries.py | 2 ++ tests/test_config_entries.py | 1 + 2 files changed, 3 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 07cb9eae7f9..b711802386a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -104,6 +104,8 @@ DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" DISCOVERY_SOURCES = ( SOURCE_SSDP, SOURCE_ZEROCONF, + SOURCE_HOMEKIT, + SOURCE_DHCP, SOURCE_DISCOVERY, SOURCE_IMPORT, SOURCE_UNIGNORE, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 7bcc83048a4..391366683d5 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2360,6 +2360,7 @@ async def test_async_setup_update_entry(hass): config_entries.SOURCE_DISCOVERY, config_entries.SOURCE_SSDP, config_entries.SOURCE_HOMEKIT, + config_entries.SOURCE_DHCP, config_entries.SOURCE_ZEROCONF, config_entries.SOURCE_HASSIO, ), From 1e3452496aa68344473fd99050569fe96bba5dd2 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 20 Aug 2021 17:59:10 +0200 Subject: [PATCH 555/903] Make log rollover at startup (#54865) * Secure log rollover at startup. * Review comments. * Please CI. --- homeassistant/bootstrap.py | 12 +++++++----- tests/test_bootstrap.py | 7 +++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 45c04651461..2e3b5522c3f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -332,15 +332,17 @@ def async_enable_logging( not err_path_exists and os.access(err_dir, os.W_OK) ): + err_handler: logging.FileHandler if log_rotate_days: - err_handler: logging.FileHandler = ( - logging.handlers.TimedRotatingFileHandler( - err_log_path, when="midnight", backupCount=log_rotate_days - ) + err_handler = logging.handlers.TimedRotatingFileHandler( + err_log_path, when="midnight", backupCount=log_rotate_days ) else: - err_handler = logging.FileHandler(err_log_path, mode="w", delay=True) + err_handler = logging.handlers.RotatingFileHandler( + err_log_path, backupCount=1 + ) + err_handler.rotate(err_log_path, f"{err_log_path[:-4]}.previous.log") err_handler.setLevel(logging.INFO if verbose else logging.WARNING) err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1fecf7be96b..2bdbab11c32 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -62,6 +62,13 @@ async def test_async_enable_logging(hass): ) as mock_async_activate_log_queue_handler: bootstrap.async_enable_logging(hass) mock_async_activate_log_queue_handler.assert_called_once() + mock_async_activate_log_queue_handler.reset_mock() + bootstrap.async_enable_logging( + hass, + log_rotate_days=5, + log_file="test.log", + ) + mock_async_activate_log_queue_handler.assert_called_once() async def test_load_hassio(hass): From 1f6d18c517ad0dcfff42715114a6c03323450777 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 20 Aug 2021 17:59:31 +0200 Subject: [PATCH 556/903] Set quality level of modbus to gold (#54926) * Prepare for gold. * Upgrade to gold. --- homeassistant/components/modbus/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 549ad2c2351..ceade8c6455 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -4,6 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "requirements": ["pymodbus==2.5.3rc1"], "codeowners": ["@adamchengtkc", "@janiversen", "@vzahradnik"], - "quality_scale": "silver", + "quality_scale": "gold", "iot_class": "local_polling" } From ef9ad89c236b6e1fc1616bcbed1d07701c0bc6b6 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 20 Aug 2021 12:55:17 -0400 Subject: [PATCH 557/903] Add support for area ID in zwave_js service calls (#54940) --- homeassistant/components/zwave_js/helpers.py | 67 ++++-- homeassistant/components/zwave_js/services.py | 62 ++++- tests/components/zwave_js/test_services.py | 225 +++++++++++++++++- 3 files changed, 325 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 593d5ea4151..667d7a9de24 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -13,14 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import ( - DeviceRegistry, - async_get as async_get_dev_reg, -) -from homeassistant.helpers.entity_registry import ( - EntityRegistry, - async_get as async_get_ent_reg, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from .const import ( @@ -79,7 +72,7 @@ def get_home_and_node_id_from_device_id(device_id: tuple[str, ...]) -> list[str] @callback def async_get_node_from_device_id( - hass: HomeAssistant, device_id: str, dev_reg: DeviceRegistry | None = None + hass: HomeAssistant, device_id: str, dev_reg: dr.DeviceRegistry | None = None ) -> ZwaveNode: """ Get node from a device ID. @@ -87,7 +80,7 @@ def async_get_node_from_device_id( Raises ValueError if device is invalid or node can't be found. """ if not dev_reg: - dev_reg = async_get_dev_reg(hass) + dev_reg = dr.async_get(hass) device_entry = dev_reg.async_get(device_id) if not device_entry: @@ -138,8 +131,8 @@ def async_get_node_from_device_id( def async_get_node_from_entity_id( hass: HomeAssistant, entity_id: str, - ent_reg: EntityRegistry | None = None, - dev_reg: DeviceRegistry | None = None, + ent_reg: er.EntityRegistry | None = None, + dev_reg: dr.DeviceRegistry | None = None, ) -> ZwaveNode: """ Get node from an entity ID. @@ -147,7 +140,7 @@ def async_get_node_from_entity_id( Raises ValueError if entity is invalid. """ if not ent_reg: - ent_reg = async_get_ent_reg(hass) + ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(entity_id) if entity_entry is None or entity_entry.platform != DOMAIN: @@ -159,6 +152,46 @@ def async_get_node_from_entity_id( return async_get_node_from_device_id(hass, entity_entry.device_id, dev_reg) +@callback +def async_get_nodes_from_area_id( + hass: HomeAssistant, + area_id: str, + ent_reg: er.EntityRegistry | None = None, + dev_reg: dr.DeviceRegistry | None = None, +) -> set[ZwaveNode]: + """Get nodes for all Z-Wave JS devices and entities that are in an area.""" + nodes: set[ZwaveNode] = set() + if ent_reg is None: + ent_reg = er.async_get(hass) + if dev_reg is None: + dev_reg = dr.async_get(hass) + # Add devices for all entities in an area that are Z-Wave JS entities + nodes.update( + { + async_get_node_from_device_id(hass, entity.device_id, dev_reg) + for entity in er.async_entries_for_area(ent_reg, area_id) + if entity.platform == DOMAIN and entity.device_id is not None + } + ) + # Add devices in an area that are Z-Wave JS devices + for device in dr.async_entries_for_area(dev_reg, area_id): + if next( + ( + config_entry_id + for config_entry_id in device.config_entries + if cast( + ConfigEntry, + hass.config_entries.async_get_entry(config_entry_id), + ).domain + == DOMAIN + ), + None, + ): + nodes.add(async_get_node_from_device_id(hass, device.id, dev_reg)) + + return nodes + + def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveValue: """Get a Z-Wave JS Value from a config.""" endpoint = None @@ -183,14 +216,14 @@ def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveVal def async_get_node_status_sensor_entity_id( hass: HomeAssistant, device_id: str, - ent_reg: EntityRegistry | None = None, - dev_reg: DeviceRegistry | None = None, + ent_reg: er.EntityRegistry | None = None, + dev_reg: dr.DeviceRegistry | None = None, ) -> str: """Get the node status sensor entity ID for a given Z-Wave JS device.""" if not ent_reg: - ent_reg = async_get_ent_reg(hass) + ent_reg = er.async_get(hass) if not dev_reg: - dev_reg = async_get_dev_reg(hass) + dev_reg = dr.async_get(hass) device = dev_reg.async_get(device_id) if not device: raise HomeAssistantError("Invalid Device ID provided") diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index a24f8461873..431f88a875d 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -18,15 +18,18 @@ from zwave_js_server.util.node import ( ) from homeassistant.components.group import expand_entity_ids -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity_registry import EntityRegistry from . import const -from .helpers import async_get_node_from_device_id, async_get_node_from_entity_id +from .helpers import ( + async_get_node_from_device_id, + async_get_node_from_entity_id, + async_get_nodes_from_area_id, +) _LOGGER = logging.getLogger(__name__) @@ -81,7 +84,10 @@ class ZWaveServices: """Class that holds our services (Zwave Commands) that should be published to hass.""" def __init__( - self, hass: HomeAssistant, ent_reg: EntityRegistry, dev_reg: DeviceRegistry + self, + hass: HomeAssistant, + ent_reg: er.EntityRegistry, + dev_reg: dr.DeviceRegistry, ) -> None: """Initialize with hass object.""" self._hass = hass @@ -96,6 +102,7 @@ class ZWaveServices: def get_nodes_from_service_data(val: dict[str, Any]) -> dict[str, Any]: """Get nodes set from service data.""" nodes: set[ZwaveNode] = set() + # Convert all entity IDs to nodes for entity_id in expand_entity_ids(self._hass, val.pop(ATTR_ENTITY_ID, [])): try: nodes.add( @@ -105,6 +112,16 @@ class ZWaveServices: ) except ValueError as err: const.LOGGER.warning(err.args[0]) + + # Convert all area IDs to nodes + for area_id in val.pop(ATTR_AREA_ID, []): + nodes.update( + async_get_nodes_from_area_id( + self._hass, area_id, self._ent_reg, self._dev_reg + ) + ) + + # Convert all device IDs to nodes for device_id in val.pop(ATTR_DEVICE_ID, []): try: nodes.add( @@ -170,6 +187,9 @@ class ZWaveServices: schema=vol.Schema( vol.All( { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(ATTR_DEVICE_ID): vol.All( cv.ensure_list, [cv.string] ), @@ -184,7 +204,9 @@ class ZWaveServices: vol.Coerce(int), BITMASK_SCHEMA, cv.string ), }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), parameter_name_does_not_need_bitmask, get_nodes_from_service_data, ), @@ -198,6 +220,9 @@ class ZWaveServices: schema=vol.Schema( vol.All( { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(ATTR_DEVICE_ID): vol.All( cv.ensure_list, [cv.string] ), @@ -212,7 +237,9 @@ class ZWaveServices: }, ), }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), get_nodes_from_service_data, ), ), @@ -242,6 +269,9 @@ class ZWaveServices: schema=vol.Schema( vol.All( { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(ATTR_DEVICE_ID): vol.All( cv.ensure_list, [cv.string] ), @@ -258,7 +288,9 @@ class ZWaveServices: vol.Optional(const.ATTR_WAIT_FOR_RESULT): cv.boolean, vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), get_nodes_from_service_data, ), ), @@ -271,6 +303,9 @@ class ZWaveServices: schema=vol.Schema( vol.All( { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(ATTR_DEVICE_ID): vol.All( cv.ensure_list, [cv.string] ), @@ -288,7 +323,9 @@ class ZWaveServices: vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, }, vol.Any( - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), broadcast_command, ), get_nodes_from_service_data, @@ -304,12 +341,17 @@ class ZWaveServices: schema=vol.Schema( vol.All( { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(ATTR_DEVICE_ID): vol.All( cv.ensure_list, [cv.string] ), vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), get_nodes_from_service_data, ), ), diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 275a2dbb403..0831d08b216 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -26,7 +26,9 @@ from homeassistant.components.zwave_js.const import ( SERVICE_SET_CONFIG_PARAMETER, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.helpers.area_registry import async_get as async_get_area_reg from homeassistant.helpers.device_registry import ( async_entries_for_config_entry, async_get as async_get_dev_reg, @@ -226,6 +228,52 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): client.async_send_command_no_wait.reset_mock() + # Test using area ID + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + ent_reg.async_update_entity(entity_entry.entity_id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_AREA_ID: area.id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 41, + "propertyName": "Temperature Threshold (Unit)", + "propertyKey": 15, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 3, + "min": 1, + "max": 2, + "default": 1, + "format": 0, + "allowManualEntry": False, + "states": {"1": "Celsius", "2": "Fahrenheit"}, + "label": "Temperature Threshold (Unit)", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 2 + + client.async_send_command_no_wait.reset_mock() + # Test setting parameter by property and bitmask await hass.services.async_call( DOMAIN, @@ -478,6 +526,33 @@ async def test_bulk_set_config_parameters(hass, client, multisensor_6, integrati client.async_send_command_no_wait.reset_mock() + # Test using area ID + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, + { + ATTR_AREA_ID: area.id, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_VALUE: 241, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClass": 112, + "property": 102, + } + assert args["value"] == 241 + + client.async_send_command_no_wait.reset_mock() + await hass.services.async_call( DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, @@ -808,6 +883,47 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): client.async_send_command.reset_mock() + # Test using area ID + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_AREA_ID: area.id, + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: "0x2", + ATTR_WAIT_FOR_RESULT: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 5 + assert args["valueId"] == { + "commandClassName": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Local protection state", + "states": {"0": "Unprotected", "2": "NoOperationPossible"}, + }, + "value": 0, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + # Test groups get expanded assert await async_setup_component(hass, "group", {}) await Group.async_create_group(hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY]) @@ -888,6 +1004,8 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): } assert args["value"] == 2 + client.async_send_command.reset_mock() + # Test missing device and entities keys with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( @@ -1017,6 +1135,49 @@ async def test_multicast_set_value( client.async_send_command.reset_mock() + # Test using area ID + dev_reg = async_get_dev_reg(hass) + device_eurotronic = dev_reg.async_get_device( + {get_device_id(client, climate_eurotronic_spirit_z)} + ) + assert device_eurotronic + device_danfoss = dev_reg.async_get_device( + {get_device_id(client, climate_danfoss_lc_13)} + ) + assert device_danfoss + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device_eurotronic.id, area_id=area.id) + dev_reg.async_update_device(device_danfoss.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_AREA_ID: area.id, + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, + ATTR_VALUE: "0x2", + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "multicast_group.set_value" + assert args["nodeIDs"] == [ + climate_eurotronic_spirit_z.node_id, + climate_danfoss_lc_13.node_id, + ] + assert args["valueId"] == { + "commandClass": 67, + "property": "setpoint", + "propertyKey": 1, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + # Test groups get expanded for multicast call assert await async_setup_component(hass, "group", {}) await Group.async_create_group( @@ -1228,6 +1389,16 @@ async def test_ping( integration, ): """Test ping service.""" + dev_reg = async_get_dev_reg(hass) + device_radio_thermostat = dev_reg.async_get_device( + {get_device_id(client, climate_radio_thermostat_ct100_plus_different_endpoints)} + ) + assert device_radio_thermostat + device_danfoss = dev_reg.async_get_device( + {get_device_id(client, climate_danfoss_lc_13)} + ) + assert device_danfoss + client.async_send_command.return_value = {"responded": True} # Test successful ping call @@ -1243,7 +1414,57 @@ async def test_ping( blocking=True, ) - # assert client.async_send_command.call_args_list is None + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.ping" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.ping" + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + + # Test successful ping call with devices + await hass.services.async_call( + DOMAIN, + SERVICE_PING, + { + ATTR_DEVICE_ID: [ + device_radio_thermostat.id, + device_danfoss.id, + ], + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.ping" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.ping" + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + + # Test successful ping call with area + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device_radio_thermostat.id, area_id=area.id) + dev_reg.async_update_device(device_danfoss.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_PING, + {ATTR_AREA_ID: area.id}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 2 args = client.async_send_command.call_args_list[0][0][0] assert args["command"] == "node.ping" From 09ee7fc021f12a5b4264b3c72a5703e3557e01ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 20 Aug 2021 19:08:22 +0200 Subject: [PATCH 558/903] Enable basic type checking for asuswrt (#54929) --- homeassistant/components/asuswrt/device_tracker.py | 2 +- homeassistant/components/asuswrt/router.py | 10 +++++----- homeassistant/components/asuswrt/sensor.py | 8 ++++---- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 3e954eb25b9..d5d3d9026b5 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -19,7 +19,7 @@ async def async_setup_entry( ) -> None: """Set up device tracker for AsusWrt component.""" router = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] - tracked = set() + tracked: set = set() @callback def update_router(): diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 9d1bcb35c9e..9acea7ba762 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging -from typing import Any +from typing import Any, Callable from aioasuswrt.asuswrt import AsusWrt @@ -209,16 +209,16 @@ class AsusWrtRouter: self._protocol = entry.data[CONF_PROTOCOL] self._host = entry.data[CONF_HOST] self._model = "Asus Router" - self._sw_v = None + self._sw_v: str | None = None self._devices: dict[str, Any] = {} self._connected_devices = 0 self._connect_error = False - self._sensors_data_handler: AsusWrtSensorDataHandler = None + self._sensors_data_handler: AsusWrtSensorDataHandler | None = None self._sensors_coordinator: dict[str, Any] = {} - self._on_close = [] + self._on_close: list[Callable] = [] self._options = { CONF_DNSMASQ: DEFAULT_DNSMASQ, @@ -229,7 +229,7 @@ class AsusWrtRouter: async def setup(self) -> None: """Set up a AsusWrt router.""" - self._api = get_api(self._entry.data, self._options) + self._api = get_api(dict(self._entry.data), self._options) try: await self._api.connection.async_connect() diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 287ea3e8938..5392b419bca 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass import logging -from numbers import Number +from numbers import Real from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -149,7 +149,7 @@ class AsusWrtSensor(CoordinatorEntity, SensorEntity): ) -> None: """Initialize a AsusWrt sensor.""" super().__init__(coordinator) - self.entity_description = description + self.entity_description: AsusWrtSensorEntityDescription = description self._attr_name = f"{DEFAULT_PREFIX} {description.name}" self._attr_unique_id = f"{DOMAIN} {self.name}" @@ -157,10 +157,10 @@ class AsusWrtSensor(CoordinatorEntity, SensorEntity): self._attr_extra_state_attributes = {"hostname": router.host} @property - def native_value(self) -> str | None: + def native_value(self) -> float | str | None: """Return current state.""" descr = self.entity_description state = self.coordinator.data.get(descr.key) - if state is not None and descr.factor and isinstance(state, Number): + if state is not None and descr.factor and isinstance(state, Real): return round(state / descr.factor, descr.precision) return state diff --git a/mypy.ini b/mypy.ini index cb6adf5d62e..cd2bc0f36e6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1277,9 +1277,6 @@ ignore_errors = true [mypy-homeassistant.components.analytics.*] ignore_errors = true -[mypy-homeassistant.components.asuswrt.*] -ignore_errors = true - [mypy-homeassistant.components.atag.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 73081ddfc53..eec262ce8bd 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -18,7 +18,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.aemet.*", "homeassistant.components.almond.*", "homeassistant.components.analytics.*", - "homeassistant.components.asuswrt.*", "homeassistant.components.atag.*", "homeassistant.components.awair.*", "homeassistant.components.azure_event_hub.*", From dbc4470979bd5b7643a8f6750d648462211ee357 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 20 Aug 2021 19:10:19 +0200 Subject: [PATCH 559/903] Enable basic type checking for aemet (#54925) --- .../components/aemet/weather_update_coordinator.py | 6 ++++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 8259baf9984..77f4a593fb0 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -1,4 +1,6 @@ """Weather data coordinator for the AEMET OpenData service.""" +from __future__ import annotations + from dataclasses import dataclass, field from datetime import timedelta import logging @@ -95,7 +97,7 @@ def format_condition(condition: str) -> str: return condition -def format_float(value) -> float: +def format_float(value) -> float | None: """Try converting string to float.""" try: return float(value) @@ -103,7 +105,7 @@ def format_float(value) -> float: return None -def format_int(value) -> int: +def format_int(value) -> int | None: """Try converting string to int.""" try: return int(value) diff --git a/mypy.ini b/mypy.ini index cd2bc0f36e6..954c15725b3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1268,9 +1268,6 @@ warn_unreachable = false [mypy-homeassistant.components.adguard.*] ignore_errors = true -[mypy-homeassistant.components.aemet.*] -ignore_errors = true - [mypy-homeassistant.components.almond.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index eec262ce8bd..cb8be180af2 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -15,7 +15,6 @@ from .model import Config, Integration # Do your best to not add anything new here. IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.adguard.*", - "homeassistant.components.aemet.*", "homeassistant.components.almond.*", "homeassistant.components.analytics.*", "homeassistant.components.atag.*", From 3048923dc21059da2b46e6da56d24e0855ab08bb Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 20 Aug 2021 20:13:58 +0200 Subject: [PATCH 560/903] Remove deprecated async_setup_platforms() for xiaomi_miio (#54930) --- .../components/xiaomi_miio/air_quality.py | 31 +-------------- homeassistant/components/xiaomi_miio/fan.py | 36 +---------------- homeassistant/components/xiaomi_miio/light.py | 29 +------------- .../components/xiaomi_miio/sensor.py | 28 ------------- .../components/xiaomi_miio/switch.py | 39 ------------------- .../components/xiaomi_miio/vacuum.py | 28 +------------ 6 files changed, 5 insertions(+), 186 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 4b56c60cd82..372a1b62e73 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -2,18 +2,14 @@ import logging from miio import AirQualityMonitor, AirQualityMonitorCGDN1, DeviceException -import voluptuous as vol -from homeassistant.components.air_quality import PLATFORM_SCHEMA, AirQualityEntity -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN -import homeassistant.helpers.config_validation as cv +from homeassistant.components.air_quality import AirQualityEntity +from homeassistant.const import CONF_HOST, CONF_TOKEN from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, CONF_MODEL, - DOMAIN, MODEL_AIRQUALITYMONITOR_B1, MODEL_AIRQUALITYMONITOR_CGDN1, MODEL_AIRQUALITYMONITOR_S1, @@ -30,14 +26,6 @@ ATTR_TVOC = "total_volatile_organic_compounds" ATTR_TEMP = "temperature" ATTR_HUM = "humidity" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - PROP_TO_ATTR = { "carbon_dioxide_equivalent": ATTR_CO2E, "total_volatile_organic_compounds": ATTR_TVOC, @@ -249,21 +237,6 @@ DEVICE_MAP = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Air Quality via platform setup is deprecated. " - "Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi Air Quality from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 57a4f004529..c3e3bce289a 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -10,19 +10,11 @@ from miio.airpurifier_miot import OperationMode as AirpurifierMiotOperationMode import voluptuous as vol from homeassistant.components.fan import ( - PLATFORM_SCHEMA, SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_MODE, - CONF_HOST, - CONF_NAME, - CONF_TOKEN, -) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.percentage import ( @@ -53,7 +45,6 @@ from .const import ( MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V3, - MODELS_FAN, MODELS_PURIFIER_MIOT, SERVICE_RESET_FILTER, SERVICE_SET_EXTRA_FEATURES, @@ -70,16 +61,6 @@ DATA_KEY = "fan.xiaomi_miio" CONF_MODEL = "model" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MODEL): vol.In(MODELS_FAN), - } -) - ATTR_MODEL = "model" # Air Purifier @@ -217,21 +198,6 @@ SERVICE_TO_METHOD = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Fan via platform setup is deprecated. " - "Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Fan from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 6025ae047c6..b916de899b9 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -19,14 +19,12 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, LightEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_TOKEN import homeassistant.helpers.config_validation as cv from homeassistant.util import color, dt @@ -37,7 +35,6 @@ from .const import ( CONF_MODEL, DOMAIN, KEY_COORDINATOR, - MODELS_LIGHT, MODELS_LIGHT_BULB, MODELS_LIGHT_CEILING, MODELS_LIGHT_EYECARE, @@ -60,15 +57,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Philips Light" DATA_KEY = "light.xiaomi_miio" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MODEL): vol.In(MODELS_LIGHT), - } -) - # The light does not accept cct values < 1 CCT_MIN = 1 CCT_MAX = 100 @@ -120,21 +108,6 @@ SERVICE_TO_METHOD = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Light via platform setup is deprecated. " - "Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi light from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index a8a2787aaed..a505c23498c 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -14,23 +14,19 @@ from miio.gateway.gateway import ( GATEWAY_MODEL_EU, GatewayException, ) -import voluptuous as vol from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, CONF_HOST, - CONF_NAME, CONF_TOKEN, DEVICE_CLASS_CO2, DEVICE_CLASS_GAS, @@ -47,7 +43,6 @@ from homeassistant.const import ( TIME_HOURS, VOLUME_CUBIC_METERS, ) -import homeassistant.helpers.config_validation as cv from .const import ( CONF_DEVICE, @@ -78,14 +73,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Miio Sensor" UNIT_LUMEN = "lm" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - ATTR_ACTUAL_SPEED = "actual_speed" ATTR_AIR_QUALITY = "air_quality" ATTR_AQI = "aqi" @@ -326,21 +313,6 @@ MODEL_TO_SENSORS_MAP = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Sensor via platform setup is deprecated. " - "Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi sensor from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 4a6471a20b9..84503664498 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -13,17 +13,14 @@ import voluptuous as vol from homeassistant.components.switch import ( DEVICE_CLASS_SWITCH, - PLATFORM_SCHEMA, SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, ATTR_TEMPERATURE, CONF_HOST, - CONF_NAME, CONF_TOKEN, ) from homeassistant.core import callback @@ -95,28 +92,6 @@ GATEWAY_SWITCH_VARS = { "status_ch2": {KEY_CHANNEL: 2}, } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MODEL): vol.In( - [ - "chuangmi.plug.v1", - "qmi.powerstrip.v1", - "zimi.powerstrip.v2", - "chuangmi.plug.m1", - "chuangmi.plug.m3", - "chuangmi.plug.v2", - "chuangmi.plug.v3", - "chuangmi.plug.hmi205", - "chuangmi.plug.hmi206", - "chuangmi.plug.hmi208", - "lumi.acpartner.v3", - ] - ), - } -) ATTR_AUTO_DETECT = "auto_detect" ATTR_BUZZER = "buzzer" @@ -254,20 +229,6 @@ SWITCH_TYPES = ( ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Switch via platform setup is deprecated; Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """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 cdd53e784b3..94d93d77f2f 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -7,7 +7,6 @@ import voluptuous as vol from homeassistant.components.vacuum import ( ATTR_CLEANED_AREA, - PLATFORM_SCHEMA, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, @@ -26,15 +25,13 @@ from homeassistant.components.vacuum import ( SUPPORT_STOP, StateVacuumEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON +from homeassistant.const import CONF_HOST, CONF_TOKEN, STATE_OFF, STATE_ON from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.util.dt import as_utc from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, - DOMAIN, SERVICE_CLEAN_SEGMENT, SERVICE_CLEAN_ZONE, SERVICE_GOTO, @@ -49,15 +46,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Vacuum cleaner" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - }, - extra=vol.ALLOW_EXTRA, -) - ATTR_CLEAN_START = "clean_start" ATTR_CLEAN_STOP = "clean_stop" ATTR_CLEANING_TIME = "cleaning_time" @@ -119,20 +107,6 @@ STATE_CODE_TO_STATE = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Vacuum via platform setup is deprecated; Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi vacuum cleaner robot from a config entry.""" entities = [] From dd8542e01f3fd62f074565339b8bfb0852a52626 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 20 Aug 2021 20:18:30 +0200 Subject: [PATCH 561/903] =?UTF-8?q?Add=20fj=C3=A4r=C3=A5skupan=20binary=5F?= =?UTF-8?q?sensor=20(#54920)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add fjäråskupan binary_sensor * Switch to entity description * Type check constructor --- .coveragerc | 1 + .../components/fjaraskupan/__init__.py | 2 +- .../components/fjaraskupan/binary_sensor.py | 95 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/fjaraskupan/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 9c27fd4c728..a3666ff0ac4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -318,6 +318,7 @@ omit = homeassistant/components/fitbit/* homeassistant/components/fixer/sensor.py homeassistant/components/fjaraskupan/__init__.py + homeassistant/components/fjaraskupan/binary_sensor.py homeassistant/components/fjaraskupan/const.py homeassistant/components/fjaraskupan/fan.py homeassistant/components/fleetgo/device_tracker.py diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 598fefe30c8..50c07a96c51 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DISPATCH_DETECTION, DOMAIN -PLATFORMS = ["fan"] +PLATFORMS = ["binary_sensor", "fan"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py new file mode 100644 index 00000000000..2484a0d9bc2 --- /dev/null +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -0,0 +1,95 @@ +"""Support for sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from fjaraskupan import Device, State + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_PROBLEM, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeviceState, async_setup_entry_platform + + +@dataclass +class EntityDescription(BinarySensorEntityDescription): + """Entity description.""" + + is_on: Callable = lambda _: False + + +SENSORS = ( + EntityDescription( + key="grease-filter", + name="Grease Filter", + device_class=DEVICE_CLASS_PROBLEM, + is_on=lambda state: state.grease_filter_full, + ), + EntityDescription( + key="carbon-filter", + name="Carbon Filter", + device_class=DEVICE_CLASS_PROBLEM, + is_on=lambda state: state.carbon_filter_full, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors dynamically through discovery.""" + + def _constructor(device_state: DeviceState) -> list[Entity]: + return [ + BinarySensor( + device_state.coordinator, + device_state.device, + device_state.device_info, + entity_description, + ) + for entity_description in SENSORS + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class BinarySensor(CoordinatorEntity[State], BinarySensorEntity): + """Grease filter sensor.""" + + entity_description: EntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + entity_description: EntityDescription, + ) -> None: + """Init sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + + self._attr_unique_id = f"{device.address}-{entity_description.key}" + self._attr_device_info = device_info + self._attr_name = f"{device_info['name']} {entity_description.name}" + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if data := self.coordinator.data: + return self.entity_description.is_on(data) + return None From 11c6a3359464a88cbdf158ecdbd91b7a4918cfbe Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 20 Aug 2021 14:55:58 -0400 Subject: [PATCH 562/903] Bump zwave-js-server-python to 0.29.0 (#54931) * Bump zwave-js-server-python to 0.29.0 * Cover secure flag true paths for add node WS commands --- homeassistant/components/zwave_js/api.py | 18 ++-- .../components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 89 ++++++++++++++++++- .../zwave_js/test_device_condition.py | 2 +- 6 files changed, 105 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index a55ae47b935..6cd0ea4fe44 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -10,7 +10,7 @@ from aiohttp import hdrs, web, web_exceptions, web_request import voluptuous as vol from zwave_js_server import dump from zwave_js_server.client import Client -from zwave_js_server.const import CommandClass, LogLevel +from zwave_js_server.const import CommandClass, InclusionStrategy, LogLevel from zwave_js_server.exceptions import ( BaseZwaveJSServerError, FailedCommand, @@ -386,7 +386,11 @@ async def websocket_add_node( ) -> None: """Add a node to the Z-Wave network.""" controller = client.driver.controller - include_non_secure = not msg[SECURE] + + if msg[SECURE]: + inclusion_strategy = InclusionStrategy.SECURITY_S0 + else: + inclusion_strategy = InclusionStrategy.INSECURE @callback def async_cleanup() -> None: @@ -454,7 +458,7 @@ async def websocket_add_node( ), ] - result = await controller.async_begin_inclusion(include_non_secure) + result = await controller.async_begin_inclusion(inclusion_strategy) connection.send_result( msg[ID], result, @@ -594,9 +598,13 @@ async def websocket_replace_failed_node( ) -> None: """Replace a failed node with a new node.""" controller = client.driver.controller - include_non_secure = not msg[SECURE] node_id = msg[NODE_ID] + if msg[SECURE]: + inclusion_strategy = InclusionStrategy.SECURITY_S0 + else: + inclusion_strategy = InclusionStrategy.INSECURE + @callback def async_cleanup() -> None: """Remove signal listeners.""" @@ -677,7 +685,7 @@ async def websocket_replace_failed_node( ), ] - result = await controller.async_replace_failed_node(node_id, include_non_secure) + result = await controller.async_replace_failed_node(node_id, inclusion_strategy) connection.send_result( msg[ID], result, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index b24bc957303..6f713ed2ef2 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.28.0"], + "requirements": ["zwave-js-server-python==0.29.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["http", "websocket_api"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index a1b9869e126..76891354250 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2482,4 +2482,4 @@ zigpy==0.36.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.28.0 +zwave-js-server-python==0.29.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cacc3ed3f26..50dbf55d42f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1390,4 +1390,4 @@ zigpy-znp==0.5.3 zigpy==0.36.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.28.0 +zwave-js-server-python==0.29.0 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 75fca7f11ff..ee05724a9cb 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3,7 +3,7 @@ import json from unittest.mock import patch import pytest -from zwave_js_server.const import LogLevel +from zwave_js_server.const import InclusionStrategy, LogLevel from zwave_js_server.event import Event from zwave_js_server.exceptions import ( FailedCommand, @@ -29,6 +29,7 @@ from homeassistant.components.zwave_js.api import ( OPTED_IN, PROPERTY, PROPERTY_KEY, + SECURE, TYPE, VALUE, ) @@ -318,6 +319,31 @@ async def test_ping_node( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_add_node_secure( + hass, nortek_thermostat_added_event, integration, client, hass_ws_client +): + """Test the add_node websocket command with secure flag.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {"success": True} + + await ws_client.send_json( + {ID: 1, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, SECURE: True} + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.begin_inclusion", + "options": {"inclusionStrategy": InclusionStrategy.SECURITY_S0}, + } + + client.async_send_command.reset_mock() + + async def test_add_node( hass, nortek_thermostat_added_event, integration, client, hass_ws_client ): @@ -334,6 +360,12 @@ async def test_add_node( msg = await ws_client.receive_json() assert msg["success"] + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.begin_inclusion", + "options": {"inclusionStrategy": InclusionStrategy.INSECURE}, + } + event = Event( type="inclusion started", data={ @@ -599,6 +631,52 @@ async def test_remove_node( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_replace_failed_node_secure( + hass, + nortek_thermostat, + integration, + client, + hass_ws_client, +): + """Test the replace_failed_node websocket command with secure flag.""" + entry = integration + ws_client = await hass_ws_client(hass) + + dev_reg = dr.async_get(hass) + + # Create device registry entry for mock node + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "3245146787-67")}, + name="Node 67", + ) + + client.async_send_command.return_value = {"success": True} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/replace_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + SECURE: True, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.replace_failed_node", + "nodeId": nortek_thermostat.node_id, + "options": {"inclusionStrategy": InclusionStrategy.SECURITY_S0}, + } + + client.async_send_command.reset_mock() + + async def test_replace_failed_node( hass, nortek_thermostat, @@ -638,6 +716,15 @@ async def test_replace_failed_node( assert msg["success"] assert msg["result"] + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.replace_failed_node", + "nodeId": nortek_thermostat.node_id, + "options": {"inclusionStrategy": InclusionStrategy.INSECURE}, + } + + client.async_send_command.reset_mock() + event = Event( type="inclusion started", data={ diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 0256981a726..dd5507d4c0a 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -437,7 +437,7 @@ async def test_get_condition_capabilities_value( (98, "DOOR_LOCK"), (122, "FIRMWARE_UPDATE_MD"), (114, "MANUFACTURER_SPECIFIC"), - (113, "ALARM"), + (113, "NOTIFICATION"), (152, "SECURITY"), (99, "USER_CODE"), (134, "VERSION"), From dc74a52f580520d57ba164c1d0e2e9aca4ceb978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Aug 2021 14:04:18 -0500 Subject: [PATCH 563/903] Add support for USB discovery (#54904) Co-authored-by: Martin Hjelmare --- CODEOWNERS | 1 + .../components/default_config/manifest.json | 1 + homeassistant/components/usb/__init__.py | 138 +++++++++ homeassistant/components/usb/const.py | 3 + homeassistant/components/usb/flow.py | 48 +++ homeassistant/components/usb/manifest.json | 12 + homeassistant/components/usb/models.py | 16 + homeassistant/components/usb/utils.py | 18 ++ homeassistant/config_entries.py | 10 + homeassistant/generated/usb.py | 8 + homeassistant/loader.py | 21 ++ homeassistant/package_constraints.txt | 2 + requirements_all.txt | 4 + requirements_test_all.txt | 4 + script/hassfest/__main__.py | 2 + script/hassfest/config_flow.py | 1 + script/hassfest/manifest.py | 8 + script/hassfest/usb.py | 64 ++++ tests/components/usb/__init__.py | 29 ++ tests/components/usb/test_init.py | 273 ++++++++++++++++++ tests/test_config_entries.py | 1 + tests/test_loader.py | 54 ++++ 22 files changed, 718 insertions(+) create mode 100644 homeassistant/components/usb/__init__.py create mode 100644 homeassistant/components/usb/const.py create mode 100644 homeassistant/components/usb/flow.py create mode 100644 homeassistant/components/usb/manifest.json create mode 100644 homeassistant/components/usb/models.py create mode 100644 homeassistant/components/usb/utils.py create mode 100644 homeassistant/generated/usb.py create mode 100644 script/hassfest/usb.py create mode 100644 tests/components/usb/__init__.py create mode 100644 tests/components/usb/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 62b5b70648b..0b3d7500a21 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -549,6 +549,7 @@ homeassistant/components/upcloud/* @scop homeassistant/components/updater/* @home-assistant/core homeassistant/components/upnp/* @StevenLooman @ehendrix23 homeassistant/components/uptimerobot/* @ludeeus +homeassistant/components/usb/* @bdraco homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes homeassistant/components/velbus/* @Cereal2nd @brefra diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 834438f5a9f..274e53c2f38 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -29,6 +29,7 @@ "system_health", "tag", "timer", + "usb", "updater", "webhook", "zeroconf", diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py new file mode 100644 index 00000000000..ff8bb5fae88 --- /dev/null +++ b/homeassistant/components/usb/__init__.py @@ -0,0 +1,138 @@ +"""The USB Discovery integration.""" +from __future__ import annotations + +import dataclasses +import datetime +import logging +import sys + +from serial.tools.list_ports import comports +from serial.tools.list_ports_common import ListPortInfo + +from homeassistant import config_entries +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_usb + +from .flow import FlowDispatcher, USBFlow +from .models import USBDevice +from .utils import usb_device_from_port + +_LOGGER = logging.getLogger(__name__) + +# Perodic scanning only happens on non-linux systems +SCAN_INTERVAL = datetime.timedelta(minutes=60) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the USB Discovery integration.""" + usb = await async_get_usb(hass) + usb_discovery = USBDiscovery(hass, FlowDispatcher(hass), usb) + await usb_discovery.async_setup() + return True + + +class USBDiscovery: + """Manage USB Discovery.""" + + def __init__( + self, + hass: HomeAssistant, + flow_dispatcher: FlowDispatcher, + usb: list[dict[str, str]], + ) -> None: + """Init USB Discovery.""" + self.hass = hass + self.flow_dispatcher = flow_dispatcher + self.usb = usb + self.seen: set[tuple[str, ...]] = set() + + async def async_setup(self) -> None: + """Set up USB Discovery.""" + if not await self._async_start_monitor(): + await self._async_start_scanner() + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) + + async def async_start(self, event: Event) -> None: + """Start USB Discovery and run a manual scan.""" + self.flow_dispatcher.async_start() + await self.hass.async_add_executor_job(self.scan_serial) + + async def _async_start_scanner(self) -> None: + """Perodic scan with pyserial when the observer is not available.""" + stop_track = async_track_time_interval( + self.hass, lambda now: self.scan_serial(), SCAN_INTERVAL + ) + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, callback(lambda event: stop_track()) + ) + + async def _async_start_monitor(self) -> bool: + """Start monitoring hardware with pyudev.""" + if not sys.platform.startswith("linux"): + return False + from pyudev import ( # pylint: disable=import-outside-toplevel + Context, + Monitor, + MonitorObserver, + ) + + try: + context = Context() + except ImportError: + return False + + monitor = Monitor.from_netlink(context) + monitor.filter_by(subsystem="tty") + observer = MonitorObserver( + monitor, callback=self._device_discovered, name="usb-observer" + ) + observer.start() + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda event: observer.stop() + ) + return True + + def _device_discovered(self, device): + """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.scan_serial() + + @callback + def _async_process_discovered_usb_device(self, device: USBDevice) -> None: + """Process a USB discovery.""" + _LOGGER.debug("Discovered USB Device: %s", device) + device_tuple = dataclasses.astuple(device) + if device_tuple in self.seen: + return + self.seen.add(device_tuple) + for matcher in self.usb: + if "vid" in matcher and device.vid != matcher["vid"]: + continue + if "pid" in matcher and device.pid != matcher["pid"]: + continue + flow: USBFlow = { + "domain": matcher["domain"], + "context": {"source": config_entries.SOURCE_USB}, + "data": dataclasses.asdict(device), + } + self.flow_dispatcher.async_create(flow) + + @callback + def _async_process_ports(self, ports: list[ListPortInfo]) -> None: + """Process each discovered port.""" + for port in ports: + if port.vid is None and port.pid is None: + continue + self._async_process_discovered_usb_device(usb_device_from_port(port)) + + def scan_serial(self) -> None: + """Scan serial ports.""" + self.hass.add_job(self._async_process_ports, comports()) diff --git a/homeassistant/components/usb/const.py b/homeassistant/components/usb/const.py new file mode 100644 index 00000000000..c31178bc323 --- /dev/null +++ b/homeassistant/components/usb/const.py @@ -0,0 +1,3 @@ +"""Constants for the USB Discovery integration.""" + +DOMAIN = "usb" diff --git a/homeassistant/components/usb/flow.py b/homeassistant/components/usb/flow.py new file mode 100644 index 00000000000..00c40add92a --- /dev/null +++ b/homeassistant/components/usb/flow.py @@ -0,0 +1,48 @@ +"""The USB Discovery integration.""" +from __future__ import annotations + +from collections.abc import Coroutine +from typing import Any, TypedDict + +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult + + +class USBFlow(TypedDict): + """A queued usb discovery flow.""" + + domain: str + context: dict[str, Any] + data: dict + + +class FlowDispatcher: + """Dispatch discovery flows.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Init the discovery dispatcher.""" + self.hass = hass + self.pending_flows: list[USBFlow] = [] + self.started = False + + @callback + def async_start(self, *_: Any) -> None: + """Start processing pending flows.""" + self.started = True + for flow in self.pending_flows: + self.hass.async_create_task(self._init_flow(flow)) + self.pending_flows = [] + + @callback + def async_create(self, flow: USBFlow) -> None: + """Create and add or queue a flow.""" + if self.started: + self.hass.async_create_task(self._init_flow(flow)) + else: + self.pending_flows.append(flow) + + def _init_flow(self, flow: USBFlow) -> Coroutine[None, None, FlowResult]: + """Create a flow.""" + return self.hass.config_entries.flow.async_init( + flow["domain"], context=flow["context"], data=flow["data"] + ) diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json new file mode 100644 index 00000000000..274b9593f06 --- /dev/null +++ b/homeassistant/components/usb/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "usb", + "name": "USB Discovery", + "documentation": "https://www.home-assistant.io/integrations/usb", + "requirements": [ + "pyudev==0.22.0", + "pyserial==3.5" + ], + "codeowners": ["@bdraco"], + "quality_scale": "internal", + "iot_class": "local_push" +} \ No newline at end of file diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py new file mode 100644 index 00000000000..bdc8bc71ced --- /dev/null +++ b/homeassistant/components/usb/models.py @@ -0,0 +1,16 @@ +"""Models helper class for the usb integration.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class USBDevice: + """A usb device.""" + + device: str + vid: str + pid: str + serial_number: str | None + manufacturer: str | None + description: str | None diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py new file mode 100644 index 00000000000..d6bd96882b2 --- /dev/null +++ b/homeassistant/components/usb/utils.py @@ -0,0 +1,18 @@ +"""The USB Discovery integration.""" +from __future__ import annotations + +from serial.tools.list_ports_common import ListPortInfo + +from .models import USBDevice + + +def usb_device_from_port(port: ListPortInfo) -> USBDevice: + """Convert serial ListPortInfo to USBDevice.""" + return USBDevice( + device=port.device, + vid=f"{hex(port.vid)[2:]:0>4}".upper(), + pid=f"{hex(port.pid)[2:]:0>4}".upper(), + serial_number=port.serial_number, + manufacturer=port.manufacturer, + description=port.description, + ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b711802386a..67c718a497d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -40,6 +40,7 @@ SOURCE_IMPORT = "import" SOURCE_INTEGRATION_DISCOVERY = "integration_discovery" SOURCE_MQTT = "mqtt" SOURCE_SSDP = "ssdp" +SOURCE_USB = "usb" SOURCE_USER = "user" SOURCE_ZEROCONF = "zeroconf" SOURCE_DHCP = "dhcp" @@ -103,6 +104,9 @@ DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id" DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" DISCOVERY_SOURCES = ( SOURCE_SSDP, + SOURCE_USB, + SOURCE_DHCP, + SOURCE_HOMEKIT, SOURCE_ZEROCONF, SOURCE_HOMEKIT, SOURCE_DHCP, @@ -1372,6 +1376,12 @@ class ConfigFlow(data_entry_flow.FlowHandler): """Handle a flow initialized by DHCP discovery.""" return await self.async_step_discovery(discovery_info) + async def async_step_usb( + self, discovery_info: DiscoveryInfoType + ) -> data_entry_flow.FlowResult: + """Handle a flow initialized by USB discovery.""" + return await self.async_step_discovery(discovery_info) + @callback def async_create_entry( # pylint: disable=arguments-differ self, diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py new file mode 100644 index 00000000000..d72cbc8c7a5 --- /dev/null +++ b/homeassistant/generated/usb.py @@ -0,0 +1,8 @@ +"""Automatically generated by hassfest. + +To update, run python3 -m script.hassfest +""" + +# fmt: off + +USB = [] # type: ignore diff --git a/homeassistant/loader.py b/homeassistant/loader.py index a535db4bde2..57244d9ec7b 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -26,6 +26,7 @@ from awesomeversion import ( from homeassistant.generated.dhcp import DHCP from homeassistant.generated.mqtt import MQTT from homeassistant.generated.ssdp import SSDP +from homeassistant.generated.usb import USB from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF from homeassistant.util.async_ import gather_with_concurrency @@ -81,6 +82,7 @@ class Manifest(TypedDict, total=False): ssdp: list[dict[str, str]] zeroconf: list[str | dict[str, str]] dhcp: list[dict[str, str]] + usb: list[dict[str, str]] homekit: dict[str, list[str]] is_built_in: bool version: str @@ -219,6 +221,20 @@ async def async_get_dhcp(hass: HomeAssistant) -> list[dict[str, str]]: return dhcp +async def async_get_usb(hass: HomeAssistant) -> list[dict[str, str]]: + """Return cached list of usb types.""" + usb: list[dict[str, str]] = USB.copy() + + integrations = await async_get_custom_components(hass) + for integration in integrations.values(): + if not integration.usb: + continue + for entry in integration.usb: + usb.append({"domain": integration.domain, **entry}) + + return usb + + async def async_get_homekit(hass: HomeAssistant) -> dict[str, str]: """Return cached list of homekit models.""" @@ -423,6 +439,11 @@ class Integration: """Return Integration dhcp entries.""" return self.manifest.get("dhcp") + @property + def usb(self) -> list[dict[str, str]] | None: + """Return Integration usb entries.""" + return self.manifest.get("usb") + @property def homekit(self) -> dict[str, list[str]] | None: """Return Integration homekit entries.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7e4e57f608f..99e6fa77bbf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,9 @@ jinja2==3.0.1 paho-mqtt==1.5.1 pillow==8.2.0 pip>=8.0.3,<20.3 +pyserial==3.5 python-slugify==4.0.1 +pyudev==0.22.0 pyyaml==5.4.1 requests==2.25.1 ruamel.yaml==0.15.100 diff --git a/requirements_all.txt b/requirements_all.txt index 76891354250..cf746042737 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1755,6 +1755,7 @@ pysensibo==1.0.3 pyserial-asyncio==0.5 # homeassistant.components.acer_projector +# homeassistant.components.usb # homeassistant.components.zha pyserial==3.5 @@ -1957,6 +1958,9 @@ pytradfri[async]==7.0.6 # homeassistant.components.trafikverket_weatherstation pytrafikverket==0.1.6.2 +# homeassistant.components.usb +pyudev==0.22.0 + # homeassistant.components.uptimerobot pyuptimerobot==21.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50dbf55d42f..d803cb63fa2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1011,6 +1011,7 @@ pyruckus==0.12 pyserial-asyncio==0.5 # homeassistant.components.acer_projector +# homeassistant.components.usb # homeassistant.components.zha pyserial==3.5 @@ -1095,6 +1096,9 @@ pytraccar==0.9.0 # homeassistant.components.tradfri pytradfri[async]==7.0.6 +# homeassistant.components.usb +pyudev==0.22.0 + # homeassistant.components.uptimerobot pyuptimerobot==21.8.2 diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index f9a1aa54c69..1bec328702e 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -18,6 +18,7 @@ from . import ( services, ssdp, translations, + usb, zeroconf, ) from .model import Config, Integration @@ -34,6 +35,7 @@ INTEGRATION_PLUGINS = [ translations, zeroconf, dhcp, + usb, ] HASS_PLUGINS = [ coverage, diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 8e0f53fd736..87e9bea6291 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -41,6 +41,7 @@ def validate_integration(config: Config, integration: Integration): or "async_step_ssdp" in config_flow or "async_step_zeroconf" in config_flow or "async_step_dhcp" in config_flow + or "async_step_usb" in config_flow ) if not needs_unique_id: diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 797729542f4..acb2a999fe3 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -205,6 +205,14 @@ MANIFEST_SCHEMA = vol.Schema( } ) ], + vol.Optional("usb"): [ + vol.Schema( + { + vol.Optional("vid"): vol.All(str, verify_uppercase), + vol.Optional("pid"): vol.All(str, verify_uppercase), + } + ) + ], vol.Required("documentation"): vol.All( vol.Url(), documentation_url # pylint: disable=no-value-for-parameter ), diff --git a/script/hassfest/usb.py b/script/hassfest/usb.py new file mode 100644 index 00000000000..927b87def98 --- /dev/null +++ b/script/hassfest/usb.py @@ -0,0 +1,64 @@ +"""Generate usb file.""" +from __future__ import annotations + +import json + +from .model import Config, Integration + +BASE = """ +\"\"\"Automatically generated by hassfest. + +To update, run python3 -m script.hassfest +\"\"\" + +# fmt: off + +USB = {} # type: ignore +""".strip() + + +def generate_and_validate(integrations: list[dict[str, str]]) -> str: + """Validate and generate usb data.""" + match_list = [] + + for domain in sorted(integrations): + integration = integrations[domain] + + if not integration.manifest or not integration.config_flow: + continue + + match_types = integration.manifest.get("usb", []) + + if not match_types: + continue + + for entry in match_types: + match_list.append({"domain": domain, **entry}) + + return BASE.format(json.dumps(match_list, indent=4)) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate usb file.""" + usb_path = config.root / "homeassistant/generated/usb.py" + config.cache["usb"] = content = generate_and_validate(integrations) + + if config.specific_integrations: + return + + with open(str(usb_path)) as fp: + current = fp.read().strip() + if current != content: + config.add_error( + "usb", + "File usb.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) + return + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate usb file.""" + usb_path = config.root / "homeassistant/generated/usb.py" + with open(str(usb_path), "w") as fp: + fp.write(f"{config.cache['usb']}\n") diff --git a/tests/components/usb/__init__.py b/tests/components/usb/__init__.py new file mode 100644 index 00000000000..7dbfdfdcff6 --- /dev/null +++ b/tests/components/usb/__init__.py @@ -0,0 +1,29 @@ +"""Tests for the USB Discovery integration.""" + + +from homeassistant.components.usb.models import USBDevice + +conbee_device = USBDevice( + device="/dev/cu.usbmodemDE24338801", + vid="1CF1", + pid="0030", + serial_number="DE2433880", + manufacturer="dresden elektronik ingenieurtechnik GmbH", + description="ConBee II", +) +slae_sh_device = USBDevice( + device="/dev/cu.usbserial-110", + vid="10C4", + pid="EA60", + serial_number="00_12_4B_00_22_98_88_7F", + manufacturer="Silicon Labs", + description="slae.sh cc2652rb stick - slaesh's iot stuff", +) +electro_lama_device = USBDevice( + device="/dev/cu.usbserial-110", + vid="1A86", + pid="7523", + serial_number=None, + manufacturer=None, + description="USB2.0-Serial", +) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py new file mode 100644 index 00000000000..cb547edc939 --- /dev/null +++ b/tests/components/usb/test_init.py @@ -0,0 +1,273 @@ +"""Tests for the USB Discovery integration.""" +import datetime +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from . import slae_sh_device + +from tests.common import async_fire_time_changed + + +@pytest.mark.skipif( + not sys.platform.startswith("linux"), + reason="Only works on linux", +) +async def test_discovered_by_observer_before_started(hass): + """Test a device is discovered by the observer before started.""" + + 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() + + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + mock_comports = [ + 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( + "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 + ): + 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) == 1 + 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_removal_by_observer_before_started(hass): + """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)) + return MagicMock() + + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + mock_comports = [ + 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( + "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": {}}) + await hass.async_block_till_done() + + with patch("homeassistant.components.usb.comports", return_value=[]): + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_scanner_after_started(hass): + """Test a device is discovered by the scanner after the started event.""" + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + mock_comports = [ + 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("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: + 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() + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_scanner_after_started_match_vid_only(hass): + """Test a device is discovered by the scanner after the started event only matching vid.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + + mock_comports = [ + 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("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: + 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() + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_scanner_after_started_match_vid_wrong_pid(hass): + """Test a device is discovered by the scanner after the started event only matching vid but wrong pid.""" + new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] + + mock_comports = [ + 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("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: + 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() + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_scanner_after_started_no_vid_pid(hass): + """Test a device is discovered by the scanner after the started event with no vid or pid.""" + new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=None, + pid=None, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + 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: + 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() + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_non_matching_discovered_by_scanner_after_started(hass): + """Test a device is discovered by the scanner after the started event that does not match.""" + new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] + + mock_comports = [ + 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("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: + 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() + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 391366683d5..4c002ad8228 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2359,6 +2359,7 @@ async def test_async_setup_update_entry(hass): ( config_entries.SOURCE_DISCOVERY, config_entries.SOURCE_SSDP, + config_entries.SOURCE_USB, config_entries.SOURCE_HOMEKIT, config_entries.SOURCE_DHCP, config_entries.SOURCE_ZEROCONF, diff --git a/tests/test_loader.py b/tests/test_loader.py index 20dcf90d90e..2c5eb91d0fb 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -192,6 +192,12 @@ def test_integration_properties(hass): {"hostname": "tesla_*", "macaddress": "044EAF*"}, {"hostname": "tesla_*", "macaddress": "98ED5C*"}, ], + "usb": [ + {"vid": "10C4", "pid": "EA60"}, + {"vid": "1CF1", "pid": "0030"}, + {"vid": "1A86", "pid": "7523"}, + {"vid": "10C4", "pid": "8A2A"}, + ], "ssdp": [ { "manufacturer": "Royal Philips Electronics", @@ -216,6 +222,12 @@ def test_integration_properties(hass): {"hostname": "tesla_*", "macaddress": "044EAF*"}, {"hostname": "tesla_*", "macaddress": "98ED5C*"}, ] + assert integration.usb == [ + {"vid": "10C4", "pid": "EA60"}, + {"vid": "1CF1", "pid": "0030"}, + {"vid": "1A86", "pid": "7523"}, + {"vid": "10C4", "pid": "8A2A"}, + ] assert integration.ssdp == [ { "manufacturer": "Royal Philips Electronics", @@ -248,6 +260,7 @@ def test_integration_properties(hass): assert integration.homekit is None assert integration.zeroconf is None assert integration.dhcp is None + assert integration.usb is None assert integration.ssdp is None assert integration.mqtt is None assert integration.version is None @@ -268,6 +281,7 @@ def test_integration_properties(hass): assert integration.homekit is None assert integration.zeroconf == [{"type": "_hue._tcp.local.", "name": "hue*"}] assert integration.dhcp is None + assert integration.usb is None assert integration.ssdp is None @@ -342,6 +356,28 @@ def _get_test_integration_with_dhcp_matcher(hass, name, config_flow): ) +def _get_test_integration_with_usb_matcher(hass, name, config_flow): + """Return a generated test integration with a usb matcher.""" + return loader.Integration( + hass, + f"homeassistant.components.{name}", + None, + { + "name": name, + "domain": name, + "config_flow": config_flow, + "dependencies": [], + "requirements": [], + "usb": [ + {"vid": "10C4", "pid": "EA60"}, + {"vid": "1CF1", "pid": "0030"}, + {"vid": "1A86", "pid": "7523"}, + {"vid": "10C4", "pid": "8A2A"}, + ], + }, + ) + + async def test_get_custom_components(hass, enable_custom_integrations): """Verify that custom components are cached.""" test_1_integration = _get_test_integration(hass, "test_1", False) @@ -411,6 +447,24 @@ async def test_get_dhcp(hass): ] +async def test_get_usb(hass): + """Verify that custom components with usb matchers are found.""" + test_1_integration = _get_test_integration_with_usb_matcher(hass, "test_1", True) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test_1": test_1_integration, + } + usb = await loader.async_get_usb(hass) + usb_for_domain = [entry for entry in usb if entry["domain"] == "test_1"] + assert usb_for_domain == [ + {"domain": "test_1", "vid": "10C4", "pid": "EA60"}, + {"domain": "test_1", "vid": "1CF1", "pid": "0030"}, + {"domain": "test_1", "vid": "1A86", "pid": "7523"}, + {"domain": "test_1", "vid": "10C4", "pid": "8A2A"}, + ] + + async def test_get_homekit(hass): """Verify that custom components with homekit are found.""" test_1_integration = _get_test_integration(hass, "test_1", True) From 63f6a3b46b0fba9d2365749c6d715050cb277e07 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 20 Aug 2021 15:21:55 -0400 Subject: [PATCH 564/903] Add zwave_js.value_updated automation trigger (#54897) * Add zwave_js automation trigger * Rename to align with zwave-js api * Improve test coverage * Add additional template variables * Support states values in addition to keys when present * remove entity ID from trigger payload * comments and order * Add init and dynamically define platform_type * reduce mypy ignores * pylint * pylint * review * use module map --- homeassistant/components/zwave_js/const.py | 7 + homeassistant/components/zwave_js/trigger.py | 54 ++++ .../components/zwave_js/triggers/__init__.py | 1 + .../zwave_js/triggers/value_updated.py | 193 ++++++++++++ tests/components/zwave_js/test_trigger.py | 276 ++++++++++++++++++ 5 files changed, 531 insertions(+) create mode 100644 homeassistant/components/zwave_js/trigger.py create mode 100644 homeassistant/components/zwave_js/triggers/__init__.py create mode 100644 homeassistant/components/zwave_js/triggers/value_updated.py create mode 100644 tests/components/zwave_js/test_trigger.py diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 7848af146b5..4a311012690 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -48,6 +48,13 @@ ATTR_OPTIONS = "options" ATTR_NODE = "node" ATTR_ZWAVE_VALUE = "zwave_value" +# automation trigger attributes +ATTR_PREVIOUS_VALUE = "previous_value" +ATTR_PREVIOUS_VALUE_RAW = "previous_value_raw" +ATTR_CURRENT_VALUE = "current_value" +ATTR_CURRENT_VALUE_RAW = "current_value_raw" +ATTR_DESCRIPTION = "description" + # service constants SERVICE_SET_VALUE = "set_value" SERVICE_RESET_METER = "reset_meter" diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py new file mode 100644 index 00000000000..69e770e3817 --- /dev/null +++ b/homeassistant/components/zwave_js/trigger.py @@ -0,0 +1,54 @@ +"""Z-Wave JS trigger dispatcher.""" +from __future__ import annotations + +from types import ModuleType +from typing import Any, Callable, cast + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .triggers import value_updated + +TRIGGERS = { + "value_updated": value_updated, +} + + +def _get_trigger_platform(config: ConfigType) -> ModuleType: + """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 Z-Wave JS trigger platform {config[CONF_PLATFORM]}") + return TRIGGERS[platform_split[1]] + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + platform = _get_trigger_platform(config) + if hasattr(platform, "async_validate_trigger_config"): + return cast( + ConfigType, + await getattr(platform, "async_validate_trigger_config")(hass, config), + ) + assert hasattr(platform, "TRIGGER_SCHEMA") + return cast(ConfigType, getattr(platform, "TRIGGER_SCHEMA")(config)) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: Callable, + automation_info: dict[str, Any], +) -> Callable: + """Attach trigger of specified platform.""" + platform = _get_trigger_platform(config) + assert hasattr(platform, "async_attach_trigger") + return cast( + Callable, + await getattr(platform, "async_attach_trigger")( + hass, config, action, automation_info + ), + ) diff --git a/homeassistant/components/zwave_js/triggers/__init__.py b/homeassistant/components/zwave_js/triggers/__init__.py new file mode 100644 index 00000000000..7c4f867d465 --- /dev/null +++ b/homeassistant/components/zwave_js/triggers/__init__.py @@ -0,0 +1 @@ +"""Z-Wave JS triggers.""" diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py new file mode 100644 index 00000000000..a2dbb84cf3b --- /dev/null +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -0,0 +1,193 @@ +"""Offer Z-Wave JS value updated listening automation rules.""" +from __future__ import annotations + +import functools +import logging +from typing import Any, Callable + +import voluptuous as vol +from zwave_js_server.const import CommandClass +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node +from zwave_js_server.model.value import Value, get_value_id + +from homeassistant.components.zwave_js.const import ( + ATTR_COMMAND_CLASS, + ATTR_COMMAND_CLASS_NAME, + ATTR_CURRENT_VALUE, + ATTR_CURRENT_VALUE_RAW, + ATTR_ENDPOINT, + ATTR_NODE_ID, + ATTR_PREVIOUS_VALUE, + ATTR_PREVIOUS_VALUE_RAW, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_PROPERTY_KEY_NAME, + ATTR_PROPERTY_NAME, + DOMAIN, +) +from homeassistant.components.zwave_js.helpers import ( + async_get_node_from_device_id, + async_get_node_from_entity_id, + get_device_id, +) +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM, MATCH_ALL +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +# Platform type should be . +PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" + +ATTR_FROM = "from" +ATTR_TO = "to" + +VALUE_SCHEMA = vol.Any( + bool, + vol.Coerce(int), + vol.Coerce(float), + cv.boolean, + cv.string, +) + +TRIGGER_SCHEMA = vol.All( + cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): PLATFORM_TYPE, + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_COMMAND_CLASS): vol.In( + {cc.value: cc.name for cc in CommandClass} + ), + vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_FROM, default=MATCH_ALL): vol.Any( + VALUE_SCHEMA, [VALUE_SCHEMA] + ), + vol.Optional(ATTR_TO, default=MATCH_ALL): vol.Any( + VALUE_SCHEMA, [VALUE_SCHEMA] + ), + }, + ), + cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), +) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: Callable, + automation_info: dict[str, Any], + *, + platform_type: str = PLATFORM_TYPE, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + nodes: set[Node] = set() + if ATTR_DEVICE_ID in config: + nodes.update( + { + async_get_node_from_device_id(hass, device_id) + for device_id in config.get(ATTR_DEVICE_ID, []) + } + ) + if ATTR_ENTITY_ID in config: + nodes.update( + { + async_get_node_from_entity_id(hass, entity_id) + for entity_id in config.get(ATTR_ENTITY_ID, []) + } + ) + + from_value = config[ATTR_FROM] + to_value = config[ATTR_TO] + command_class = config[ATTR_COMMAND_CLASS] + property_ = config[ATTR_PROPERTY] + endpoint = config.get(ATTR_ENDPOINT) + property_key = config.get(ATTR_PROPERTY_KEY) + unsubs = [] + job = HassJob(action) + + trigger_data: dict = {} + if automation_info: + trigger_data = automation_info.get("trigger_data", {}) + + @callback + def async_on_value_updated( + value: Value, device: dr.DeviceEntry, event: Event + ) -> None: + """Handle value update.""" + event_value: Value = event["value"] + if event_value != value: + return + + # Get previous value and its state value if it exists + prev_value_raw = event["args"]["prevValue"] + prev_value = value.metadata.states.get(str(prev_value_raw), prev_value_raw) + # Get current value and its state value if it exists + curr_value_raw = event["args"]["newValue"] + curr_value = value.metadata.states.get(str(curr_value_raw), curr_value_raw) + # Check from and to values against previous and current values respectively + for value_to_eval, raw_value_to_eval, match in ( + (prev_value, prev_value_raw, from_value), + (curr_value, curr_value_raw, to_value), + ): + if ( + match != MATCH_ALL + and value_to_eval != match + and not ( + isinstance(match, list) + and (value_to_eval in match or raw_value_to_eval in match) + ) + and raw_value_to_eval != match + ): + return + + device_name = device.name_by_user or device.name + + payload = { + **trigger_data, + CONF_PLATFORM: platform_type, + ATTR_DEVICE_ID: device.id, + ATTR_NODE_ID: value.node.node_id, + ATTR_COMMAND_CLASS: value.command_class, + ATTR_COMMAND_CLASS_NAME: value.command_class_name, + ATTR_PROPERTY: value.property_, + ATTR_PROPERTY_NAME: value.property_name, + ATTR_ENDPOINT: endpoint, + ATTR_PROPERTY_KEY: value.property_key, + ATTR_PROPERTY_KEY_NAME: value.property_key_name, + ATTR_PREVIOUS_VALUE: prev_value, + ATTR_PREVIOUS_VALUE_RAW: prev_value_raw, + ATTR_CURRENT_VALUE: curr_value, + ATTR_CURRENT_VALUE_RAW: curr_value_raw, + "description": f"Z-Wave value {value_id} updated on {device_name}", + } + + hass.async_run_hass_job(job, {"trigger": payload}) + + dev_reg = dr.async_get(hass) + for node in nodes: + device_identifier = get_device_id(node.client, node) + device = dev_reg.async_get_device({device_identifier}) + assert device + value_id = get_value_id(node, command_class, property_, endpoint, property_key) + value = node.values[value_id] + # We need to store the current value and device for the callback + unsubs.append( + node.on( + "value updated", + functools.partial(async_on_value_updated, value, device), + ) + ) + + @callback + def async_remove() -> None: + """Remove state listeners async.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + return async_remove diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py new file mode 100644 index 00000000000..33f6205c7b9 --- /dev/null +++ b/tests/components/zwave_js/test_trigger.py @@ -0,0 +1,276 @@ +"""The tests for Z-Wave JS automation triggers.""" +from unittest.mock import AsyncMock, patch + +from zwave_js_server.const import CommandClass +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node + +from homeassistant.components import automation +from homeassistant.components.zwave_js import DOMAIN +from homeassistant.components.zwave_js.trigger import async_validate_trigger_config +from homeassistant.const import SERVICE_RELOAD +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + async_get as async_get_dev_reg, +) +from homeassistant.setup import async_setup_component + +from .common import SCHLAGE_BE469_LOCK_ENTITY + +from tests.common import async_capture_events + + +async def test_zwave_js_value_updated(hass, client, lock_schlage_be469, integration): + """Test for zwave_js.value_updated automation trigger.""" + trigger_type = f"{DOMAIN}.value_updated" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + no_value_filter = async_capture_events(hass, "no_value_filter") + single_from_value_filter = async_capture_events(hass, "single_from_value_filter") + multiple_from_value_filters = async_capture_events( + hass, "multiple_from_value_filters" + ) + from_and_to_value_filters = async_capture_events(hass, "from_and_to_value_filters") + different_value = async_capture_events(hass, "different_value") + + def clear_events(): + """Clear all events in the event list.""" + no_value_filter.clear() + single_from_value_filter.clear() + multiple_from_value_filters.clear() + from_and_to_value_filters.clear() + different_value.clear() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # no value filter + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + "action": { + "event": "no_value_filter", + }, + }, + # single from value filter + { + "trigger": { + "platform": trigger_type, + "device_id": device.id, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": "ajar", + }, + "action": { + "event": "single_from_value_filter", + }, + }, + # multiple from value filters + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": ["closed", "opened"], + }, + "action": { + "event": "multiple_from_value_filters", + }, + }, + # from and to value filters + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": ["closed", "opened"], + "to": ["opened"], + }, + "action": { + "event": "from_and_to_value_filters", + }, + }, + # different value + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "boltStatus", + }, + "action": { + "event": "different_value", + }, + }, + ] + }, + ) + + # Test that no value filter is triggered + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 0 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 0 + + clear_events() + + # Test that a single_from_value_filter is triggered + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "ajar", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 1 + assert len(multiple_from_value_filters) == 0 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 0 + + clear_events() + + # Test that multiple_from_value_filters are triggered + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "closed", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 1 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 0 + + clear_events() + + # Test that from_and_to_value_filters is triggered + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "opened", + "prevValue": "closed", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 1 + assert len(from_and_to_value_filters) == 1 + assert len(different_value) == 0 + + clear_events() + + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "boltStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "boltStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 0 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 0 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 1 + + clear_events() + + with patch("homeassistant.config.load_yaml", return_value={}): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) + + +async def test_async_validate_trigger_config(hass): + """Test async_validate_trigger_config.""" + mock_platform = AsyncMock() + with patch( + "homeassistant.components.zwave_js.trigger._get_trigger_platform", + return_value=mock_platform, + ): + mock_platform.async_validate_trigger_config.return_value = {} + await async_validate_trigger_config(hass, {}) + mock_platform.async_validate_trigger_config.assert_awaited() From 9633b9fe6ed2dc5ec2a098a62e4710f0d008ae17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6vdahl?= Date: Fri, 20 Aug 2021 23:12:10 +0300 Subject: [PATCH 565/903] Fix Google Calendar auth user code expire time comparison (#54893) Fixes #51490. --- homeassistant/components/google/__init__.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 33afac6f57b..7e157d238d5 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,5 +1,5 @@ """Support for Google - Calendar Event Devices.""" -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from enum import Enum import logging import os @@ -27,8 +27,8 @@ from homeassistant.const import ( from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.event import track_time_change -from homeassistant.util import convert, dt +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import convert _LOGGER = logging.getLogger(__name__) @@ -188,7 +188,12 @@ def do_authentication(hass, hass_config, config): def step2_exchange(now): """Keep trying to validate the user_code until it expires.""" - if now >= dt.as_local(dev_flow.user_code_expiry): + + # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime + # object without tzinfo. For the comparison below to work, it needs one. + user_code_expiry = dev_flow.user_code_expiry.replace(tzinfo=timezone.utc) + + if now >= user_code_expiry: hass.components.persistent_notification.create( "Authentication code expired, please restart " "Home-Assistant and try again", @@ -216,7 +221,7 @@ def do_authentication(hass, hass_config, config): notification_id=NOTIFICATION_ID, ) - listener = track_time_change( + listener = track_utc_time_change( hass, step2_exchange, second=range(0, 60, dev_flow.interval) ) From debc6d632c7e5025c516968e1e3adb9ccdbc43d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 20 Aug 2021 23:21:40 +0300 Subject: [PATCH 566/903] Improve device condition type hinting (#54906) --- .../components/binary_sensor/device_condition.py | 6 +++++- homeassistant/components/cover/device_condition.py | 14 +++++++++----- .../components/device_automation/toggle_entity.py | 4 +++- homeassistant/components/light/device_condition.py | 6 +++++- homeassistant/components/lock/device_condition.py | 6 +++++- .../components/remote/device_condition.py | 6 +++++- .../components/select/device_condition.py | 6 +++--- .../components/switch/device_condition.py | 6 +++++- 8 files changed, 40 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index 309e26847a1..a6b9d3ffb8b 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -43,6 +43,8 @@ from . import ( DOMAIN, ) +# mypy: disallow-any-generics + DEVICE_CLASS_NONE = "none" CONF_IS_BAT_LOW = "is_bat_low" @@ -266,7 +268,9 @@ def async_condition_from_config( return condition.state_from_config(state_config) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index bd433dbd93d..c163bd097ae 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -1,8 +1,6 @@ """Provides device automations for Cover.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.const import ( @@ -38,6 +36,8 @@ from . import ( SUPPORT_SET_TILT_POSITION, ) +# mypy: disallow-any-generics + POSITION_CONDITION_TYPES = {"is_position", "is_tilt_position"} STATE_CONDITION_TYPES = {"is_open", "is_closed", "is_opening", "is_closing"} @@ -67,10 +67,12 @@ STATE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( CONDITION_SCHEMA = vol.Any(POSITION_CONDITION_SCHEMA, STATE_CONDITION_SCHEMA) -async def async_get_conditions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device conditions for Cover devices.""" registry = await entity_registry.async_get_registry(hass) - conditions: list[dict[str, Any]] = [] + conditions: list[dict[str, str]] = [] # Get all the integrations entities for this device for entry in entity_registry.async_entries_for_device(registry, device_id): @@ -100,7 +102,9 @@ async def async_get_conditions(hass: HomeAssistant, device_id: str) -> list[dict return conditions -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" if config[CONF_TYPE] not in ["is_position", "is_tilt_position"]: return {} diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index 6ad2264b516..794b6643ae8 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -217,7 +217,9 @@ async def async_get_triggers( return await _async_get_automations(hass, device_id, ENTITY_TRIGGERS, domain) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/light/device_condition.py b/homeassistant/components/light/device_condition.py index 7396ddeea31..e5ff8a83ba3 100644 --- a/homeassistant/components/light/device_condition.py +++ b/homeassistant/components/light/device_condition.py @@ -11,6 +11,8 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN +# mypy: disallow-any-generics + CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( {vol.Required(CONF_DOMAIN): DOMAIN} ) @@ -33,6 +35,8 @@ async def async_get_conditions( return await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return await toggle_entity.async_get_condition_capabilities(hass, config) diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index d0829eb742b..74b55a1a89c 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -23,6 +23,8 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN +# mypy: disallow-any-generics + CONDITION_TYPES = { "is_locked", "is_unlocked", @@ -39,7 +41,9 @@ CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( ) -async def async_get_conditions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device conditions for Lock devices.""" registry = await entity_registry.async_get_registry(hass) conditions = [] diff --git a/homeassistant/components/remote/device_condition.py b/homeassistant/components/remote/device_condition.py index ed200fd5579..02e6ea6bd23 100644 --- a/homeassistant/components/remote/device_condition.py +++ b/homeassistant/components/remote/device_condition.py @@ -11,6 +11,8 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN +# mypy: disallow-any-generics + CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( {vol.Required(CONF_DOMAIN): DOMAIN} ) @@ -33,6 +35,8 @@ async def async_get_conditions( return await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return await toggle_entity.async_get_condition_capabilities(hass, config) diff --git a/homeassistant/components/select/device_condition.py b/homeassistant/components/select/device_condition.py index ad82c432ce2..4f650ddadda 100644 --- a/homeassistant/components/select/device_condition.py +++ b/homeassistant/components/select/device_condition.py @@ -1,8 +1,6 @@ """Provide the device conditions for Select.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.const import ( @@ -21,6 +19,8 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import ATTR_OPTIONS, CONF_OPTION, DOMAIN +# nypy: disallow-any-generics + CONDITION_TYPES = {"selected_option"} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( @@ -71,7 +71,7 @@ def async_condition_from_config( async def async_get_condition_capabilities( hass: HomeAssistant, config: ConfigType -) -> dict[str, Any]: +) -> dict[str, vol.Schema]: """List condition capabilities.""" try: options = get_capability(hass, config[CONF_ENTITY_ID], ATTR_OPTIONS) or [] diff --git a/homeassistant/components/switch/device_condition.py b/homeassistant/components/switch/device_condition.py index 15c2e54d193..b59e533375c 100644 --- a/homeassistant/components/switch/device_condition.py +++ b/homeassistant/components/switch/device_condition.py @@ -11,6 +11,8 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN +# mypy: disallow-any-generics + CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( {vol.Required(CONF_DOMAIN): DOMAIN} ) @@ -33,6 +35,8 @@ async def async_get_conditions( return await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return await toggle_entity.async_get_condition_capabilities(hass, config) From 1f2134a31acf81bb917d0101612a192d996d006e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 20 Aug 2021 16:25:39 -0400 Subject: [PATCH 567/903] Use entity descriptions for zwave_js sensors (#53744) * Use entity descriptions for zwave_js sensors * reorder * use new type * revert typing changes * switch to using maps * Get device and state class from discovery instead * ues constants for keys * Add meter type attribute and simplify platform data access * comments * second refactor * Add None lookup value * readability * Switch base data template to type Any for more flexibility * Additional changes based on feedback * rewrite based on new upstream util functions * Use new combo type * Handle UnknownValueData in discovery * bug fixes * remove redundant comment * re-add force_update * fixes and tweaks * pylint and feedback --- homeassistant/components/zwave_js/const.py | 19 ++ .../components/zwave_js/discovery.py | 25 +- .../zwave_js/discovery_data_template.py | 106 +++++++- homeassistant/components/zwave_js/sensor.py | 254 ++++++++++++------ 4 files changed, 310 insertions(+), 94 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 4a311012690..8e545975faa 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -75,5 +75,24 @@ ATTR_REFRESH_ALL_VALUES = "refresh_all_values" ATTR_BROADCAST = "broadcast" # meter reset ATTR_METER_TYPE = "meter_type" +ATTR_METER_TYPE_NAME = "meter_type_name" ADDON_SLUG = "core_zwave_js" + +# Sensor entity description constants +ENTITY_DESC_KEY_BATTERY = "battery" +ENTITY_DESC_KEY_CURRENT = "current" +ENTITY_DESC_KEY_VOLTAGE = "voltage" +ENTITY_DESC_KEY_ENERGY_MEASUREMENT = "energy_measurement" +ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING = "energy_total_increasing" +ENTITY_DESC_KEY_POWER = "power" +ENTITY_DESC_KEY_POWER_FACTOR = "power_factor" +ENTITY_DESC_KEY_CO = "co" +ENTITY_DESC_KEY_CO2 = "co2" +ENTITY_DESC_KEY_HUMIDITY = "humidity" +ENTITY_DESC_KEY_ILLUMINANCE = "illuminance" +ENTITY_DESC_KEY_PRESSURE = "pressure" +ENTITY_DESC_KEY_SIGNAL_STRENGTH = "signal_strength" +ENTITY_DESC_KEY_TEMPERATURE = "temperature" +ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" +ENTITY_DESC_KEY_TIMESTAMP = "timestamp" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index d59a3d935a0..7a4955d693a 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -7,15 +7,18 @@ from typing import Any from awesomeversion import AwesomeVersion from zwave_js_server.const import THERMOSTAT_CURRENT_TEMP_PROPERTY, CommandClass +from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.core import callback +from .const import LOGGER from .discovery_data_template import ( BaseDiscoverySchemaDataTemplate, DynamicCurrentTempClimateDataTemplate, + NumericSensorDataTemplate, ZwaveValueID, ) @@ -59,14 +62,14 @@ class ZwaveDiscoveryInfo: assumed_state: bool # the home assistant platform for which an entity should be created platform: str + # helper data to use in platform setup + platform_data: Any + # additional values that need to be watched by entity + additional_value_ids_to_watch: set[str] # hint for the platform about this discovered entity platform_hint: str | None = "" # data template to use in platform logic platform_data_template: BaseDiscoverySchemaDataTemplate | None = None - # helper data to use in platform setup - platform_data: dict[str, Any] | None = None - # additional values that need to be watched by entity - additional_value_ids_to_watch: set[str] | None = None # bool to specify whether entity should be enabled by default entity_registry_enabled_default: bool = True @@ -487,6 +490,7 @@ DISCOVERY_SCHEMAS = [ }, type={"number"}, ), + data_template=NumericSensorDataTemplate(), ), ZWaveDiscoverySchema( platform="sensor", @@ -495,6 +499,7 @@ DISCOVERY_SCHEMAS = [ command_class={CommandClass.INDICATOR}, type={"number"}, ), + data_template=NumericSensorDataTemplate(), entity_registry_enabled_default=False, ), # Meter sensors for Meter CC @@ -508,6 +513,7 @@ DISCOVERY_SCHEMAS = [ type={"number"}, property={"value"}, ), + data_template=NumericSensorDataTemplate(), ), # special list sensors (Notification CC) ZWaveDiscoverySchema( @@ -542,6 +548,7 @@ DISCOVERY_SCHEMAS = [ property={"targetValue"}, ) ], + data_template=NumericSensorDataTemplate(), entity_registry_enabled_default=False, ), # binary switches @@ -745,9 +752,15 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None # resolve helper data from template resolved_data = None - additional_value_ids_to_watch = None + additional_value_ids_to_watch = set() if schema.data_template: - resolved_data = schema.data_template.resolve_data(value) + try: + resolved_data = schema.data_template.resolve_data(value) + except UnknownValueData as err: + LOGGER.error( + "Discovery for value %s will be skipped: %s", value, err + ) + continue additional_value_ids_to_watch = schema.data_template.value_ids_to_watch( resolved_data ) diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 7962b6b1c05..3ef74a7e17d 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -1,12 +1,80 @@ -"""Data template classes for discovery used to generate device specific data for setup.""" +"""Data template classes for discovery used to generate additional data for setup.""" from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass from typing import Any +from zwave_js_server.const import ( + CO2_SENSORS, + CO_SENSORS, + CURRENT_METER_TYPES, + CURRENT_SENSORS, + ENERGY_METER_TYPES, + ENERGY_SENSORS, + HUMIDITY_SENSORS, + ILLUMINANCE_SENSORS, + POWER_FACTOR_METER_TYPES, + POWER_METER_TYPES, + POWER_SENSORS, + PRESSURE_SENSORS, + SIGNAL_STRENGTH_SENSORS, + TEMPERATURE_SENSORS, + TIMESTAMP_SENSORS, + VOLTAGE_METER_TYPES, + VOLTAGE_SENSORS, + CommandClass, + MeterScaleType, + MultilevelSensorType, +) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue, get_value_id +from zwave_js_server.util.command_class import ( + get_meter_scale_type, + get_multilevel_sensor_type, +) + +from .const import ( + ENTITY_DESC_KEY_BATTERY, + ENTITY_DESC_KEY_CO, + ENTITY_DESC_KEY_CO2, + ENTITY_DESC_KEY_CURRENT, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + ENTITY_DESC_KEY_HUMIDITY, + ENTITY_DESC_KEY_ILLUMINANCE, + ENTITY_DESC_KEY_POWER, + ENTITY_DESC_KEY_POWER_FACTOR, + ENTITY_DESC_KEY_PRESSURE, + ENTITY_DESC_KEY_SIGNAL_STRENGTH, + ENTITY_DESC_KEY_TARGET_TEMPERATURE, + ENTITY_DESC_KEY_TEMPERATURE, + ENTITY_DESC_KEY_TIMESTAMP, + ENTITY_DESC_KEY_VOLTAGE, +) + +METER_DEVICE_CLASS_MAP: dict[str, set[MeterScaleType]] = { + ENTITY_DESC_KEY_CURRENT: CURRENT_METER_TYPES, + ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_METER_TYPES, + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ENERGY_METER_TYPES, + ENTITY_DESC_KEY_POWER: POWER_METER_TYPES, + ENTITY_DESC_KEY_POWER_FACTOR: POWER_FACTOR_METER_TYPES, +} + +MULTILEVEL_SENSOR_DEVICE_CLASS_MAP: dict[str, set[MultilevelSensorType]] = { + ENTITY_DESC_KEY_CO: CO_SENSORS, + ENTITY_DESC_KEY_CO2: CO2_SENSORS, + ENTITY_DESC_KEY_CURRENT: CURRENT_SENSORS, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ENERGY_SENSORS, + ENTITY_DESC_KEY_HUMIDITY: HUMIDITY_SENSORS, + ENTITY_DESC_KEY_ILLUMINANCE: ILLUMINANCE_SENSORS, + ENTITY_DESC_KEY_POWER: POWER_SENSORS, + ENTITY_DESC_KEY_PRESSURE: PRESSURE_SENSORS, + ENTITY_DESC_KEY_SIGNAL_STRENGTH: SIGNAL_STRENGTH_SENSORS, + ENTITY_DESC_KEY_TEMPERATURE: TEMPERATURE_SENSORS, + ENTITY_DESC_KEY_TIMESTAMP: TIMESTAMP_SENSORS, + ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_SENSORS, +} @dataclass @@ -19,11 +87,10 @@ class ZwaveValueID: property_key: str | int | None = None -@dataclass class BaseDiscoverySchemaDataTemplate: """Base class for discovery schema data templates.""" - def resolve_data(self, value: ZwaveValue) -> dict[str, Any]: + def resolve_data(self, value: ZwaveValue) -> Any: """ Resolve helper class data for a discovered value. @@ -33,7 +100,7 @@ class BaseDiscoverySchemaDataTemplate: # pylint: disable=no-self-use return {} - def values_to_watch(self, resolved_data: dict[str, Any]) -> Iterable[ZwaveValue]: + def values_to_watch(self, resolved_data: Any) -> Iterable[ZwaveValue]: """ Return list of all ZwaveValues resolved by helper that should be watched. @@ -42,7 +109,7 @@ class BaseDiscoverySchemaDataTemplate: # pylint: disable=no-self-use return [] - def value_ids_to_watch(self, resolved_data: dict[str, Any]) -> set[str]: + def value_ids_to_watch(self, resolved_data: Any) -> set[str]: """ Return list of all Value IDs resolved by helper that should be watched. @@ -107,3 +174,32 @@ class DynamicCurrentTempClimateDataTemplate(BaseDiscoverySchemaDataTemplate): return lookup_table.get(lookup_key) return None + + +class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): + """Data template class for Z-Wave Sensor entities.""" + + def resolve_data(self, value: ZwaveValue) -> str | None: + """Resolve helper class data for a discovered value.""" + + if value.command_class == CommandClass.BATTERY: + return ENTITY_DESC_KEY_BATTERY + + if value.command_class == CommandClass.METER: + scale_type = get_meter_scale_type(value) + for key, scale_type_set in METER_DEVICE_CLASS_MAP.items(): + if scale_type in scale_type_set: + return key + + if value.command_class == CommandClass.SENSOR_MULTILEVEL: + sensor_type = get_multilevel_sensor_type(value) + if sensor_type == MultilevelSensorType.TARGET_TEMPERATURE: + return ENTITY_DESC_KEY_TARGET_TEMPERATURE + for ( + key, + sensor_type_set, + ) in MULTILEVEL_SENSOR_DEVICE_CLASS_MAP.items(): + if sensor_type in sensor_type_set: + return key + + return None diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 1ffa263dae7..5c8ed8633f1 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -1,30 +1,45 @@ """Representation of Z-Wave sensors.""" from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass import logging from typing import cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const import ( + RESET_METER_OPTION_TARGET_VALUE, + RESET_METER_OPTION_TYPE, + CommandClass, + ConfigurationValueType, +) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue +from zwave_js_server.util.command_class import get_meter_type from homeassistant.components.sensor import ( - DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, DOMAIN as SENSOR_DOMAIN, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT, @@ -34,7 +49,30 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_METER_TYPE, ATTR_VALUE, DATA_CLIENT, DOMAIN, SERVICE_RESET_METER +from .const import ( + ATTR_METER_TYPE, + ATTR_METER_TYPE_NAME, + ATTR_VALUE, + DATA_CLIENT, + DOMAIN, + ENTITY_DESC_KEY_BATTERY, + ENTITY_DESC_KEY_CO, + ENTITY_DESC_KEY_CO2, + ENTITY_DESC_KEY_CURRENT, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + ENTITY_DESC_KEY_HUMIDITY, + ENTITY_DESC_KEY_ILLUMINANCE, + ENTITY_DESC_KEY_POWER, + ENTITY_DESC_KEY_POWER_FACTOR, + ENTITY_DESC_KEY_PRESSURE, + ENTITY_DESC_KEY_SIGNAL_STRENGTH, + ENTITY_DESC_KEY_TARGET_TEMPERATURE, + ENTITY_DESC_KEY_TEMPERATURE, + ENTITY_DESC_KEY_TIMESTAMP, + ENTITY_DESC_KEY_VOLTAGE, + SERVICE_RESET_METER, +) from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity from .helpers import get_device_id @@ -42,6 +80,97 @@ from .helpers import get_device_id LOGGER = logging.getLogger(__name__) +@dataclass +class ZwaveSensorEntityDescription(SensorEntityDescription): + """Base description of a Zwave Sensor entity.""" + + info: ZwaveDiscoveryInfo | None = None + + +ENTITY_DESCRIPTION_KEY_MAP: dict[str, ZwaveSensorEntityDescription] = { + ENTITY_DESC_KEY_BATTERY: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_BATTERY, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_CURRENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_CURRENT, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_VOLTAGE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_VOLTAGE, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + ENTITY_DESC_KEY_POWER: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_POWER, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_POWER_FACTOR: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_POWER_FACTOR, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_CO: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_CO, + device_class=DEVICE_CLASS_CO, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_CO2: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_CO2, + device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_HUMIDITY: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_ILLUMINANCE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_ILLUMINANCE, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_PRESSURE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_PRESSURE, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_SIGNAL_STRENGTH: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_SIGNAL_STRENGTH, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_TEMPERATURE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_TIMESTAMP: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TIMESTAMP, + device_class=DEVICE_CLASS_TIMESTAMP, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_TARGET_TEMPERATURE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TARGET_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=None, + ), +} + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -55,16 +184,25 @@ async def async_setup_entry( """Add Z-Wave Sensor.""" entities: list[ZWaveBaseEntity] = [] + entity_description = ENTITY_DESCRIPTION_KEY_MAP.get( + info.platform_data + ) or ZwaveSensorEntityDescription("base_sensor") + entity_description.info = info + if info.platform_hint == "string_sensor": - entities.append(ZWaveStringSensor(config_entry, client, info)) + entities.append(ZWaveStringSensor(config_entry, client, entity_description)) elif info.platform_hint == "numeric_sensor": - entities.append(ZWaveNumericSensor(config_entry, client, info)) + entities.append( + ZWaveNumericSensor(config_entry, client, entity_description) + ) elif info.platform_hint == "list_sensor": - entities.append(ZWaveListSensor(config_entry, client, info)) + entities.append(ZWaveListSensor(config_entry, client, entity_description)) elif info.platform_hint == "config_parameter": - entities.append(ZWaveConfigParameterSensor(config_entry, client, info)) + entities.append( + ZWaveConfigParameterSensor(config_entry, client, entity_description) + ) elif info.platform_hint == "meter": - entities.append(ZWaveMeterSensor(config_entry, client, info)) + entities.append(ZWaveMeterSensor(config_entry, client, entity_description)) else: LOGGER.warning( "Sensor not implemented for %s/%s", @@ -114,62 +252,16 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): self, config_entry: ConfigEntry, client: ZwaveClient, - info: ZwaveDiscoveryInfo, + entity_description: ZwaveSensorEntityDescription, ) -> None: """Initialize a ZWaveSensorBase entity.""" - super().__init__(config_entry, client, info) + assert entity_description.info + super().__init__(config_entry, client, entity_description.info) + self.entity_description = entity_description # Entity class attributes + self._attr_force_update = True self._attr_name = self.generate_name(include_value_name=True) - self._attr_device_class = self._get_device_class() - self._attr_state_class = self._get_state_class() - - def _get_device_class(self) -> str | None: - """ - Get the device class of the sensor. - - This should be run once during initialization so we don't have to calculate - this value on every state update. - """ - if self.info.primary_value.command_class == CommandClass.BATTERY: - return DEVICE_CLASS_BATTERY - if isinstance(self.info.primary_value.property_, str): - property_lower = self.info.primary_value.property_.lower() - if "humidity" in property_lower: - return DEVICE_CLASS_HUMIDITY - if "temperature" in property_lower: - return DEVICE_CLASS_TEMPERATURE - if self.info.primary_value.metadata.unit == "A": - return DEVICE_CLASS_CURRENT - if self.info.primary_value.metadata.unit == "W": - return DEVICE_CLASS_POWER - if self.info.primary_value.metadata.unit == "kWh": - return DEVICE_CLASS_ENERGY - if self.info.primary_value.metadata.unit == "V": - return DEVICE_CLASS_VOLTAGE - if self.info.primary_value.metadata.unit == "Lux": - return DEVICE_CLASS_ILLUMINANCE - return None - - def _get_state_class(self) -> str | None: - """ - Get the state class of the sensor. - - This should be run once during initialization so we don't have to calculate - this value on every state update. - """ - if self.info.primary_value.command_class == CommandClass.BATTERY: - return STATE_CLASS_MEASUREMENT - if isinstance(self.info.primary_value.property_, str): - property_lower = self.info.primary_value.property_.lower() - if "humidity" in property_lower or "temperature" in property_lower: - return STATE_CLASS_MEASUREMENT - return None - - @property - def force_update(self) -> bool: - """Force updates.""" - return True class ZWaveStringSensor(ZwaveSensorBase): @@ -216,20 +308,16 @@ class ZWaveNumericSensor(ZwaveSensorBase): class ZWaveMeterSensor(ZWaveNumericSensor): """Representation of a Z-Wave Meter CC sensor.""" - def __init__( - self, - config_entry: ConfigEntry, - client: ZwaveClient, - info: ZwaveDiscoveryInfo, - ) -> None: - """Initialize a ZWaveNumericSensor entity.""" - super().__init__(config_entry, client, info) - - # Entity class attributes - if self.device_class == DEVICE_CLASS_ENERGY: - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING - else: - self._attr_state_class = STATE_CLASS_MEASUREMENT + @property + def extra_state_attributes(self) -> Mapping[str, int | str] | None: + """Return extra state attributes.""" + meter_type = get_meter_type(self.info.primary_value) + if meter_type: + return { + ATTR_METER_TYPE: meter_type.value, + ATTR_METER_TYPE_NAME: meter_type.name, + } + return None async def async_reset_meter( self, meter_type: int | None = None, value: int | None = None @@ -239,9 +327,9 @@ class ZWaveMeterSensor(ZWaveNumericSensor): primary_value = self.info.primary_value options = {} if meter_type is not None: - options["type"] = meter_type + options[RESET_METER_OPTION_TYPE] = meter_type if value is not None: - options["targetValue"] = value + options[RESET_METER_OPTION_TARGET_VALUE] = value args = [options] if options else [] await node.endpoints[primary_value.endpoint].async_invoke_cc_api( CommandClass.METER, "reset", *args, wait_for_result=False @@ -261,10 +349,10 @@ class ZWaveListSensor(ZwaveSensorBase): self, config_entry: ConfigEntry, client: ZwaveClient, - info: ZwaveDiscoveryInfo, + entity_description: ZwaveSensorEntityDescription, ) -> None: """Initialize a ZWaveListSensor entity.""" - super().__init__(config_entry, client, info) + super().__init__(config_entry, client, entity_description) # Entity class attributes self._attr_name = self.generate_name( @@ -291,7 +379,7 @@ class ZWaveListSensor(ZwaveSensorBase): def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" # add the value's int value as property for multi-value (list) items - return {"value": self.info.primary_value.value} + return {ATTR_VALUE: self.info.primary_value.value} class ZWaveConfigParameterSensor(ZwaveSensorBase): @@ -301,10 +389,10 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): self, config_entry: ConfigEntry, client: ZwaveClient, - info: ZwaveDiscoveryInfo, + entity_description: ZwaveSensorEntityDescription, ) -> None: """Initialize a ZWaveConfigParameterSensor entity.""" - super().__init__(config_entry, client, info) + super().__init__(config_entry, client, entity_description) self._primary_value = cast(ConfigurationValue, self.info.primary_value) # Entity class attributes @@ -338,7 +426,7 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): if self._primary_value.configuration_value_type == ConfigurationValueType.RANGE: return None # add the value's int value as property for multi-value (list) items - return {"value": self.info.primary_value.value} + return {ATTR_VALUE: self.info.primary_value.value} class ZWaveNodeStatusSensor(SensorEntity): From 8f6281473ebcf6fdb5442f199350c397e8170b6f Mon Sep 17 00:00:00 2001 From: Guy Khmelnitsky <3136012+GuyKh@users.noreply.github.com> Date: Fri, 20 Aug 2021 23:41:30 +0300 Subject: [PATCH 568/903] Fix Watson TTS to use correct API (#54916) --- homeassistant/components/watson_tts/tts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index cdcbbc6ed2a..610ad61132e 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -191,7 +191,7 @@ class WatsonTTSProvider(Provider): def get_tts_audio(self, message, language=None, options=None): """Request TTS file from Watson TTS.""" response = self.service.synthesize( - message, accept=self.output_format, voice=self.default_voice + text=message, accept=self.output_format, voice=self.default_voice ).get_result() return (CONTENT_TYPE_EXTENSIONS[self.output_format], response.content) From 152f799d0eb6c2f1e27059c127657728f7513192 Mon Sep 17 00:00:00 2001 From: bsmappee <58250533+bsmappee@users.noreply.github.com> Date: Fri, 20 Aug 2021 23:20:45 +0200 Subject: [PATCH 569/903] Extract smappee switch energy attributes into sensors (#54329) --- homeassistant/components/smappee/sensor.py | 104 +++++++++++++++------ homeassistant/components/smappee/switch.py | 11 --- 2 files changed, 77 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index fb879e3cef5..6474c74c185 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -1,8 +1,15 @@ """Support for monitoring a Smappee energy sensor.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, POWER_WATT, ) @@ -28,34 +35,34 @@ TREND_SENSORS = { ], "power_today": [ "Total consumption - Today", - "mdi:power-plug", + None, ENERGY_WATT_HOUR, "power_today", - None, + DEVICE_CLASS_ENERGY, False, # cloud only ], "power_current_hour": [ "Total consumption - Current hour", - "mdi:power-plug", + None, ENERGY_WATT_HOUR, "power_current_hour", - None, + DEVICE_CLASS_ENERGY, False, # cloud only ], "power_last_5_minutes": [ "Total consumption - Last 5 minutes", - "mdi:power-plug", + None, ENERGY_WATT_HOUR, "power_last_5_minutes", - None, + DEVICE_CLASS_ENERGY, False, # cloud only ], "alwayson_today": [ "Always on - Today", - "mdi:sleep", + None, ENERGY_WATT_HOUR, "alwayson_today", - None, + DEVICE_CLASS_ENERGY, False, # cloud only ], } @@ -79,68 +86,68 @@ SOLAR_SENSORS = { ], "solar_today": [ "Total production - Today", - "mdi:white-balance-sunny", + None, ENERGY_WATT_HOUR, "solar_today", - None, + DEVICE_CLASS_POWER, False, # cloud only ], "solar_current_hour": [ "Total production - Current hour", - "mdi:white-balance-sunny", + None, ENERGY_WATT_HOUR, "solar_current_hour", - None, + DEVICE_CLASS_POWER, False, # cloud only ], } VOLTAGE_SENSORS = { "phase_voltages_a": [ "Phase voltages - A", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "phase_voltage_a", - None, + DEVICE_CLASS_VOLTAGE, ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], ], "phase_voltages_b": [ "Phase voltages - B", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "phase_voltage_b", - None, + DEVICE_CLASS_VOLTAGE, ["TWO", "THREE_STAR", "THREE_DELTA"], ], "phase_voltages_c": [ "Phase voltages - C", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "phase_voltage_c", - None, + DEVICE_CLASS_VOLTAGE, ["THREE_STAR"], ], "line_voltages_a": [ "Line voltages - A", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "line_voltage_a", - None, + DEVICE_CLASS_VOLTAGE, ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], ], "line_voltages_b": [ "Line voltages - B", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "line_voltage_b", - None, + DEVICE_CLASS_VOLTAGE, ["TWO", "THREE_STAR", "THREE_DELTA"], ], "line_voltages_c": [ "Line voltages - C", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "line_voltage_c", - None, + DEVICE_CLASS_VOLTAGE, ["THREE_STAR", "THREE_DELTA"], ], } @@ -246,6 +253,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) ) + # Add today_energy_kwh sensors for switches + for actuator_id, actuator in service_location.actuators.items(): + if actuator.type == "SWITCH": + entities.append( + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + sensor="switch", + attributes=[ + f"{actuator.name} - energy today", + None, + ENERGY_KILO_WATT_HOUR, + actuator_id, + DEVICE_CLASS_ENERGY, + False, # cloud only + ], + ) + ) + async_add_entities(entities, True) @@ -268,7 +294,7 @@ class SmappeeSensor(SensorEntity): @property def name(self): """Return the name for this sensor.""" - if self._sensor in ("sensor", "load"): + if self._sensor in ("sensor", "load", "switch"): return ( f"{self._service_location.service_location_name} - " f"{self._sensor.title()} - {self._name}" @@ -291,6 +317,24 @@ class SmappeeSensor(SensorEntity): """Return the class of this device, from component DEVICE_CLASSES.""" return self._device_class + @property + def state_class(self): + """Return the state class of this device.""" + scm = STATE_CLASS_MEASUREMENT + + if self._sensor in ( + "power_today", + "power_current_hour", + "power_last_5_minutes", + "solar_today", + "solar_current_hour", + "alwayson_today", + "switch", + ): + scm = STATE_CLASS_TOTAL_INCREASING + + return scm + @property def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" @@ -301,7 +345,7 @@ class SmappeeSensor(SensorEntity): self, ): """Return the unique ID for this sensor.""" - if self._sensor in ("load", "sensor"): + if self._sensor in ("load", "sensor", "switch"): return ( f"{self._service_location.device_serial_number}-" f"{self._service_location.service_location_id}-" @@ -379,3 +423,9 @@ class SmappeeSensor(SensorEntity): for channel in sensor.channels: if channel.get("channel") == int(channel_id): self._state = channel.get("value_today") + elif self._sensor == "switch": + cons = self._service_location.actuators.get( + self._sensor_id + ).consumption_today + if cons is not None: + self._state = round(cons / 1000.0, 2) diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index 3ba5e6b2a97..ded898f9f10 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -128,17 +128,6 @@ class SmappeeActuator(SwitchEntity): or self._actuator_type == "COMFORT_PLUG" ) - @property - def today_energy_kwh(self): - """Return the today total energy usage in kWh.""" - if self._actuator_type == "SWITCH": - cons = self._service_location.actuators.get( - self._actuator_id - ).consumption_today - if cons is not None: - return round(cons / 1000.0, 2) - return None - @property def unique_id( self, From 71b8409c0d382fab0f3b7369b6a21632d04a03ca Mon Sep 17 00:00:00 2001 From: Samuel Tardieu Date: Fri, 20 Aug 2021 23:24:16 +0200 Subject: [PATCH 570/903] Use a static collection of forwarded attributes (#54870) Not repeating each attribute name three times lowers the risk of a typo. Also, only one lookup is done during the kwargs traversal instead of two. --- homeassistant/components/group/light.py | 55 ++++++++++--------------- 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index bb0762d2278..671a471318a 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -82,6 +82,23 @@ async def async_setup_platform( ) +FORWARDED_ATTRIBUTES = frozenset( + { + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, + ATTR_TRANSITION, + ATTR_WHITE_VALUE, + ATTR_XY_COLOR, + } +) + + class LightGroup(GroupEntity, light.LightEntity): """Representation of a light group.""" @@ -128,40 +145,10 @@ class LightGroup(GroupEntity, light.LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Forward the turn_on command to all lights in the light group.""" - data = {ATTR_ENTITY_ID: self._entity_ids} - - if ATTR_BRIGHTNESS in kwargs: - data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS] - - if ATTR_HS_COLOR in kwargs: - data[ATTR_HS_COLOR] = kwargs[ATTR_HS_COLOR] - - if ATTR_RGB_COLOR in kwargs: - data[ATTR_RGB_COLOR] = kwargs[ATTR_RGB_COLOR] - - if ATTR_RGBW_COLOR in kwargs: - data[ATTR_RGBW_COLOR] = kwargs[ATTR_RGBW_COLOR] - - if ATTR_RGBWW_COLOR in kwargs: - data[ATTR_RGBWW_COLOR] = kwargs[ATTR_RGBWW_COLOR] - - if ATTR_XY_COLOR in kwargs: - data[ATTR_XY_COLOR] = kwargs[ATTR_XY_COLOR] - - if ATTR_COLOR_TEMP in kwargs: - data[ATTR_COLOR_TEMP] = kwargs[ATTR_COLOR_TEMP] - - if ATTR_WHITE_VALUE in kwargs: - data[ATTR_WHITE_VALUE] = kwargs[ATTR_WHITE_VALUE] - - if ATTR_EFFECT in kwargs: - data[ATTR_EFFECT] = kwargs[ATTR_EFFECT] - - if ATTR_TRANSITION in kwargs: - data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] - - if ATTR_FLASH in kwargs: - data[ATTR_FLASH] = kwargs[ATTR_FLASH] + data = { + key: value for key, value in kwargs.items() if key in FORWARDED_ATTRIBUTES + } + data[ATTR_ENTITY_ID] = self._entity_ids await self.hass.services.async_call( light.DOMAIN, From 3cb72270407a8ea6031a7d894ae61d542c8069d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Aug 2021 16:32:48 -0500 Subject: [PATCH 571/903] Normalize the display name of yeelight devices (#54883) Co-authored-by: Teemu R. --- homeassistant/components/yeelight/__init__.py | 23 ++++++++++++++++--- .../components/yeelight/config_flow.py | 16 +++++++++---- .../components/yeelight/strings.json | 2 +- .../components/yeelight/translations/en.json | 2 +- tests/components/yeelight/test_config_flow.py | 2 +- 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index d315c3b5860..aeffe4c6ea5 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -316,12 +316,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +@callback +def async_format_model(model: str) -> str: + """Generate a more human readable model.""" + return model.replace("_", " ").title() + + +@callback +def async_format_id(id_: str) -> str: + """Generate a more human readable id.""" + return hex(int(id_, 16)) if id_ else "None" + + +@callback +def async_format_model_id(model: str, id_: str) -> str: + """Generate a more human readable name.""" + return f"{async_format_model(model)} {async_format_id(id_)}" + + @callback def _async_unique_name(capabilities: dict) -> str: """Generate name from capabilities.""" - model = str(capabilities["model"]).replace("_", " ").title() - short_id = hex(int(capabilities["id"], 16)) - return f"Yeelight {model} {short_id}" + model_id = async_format_model_id(capabilities["model"], capabilities["id"]) + return f"Yeelight {model_id}" async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 651d41ff268..268a0e9cea2 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -23,6 +23,9 @@ from . import ( NIGHTLIGHT_SWITCH_TYPE_LIGHT, YeelightScanner, _async_unique_name, + async_format_id, + async_format_model, + async_format_model_id, ) MODEL_UNKNOWN = "unknown" @@ -92,12 +95,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Confirm discovery.""" if user_input is not None: return self.async_create_entry( - title=f"{self._discovered_model} {self.unique_id}", + title=async_format_model_id(self._discovered_model, self.unique_id), data={CONF_ID: self.unique_id, CONF_HOST: self._discovered_ip}, ) self._set_confirm_only() - placeholders = {"model": self._discovered_model, "host": self._discovered_ip} + placeholders = { + "id": async_format_id(self.unique_id), + "model": async_format_model(self._discovered_model), + "host": self._discovered_ip, + } self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="discovery_confirm", description_placeholders=placeholders @@ -118,7 +125,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: self._abort_if_unique_id_configured() return self.async_create_entry( - title=f"{model} {self.unique_id}", + title=async_format_model_id(model, self.unique_id), data={ CONF_HOST: user_input[CONF_HOST], CONF_ID: self.unique_id, @@ -162,7 +169,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): continue # ignore configured devices model = capabilities["model"] host = urlparse(capabilities["location"]).hostname - name = f"{host} {model} {unique_id}" + model_id = async_format_model_id(model, unique_id) + name = f"{model_id} ({host})" self._discovered_devices[unique_id] = capabilities devices_name[unique_id] = name diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index 807fae1ca64..a0ce26550c8 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "user": { "description": "If you leave the host empty, discovery will be used to find devices.", diff --git a/homeassistant/components/yeelight/translations/en.json b/homeassistant/components/yeelight/translations/en.json index 06431e7bc2b..3ed5bbe5515 100644 --- a/homeassistant/components/yeelight/translations/en.json +++ b/homeassistant/components/yeelight/translations/en.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Failed to connect" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Do you want to setup {model} ({host})?" diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 17902a08bfa..9fb86c8921a 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -214,7 +214,7 @@ async def test_manual(hass: HomeAssistant): ) await hass.async_block_till_done() assert result4["type"] == "create_entry" - assert result4["title"] == "color 0x000000000015243f" + assert result4["title"] == "Color 0x15243f" assert result4["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} # Duplicate From 1325b3825678bb92a055eac70e633d2b6f94f7f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Aug 2021 16:33:23 -0500 Subject: [PATCH 572/903] Handle case where location_name is set to "" for zeroconf (#54880) --- homeassistant/components/zeroconf/__init__.py | 4 +++- tests/components/zeroconf/test_init.py | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 8b1f482e05e..17cfb9d05de 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -215,7 +215,9 @@ async def _async_register_hass_zc_service( hass: HomeAssistant, aio_zc: HaAsyncZeroconf, uuid: str ) -> None: # Get instance UUID - valid_location_name = _truncate_location_name_to_valid(hass.config.location_name) + valid_location_name = _truncate_location_name_to_valid( + hass.config.location_name or "Home" + ) params = { "location_name": valid_location_name, diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index a284c91e4f4..d13bbc97547 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -39,7 +39,7 @@ def service_update_mock(ipv6, zeroconf, services, handlers, *, limit_service=Non handlers[0](zeroconf, service, f"_name.{service}", ServiceStateChange.Added) -def get_service_info_mock(service_type, name): +def get_service_info_mock(service_type, name, *args, **kwargs): """Return service info for get_service_info.""" return AsyncServiceInfo( service_type, @@ -872,3 +872,16 @@ async def test_async_detect_interfaces_explicitly_set_ipv6(hass, mock_async_zero interfaces=["192.168.1.5", "fe80::dead:beef:dead:beef"], ip_version=IPVersion.All, ) + + +async def test_no_name(hass, mock_async_zeroconf): + """Test fallback to Home for mDNS announcement if the name is missing.""" + hass.config.location_name = "" + with patch("homeassistant.components.zeroconf.HaZeroconf"): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + register_call = mock_async_zeroconf.async_register_service.mock_calls[-1] + info = register_call.args[0] + assert info.name == "Home._home-assistant._tcp.local." From b71f2689d787b8b3c5735009fcb24b9833d8aaae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Aug 2021 19:09:22 -0500 Subject: [PATCH 573/903] Make yeelight aware of the network integration (#54854) --- homeassistant/components/yeelight/__init__.py | 92 ++++++++++---- .../components/yeelight/manifest.json | 1 + tests/components/yeelight/__init__.py | 9 +- tests/components/yeelight/test_config_flow.py | 105 ++++++++++++++-- tests/components/yeelight/test_init.py | 113 ++++++++++++++++++ 5 files changed, 283 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index aeffe4c6ea5..2bdde2113a4 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import contextlib from datetime import timedelta +from ipaddress import IPv4Address, IPv6Address import logging from urllib.parse import urlparse @@ -13,6 +14,7 @@ from yeelight import BulbException from yeelight.aio import KEY_CONNECTED, AsyncBulb from homeassistant import config_entries +from homeassistant.components import network from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady from homeassistant.const import ( CONF_DEVICES, @@ -269,13 +271,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex # Otherwise fall through to discovery else: - # manually added device - try: - await _async_initialize( - hass, entry, entry.data[CONF_HOST], device=device - ) - except BulbException as ex: - raise ConfigEntryNotReady from ex + # Since device is passed this cannot throw an exception anymore + await _async_initialize(hass, entry, entry.data[CONF_HOST], device=device) return True async def _async_from_discovery(capabilities: dict[str, str]) -> None: @@ -367,34 +364,77 @@ class YeelightScanner: self._unique_id_capabilities = {} self._host_capabilities = {} self._track_interval = None - self._listener = None - self._connected_event = None + self._listeners = [] + self._connected_events = [] async def async_setup(self): """Set up the scanner.""" - if self._connected_event: - await self._connected_event.wait() + if self._connected_events: + await asyncio.gather(*(event.wait() for event in self._connected_events)) return - self._connected_event = asyncio.Event() - async def _async_connected(): - self._listener.async_search() - self._connected_event.set() + for idx, source_ip in enumerate(await self._async_build_source_set()): + self._connected_events.append(asyncio.Event()) - self._listener = SSDPListener( - async_callback=self._async_process_entry, - service_type=SSDP_ST, - target=SSDP_TARGET, - async_connect_callback=_async_connected, + def _wrap_async_connected_idx(idx): + """Create a function to capture the idx cell variable.""" + + async def _async_connected(): + self._connected_events[idx].set() + + return _async_connected + + self._listeners.append( + SSDPListener( + async_callback=self._async_process_entry, + service_type=SSDP_ST, + target=SSDP_TARGET, + source_ip=source_ip, + async_connect_callback=_wrap_async_connected_idx(idx), + ) + ) + + results = await asyncio.gather( + *(listener.async_start() for listener in self._listeners), + return_exceptions=True, ) - await self._listener.async_start() - await self._connected_event.wait() + failed_listeners = [] + for idx, result in enumerate(results): + if not isinstance(result, Exception): + continue + _LOGGER.warning( + "Failed to setup listener for %s: %s", + self._listeners[idx].source_ip, + result, + ) + failed_listeners.append(self._listeners[idx]) + self._connected_events[idx].set() + + for listener in failed_listeners: + self._listeners.remove(listener) + + await asyncio.gather(*(event.wait() for event in self._connected_events)) + self.async_scan() + + async def _async_build_source_set(self) -> set[IPv4Address]: + """Build the list of ssdp sources.""" + adapters = await network.async_get_adapters(self._hass) + sources: set[IPv4Address] = set() + if network.async_only_default_interface_enabled(adapters): + sources.add(IPv4Address("0.0.0.0")) + return sources + + return { + source_ip + for source_ip in await network.async_get_enabled_source_ips(self._hass) + if not source_ip.is_loopback and not isinstance(source_ip, IPv6Address) + } async def async_discover(self): """Discover bulbs.""" await self.async_setup() for _ in range(DISCOVERY_ATTEMPTS): - self._listener.async_search() + self.async_scan() await asyncio.sleep(DISCOVERY_SEARCH_INTERVAL.total_seconds()) return self._unique_id_capabilities.values() @@ -402,7 +442,8 @@ class YeelightScanner: def async_scan(self, *_): """Send discovery packets.""" _LOGGER.debug("Yeelight scanning") - self._listener.async_search() + for listener in self._listeners: + listener.async_search() async def async_get_capabilities(self, host): """Get capabilities via SSDP.""" @@ -413,7 +454,8 @@ class YeelightScanner: self._host_discovered_events.setdefault(host, []).append(host_event) await self.async_setup() - self._listener.async_search((host, SSDP_TARGET[1])) + for listener in self._listeners: + listener.async_search((host, SSDP_TARGET[1])) with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for(host_event.wait(), timeout=DISCOVERY_TIMEOUT) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index b1c1c131907..31d884628e1 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -5,6 +5,7 @@ "requirements": ["yeelight==0.7.2", "async-upnp-client==0.20.0"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, + "dependencies": ["network"], "quality_scale": "platinum", "iot_class": "local_push", "dhcp": [{ diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index cb2936cf8e2..06c0243e918 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -1,6 +1,7 @@ """Tests for the Yeelight integration.""" import asyncio from datetime import timedelta +from ipaddress import IPv4Address from unittest.mock import AsyncMock, MagicMock, patch from async_upnp_client.search import SSDPListener @@ -19,6 +20,8 @@ from homeassistant.components.yeelight import ( from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import callback +FAIL_TO_BIND_IP = "1.2.3.4" + IP_ADDRESS = "192.168.1.239" MODEL = "color" ID = "0x000000000015243f" @@ -127,6 +130,8 @@ def _patched_ssdp_listener(info, *args, **kwargs): listener = SSDPListener(*args, **kwargs) async def _async_callback(*_): + if kwargs["source_ip"] == IPv4Address(FAIL_TO_BIND_IP): + raise OSError await listener.async_connect_callback() @callback @@ -139,12 +144,12 @@ def _patched_ssdp_listener(info, *args, **kwargs): return listener -def _patch_discovery(no_device=False): +def _patch_discovery(no_device=False, capabilities=None): YeelightScanner._scanner = None # Clear class scanner to reset hass def _generate_fake_ssdp_listener(*args, **kwargs): return _patched_ssdp_listener( - None if no_device else CAPABILITIES, + None if no_device else capabilities or CAPABILITIES, *args, **kwargs, ) diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 9fb86c8921a..bde8a18ae55 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -51,18 +51,31 @@ DEFAULT_CONFIG = { async def test_discovery(hass: HomeAssistant): """Test setting up discovery.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["step_id"] == "user" - assert not result["errors"] - with _patch_discovery(), _patch_discovery_interval(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result2["type"] == "form" - assert result2["step_id"] == "pick_device" - assert not result2["errors"] + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + # test we can try again + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] with _patch_discovery(), _patch_discovery_interval(), patch( f"{MODULE}.async_setup", return_value=True @@ -93,6 +106,78 @@ async def test_discovery(hass: HomeAssistant): assert result2["reason"] == "no_devices_found" +async def test_discovery_with_existing_device_present(hass: HomeAssistant): + """Test setting up discovery.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_ID: "0x000000000099999", CONF_HOST: "4.4.4.4"} + ) + config_entry.add_to_hass(hass) + alternate_bulb = _mocked_bulb() + alternate_bulb.capabilities["id"] = "0x000000000099999" + alternate_bulb.capabilities["location"] = "yeelight://4.4.4.4" + + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=alternate_bulb): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_discovery_interval(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + # Now abort and make sure we can start over + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_discovery_interval(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb() + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DEVICE: ID} + ) + assert result3["type"] == "create_entry" + assert result3["title"] == UNIQUE_FRIENDLY_NAME + assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS} + await hass.async_block_till_done() + await hass.async_block_till_done() + + # ignore configured devices + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_discovery_interval(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2["type"] == "abort" + assert result2["reason"] == "no_devices_found" + + async def test_discovery_no_device(hass: HomeAssistant): """Test discovery without device.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 68571fcce27..84c87b7f1dc 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -32,6 +32,7 @@ from . import ( ENTITY_BINARY_SENSOR_TEMPLATE, ENTITY_LIGHT, ENTITY_NIGHTLIGHT, + FAIL_TO_BIND_IP, ID, IP_ADDRESS, MODULE, @@ -131,6 +132,107 @@ async def test_setup_discovery(hass: HomeAssistant): assert hass.states.get(ENTITY_LIGHT) is None +_ADAPTERS_WITH_MANUAL_CONFIG = [ + { + "auto": True, + "index": 2, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, +] + + +async def test_setup_discovery_with_manually_configured_network_adapter( + hass: HomeAssistant, +): + """Test setting up Yeelight by discovery with a manually configured network adapter.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_BINARY_SENSOR) is not None + assert hass.states.get(ENTITY_LIGHT) is not None + + # Unload + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == STATE_UNAVAILABLE + assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE + + # Remove + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_BINARY_SENSOR) is None + assert hass.states.get(ENTITY_LIGHT) is None + + +_ADAPTERS_WITH_MANUAL_CONFIG_ONE_FAILING = [ + { + "auto": True, + "index": 1, + "default": False, + "enabled": True, + "ipv4": [{"address": FAIL_TO_BIND_IP, "network_prefix": 23}], + "ipv6": [], + "name": "eth0", + }, + { + "auto": True, + "index": 2, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, +] + + +async def test_setup_discovery_with_manually_configured_network_adapter_one_fails( + hass: HomeAssistant, caplog +): + """Test setting up Yeelight by discovery with a manually configured network adapter with one that fails to bind.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG_ONE_FAILING, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_BINARY_SENSOR) is not None + assert hass.states.get(ENTITY_LIGHT) is not None + + # Unload + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == STATE_UNAVAILABLE + assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE + + # Remove + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_BINARY_SENSOR) is None + assert hass.states.get(ENTITY_LIGHT) is None + + assert f"Failed to setup listener for {FAIL_TO_BIND_IP}" in caplog.text + + async def test_setup_import(hass: HomeAssistant): """Test import from yaml.""" mocked_bulb = _mocked_bulb() @@ -247,6 +349,17 @@ async def test_async_listen_error_late_discovery(hass, caplog): assert config_entry.state is ConfigEntryState.LOADED assert "Failed to connect to bulb at" in caplog.text + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + caplog.clear() + + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert "Failed to connect to bulb at" not in caplog.text + assert config_entry.state is ConfigEntryState.LOADED async def test_async_listen_error_has_host_with_id(hass: HomeAssistant): From 8796eaec81370d1164823768255ff83764b372c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Aug 2021 20:42:23 -0500 Subject: [PATCH 574/903] Add support for USB discovery to ZHA (#54935) * Add USB discovery support to ZHA * dry * dry * Update homeassistant/components/zha/config_flow.py Co-authored-by: Martin Hjelmare * black Co-authored-by: Martin Hjelmare --- homeassistant/components/zha/config_flow.py | 84 +++++++++++ homeassistant/components/zha/manifest.json | 6 + homeassistant/components/zha/strings.json | 6 +- .../components/zha/translations/en.json | 4 + homeassistant/generated/usb.py | 23 ++- script/hassfest/usb.py | 2 +- tests/components/zha/test_config_flow.py | 139 +++++++++++++++++- 7 files changed, 260 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 00aaf7c3625..a305c97d436 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -27,6 +27,24 @@ SUPPORTED_PORT_SETTINGS = ( ) +def _format_port_human_readable( + device: str, + serial_number: str | None, + manufacturer: str | None, + description: str | None, + vid: str | None, + pid: str | None, +) -> str: + device_details = f"{device}, s/n: {serial_number or 'n/a'}" + manufacturer_details = f" - {manufacturer}" if manufacturer else "" + vendor_details = f" - {vid}:{pid}" if vid else "" + full_details = f"{device_details}{manufacturer_details}{vendor_details}" + + if not description: + return full_details + return f"{description[:26]} - {full_details}" + + class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -36,6 +54,8 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize flow instance.""" self._device_path = None self._radio_type = None + self._auto_detected_data = None + self._title = None async def async_step_user(self, user_input=None): """Handle a zha config flow start.""" @@ -92,6 +112,70 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema(schema), ) + async def async_step_usb(self, discovery_info: DiscoveryInfoType): + """Handle usb discovery.""" + vid = discovery_info["vid"] + pid = discovery_info["pid"] + serial_number = discovery_info["serial_number"] + device = discovery_info["device"] + manufacturer = discovery_info["manufacturer"] + description = discovery_info["description"] + await self.async_set_unique_id( + f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" + ) + self._abort_if_unique_id_configured( + updates={ + CONF_DEVICE: {CONF_DEVICE_PATH: self._device_path}, + } + ) + # Check if already configured + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + # If they already have a discovery for deconz + # we ignore the usb discovery as they probably + # want to use it there instead + for flow in self.hass.config_entries.flow.async_progress(): + if flow["handler"] == "deconz": + return self.async_abort(reason="not_zha_device") + + # The Nortek sticks are a special case since they + # have a Z-Wave and a Zigbee radio. We need to reject + # the Z-Wave radio. + if vid == "10C4" and pid == "8A2A" and "ZigBee" not in description: + return self.async_abort(reason="not_zha_device") + + dev_path = await self.hass.async_add_executor_job(get_serial_by_id, device) + self._auto_detected_data = await detect_radios(dev_path) + if self._auto_detected_data is None: + return self.async_abort(reason="not_zha_device") + self._device_path = dev_path + self._title = _format_port_human_readable( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + self._set_confirm_only() + self.context["title_placeholders"] = {CONF_NAME: self._title} + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None): + """Confirm a discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self._title, + data=self._auto_detected_data, + ) + + return self.async_show_form( + step_id="confirm", + description_placeholders={CONF_NAME: self._title}, + data_schema=vol.Schema({}), + ) + async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): """Handle zeroconf discovery.""" # Hostname is format: livingroom.local. diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 5200c0a8b31..57d926042db 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -15,6 +15,12 @@ "zigpy-zigate==0.7.3", "zigpy-znp==0.5.3" ], + "usb": [ + {"vid":"10C4","pid":"EA60"}, + {"vid":"1CF1","pid":"0030"}, + {"vid":"1A86","pid":"7523"}, + {"vid":"10C4","pid":"8A2A"} + ], "codeowners": ["@dmulcahey", "@adminiuga"], "zeroconf": [ { diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 9abff4e83e2..4b5b429522f 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -7,6 +7,9 @@ "data": { "path": "Serial Device Path" }, "description": "Select serial port for Zigbee radio" }, + "confirm": { + "description": "Do you want to setup {name}?" + }, "pick_radio": { "data": { "radio_type": "Radio Type" }, "title": "Radio Type", @@ -26,7 +29,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "not_zha_device": "This device is not a zha device" } }, "config_panel": { diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index e13aca2cfb1..93d3c5f697a 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "This device is not a zha device", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Do you want to setup {name}?" + }, "pick_radio": { "data": { "radio_type": "Radio Type" diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index d72cbc8c7a5..717640ce2f8 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -5,4 +5,25 @@ To update, run python3 -m script.hassfest # fmt: off -USB = [] # type: ignore +USB = [ + { + "domain": "zha", + "vid": "10C4", + "pid": "EA60" + }, + { + "domain": "zha", + "vid": "1CF1", + "pid": "0030" + }, + { + "domain": "zha", + "vid": "1A86", + "pid": "7523" + }, + { + "domain": "zha", + "vid": "10C4", + "pid": "8A2A" + } +] diff --git a/script/hassfest/usb.py b/script/hassfest/usb.py index 927b87def98..49da04ee03f 100644 --- a/script/hassfest/usb.py +++ b/script/hassfest/usb.py @@ -13,7 +13,7 @@ To update, run python3 -m script.hassfest # fmt: off -USB = {} # type: ignore +USB = {} """.strip() diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 16747980b15..c603e665912 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -8,9 +8,19 @@ import serial.tools.list_ports import zigpy.config from homeassistant import setup +from homeassistant.components.ssdp import ( + ATTR_SSDP_LOCATION, + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, +) from homeassistant.components.zha import config_flow from homeassistant.components.zha.core.const import CONF_RADIO_TYPE, DOMAIN, RadioType -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import ( + SOURCE_SSDP, + SOURCE_USB, + SOURCE_USER, + SOURCE_ZEROCONF, +) from homeassistant.const import CONF_SOURCE from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM @@ -57,6 +67,133 @@ async def test_discovery(detect_mock, hass): } +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb(detect_mock, hass): + """Test usb flow -- radio detected.""" + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + with patch("homeassistant.components.zha.async_setup_entry"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert "zigbee radio" in result2["title"] + assert result2["data"] == { + "device": { + "baudrate": 115200, + "flow_control": None, + "path": "/dev/ttyZIGBEE", + }, + CONF_RADIO_TYPE: "znp", + } + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=False) +async def test_discovery_via_usb_no_radio(detect_mock, hass): + """Test usb flow -- no radio detected.""" + discovery_info = { + "device": "/dev/null", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + assert result["type"] == "abort" + assert result["reason"] == "not_zha_device" + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_rejects_nortek_zwave(detect_mock, hass): + """Test usb flow -- reject the nortek zwave radio.""" + discovery_info = { + "device": "/dev/null", + "vid": "10C4", + "pid": "8A2A", + "serial_number": "612020FD", + "description": "HubZ Smart Home Controller - HubZ Z-Wave Com Port", + "manufacturer": "Silicon Labs", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + assert result["type"] == "abort" + assert result["reason"] == "not_zha_device" + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_already_setup(detect_mock, hass): + """Test usb flow -- already setup.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass) + + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_deconz_already_discovered(detect_mock, hass): + """Test usb flow -- deconz discovered.""" + result = await hass.config_entries.flow.async_init( + "deconz", + data={ + ATTR_SSDP_LOCATION: "http://1.2.3.4:80/", + ATTR_UPNP_MANUFACTURER_URL: "http://www.dresden-elektronik.de", + ATTR_UPNP_SERIAL: "0000000000000000", + }, + context={"source": SOURCE_SSDP}, + ) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "not_zha_device" + + @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_already_setup(detect_mock, hass): From 1075a65bbd770b2c7c07e9d28d019d5e27266a44 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 21 Aug 2021 00:09:27 -0400 Subject: [PATCH 575/903] Remove Configuration CC as choice in zwave_js value device condition (#54962) --- homeassistant/components/zwave_js/device_condition.py | 3 +++ tests/components/zwave_js/test_device_condition.py | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index 4ae8142ec9e..861c15322b3 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -193,6 +193,8 @@ async def async_get_condition_capabilities( return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} if config[CONF_TYPE] == VALUE_TYPE: + # Only show command classes on this node and exclude Configuration CC since it + # is already covered return { "extra_fields": vol.Schema( { @@ -200,6 +202,7 @@ async def async_get_condition_capabilities( { CommandClass(cc.id).value: CommandClass(cc.id).name for cc in sorted(node.command_classes, key=lambda cc: cc.name) # type: ignore[no-any-return] + if cc.id != CommandClass.CONFIGURATION } ), vol.Required(ATTR_PROPERTY): cv.string, diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index dd5507d4c0a..e9ed2266e10 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -433,7 +433,6 @@ async def test_get_condition_capabilities_value( cc_options = [ (133, "ASSOCIATION"), (128, "BATTERY"), - (112, "CONFIGURATION"), (98, "DOOR_LOCK"), (122, "FIRMWARE_UPDATE_MD"), (114, "MANUFACTURER_SPECIFIC"), From 2be50eb5b46c0eb8425c828fac3f58d606ed56a9 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 21 Aug 2021 00:09:52 -0400 Subject: [PATCH 576/903] Add zwave_js device triggers for any zwave value (#54958) * Add zwave_js device triggers for any zwave value * translations * Validate value --- .../components/zwave_js/device_trigger.py | 184 +++++++-- .../components/zwave_js/strings.json | 2 + .../components/zwave_js/translations/en.json | 7 +- .../zwave_js/test_device_trigger.py | 351 ++++++++++++++++++ 4 files changed, 520 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 6d1b611d14f..d2f7c18ca78 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -2,7 +2,7 @@ from __future__ import annotations import voluptuous as vol -from zwave_js_server.const import CommandClass +from zwave_js_server.const import CommandClass, ConfigurationValueType from homeassistant.components.automation import AutomationActionType from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA @@ -23,6 +23,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType +from . import trigger from .const import ( ATTR_COMMAND_CLASS, ATTR_DATA_TYPE, @@ -45,6 +46,7 @@ from .helpers import ( async_get_node_status_sensor_entity_id, get_zwave_value_from_config, ) +from .triggers.value_updated import ATTR_FROM, ATTR_TO CONF_SUBTYPE = "subtype" CONF_VALUE_ID = "value_id" @@ -55,8 +57,18 @@ NOTIFICATION_NOTIFICATION = "event.notification.notification" BASIC_VALUE_NOTIFICATION = "event.value_notification.basic" CENTRAL_SCENE_VALUE_NOTIFICATION = "event.value_notification.central_scene" SCENE_ACTIVATION_VALUE_NOTIFICATION = "event.value_notification.scene_activation" +CONFIG_PARAMETER_VALUE_UPDATED = f"{DOMAIN}.value_updated.config_parameter" +VALUE_VALUE_UPDATED = f"{DOMAIN}.value_updated.value" NODE_STATUS = "state.node_status" +VALUE_SCHEMA = vol.Any( + bool, + vol.Coerce(int), + vol.Coerce(float), + cv.boolean, + cv.string, +) + NOTIFICATION_EVENT_CC_MAPPINGS = ( (ENTRY_CONTROL_NOTIFICATION, CommandClass.ENTRY_CONTROL), (NOTIFICATION_NOTIFICATION, CommandClass.NOTIFICATION), @@ -135,12 +147,39 @@ NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend( } ) +# zwave_js.value_updated based trigger schemas +BASE_VALUE_UPDATED_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), + vol.Required(ATTR_PROPERTY): vol.Any(int, str), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(None, vol.Coerce(int), str), + vol.Optional(ATTR_ENDPOINT): vol.Any(None, vol.Coerce(int)), + vol.Optional(ATTR_FROM): VALUE_SCHEMA, + vol.Optional(ATTR_TO): VALUE_SCHEMA, + } +) + +CONFIG_PARAMETER_VALUE_UPDATED_SCHEMA = BASE_VALUE_UPDATED_SCHEMA.extend( + { + vol.Required(CONF_TYPE): CONFIG_PARAMETER_VALUE_UPDATED, + vol.Required(CONF_SUBTYPE): cv.string, + } +) + +VALUE_VALUE_UPDATED_SCHEMA = BASE_VALUE_UPDATED_SCHEMA.extend( + { + vol.Required(CONF_TYPE): VALUE_VALUE_UPDATED, + } +) + TRIGGER_SCHEMA = vol.Any( ENTRY_CONTROL_NOTIFICATION_SCHEMA, NOTIFICATION_NOTIFICATION_SCHEMA, BASIC_VALUE_NOTIFICATION_SCHEMA, CENTRAL_SCENE_VALUE_NOTIFICATION_SCHEMA, SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA, + CONFIG_PARAMETER_VALUE_UPDATED_SCHEMA, + VALUE_VALUE_UPDATED_SCHEMA, NODE_STATUS_SCHEMA, ) @@ -233,6 +272,25 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: ] ) + # Generic value update event trigger + triggers.append({**base_trigger, CONF_TYPE: VALUE_VALUE_UPDATED}) + + # Config parameter value update event triggers + triggers.extend( + [ + { + **base_trigger, + CONF_TYPE: CONFIG_PARAMETER_VALUE_UPDATED, + ATTR_PROPERTY: config_value.property_, + ATTR_PROPERTY_KEY: config_value.property_key, + ATTR_ENDPOINT: config_value.endpoint, + ATTR_COMMAND_CLASS: config_value.command_class, + CONF_SUBTYPE: f"{config_value.value_id} ({config_value.property_name})", + } + for config_value in node.get_configuration_values().values() + ] + ) + return triggers @@ -253,20 +311,25 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] - trigger_platform = trigger_type.split(".")[0] - - event_data = {CONF_DEVICE_ID: config[CONF_DEVICE_ID]} - event_config = { - event.CONF_PLATFORM: "event", - event.CONF_EVENT_DATA: event_data, - } - - if ATTR_COMMAND_CLASS in config: - event_data[ATTR_COMMAND_CLASS] = config[ATTR_COMMAND_CLASS] + trigger_split = trigger_type.split(".") + # Our convention for trigger types is to have the trigger type at the beginning + # delimited by a `.`. For zwave_js triggers, there is a `.` in the name + trigger_platform = trigger_split[0] + if trigger_platform == DOMAIN: + trigger_platform = ".".join(trigger_split[:2]) # Take input data from automation trigger UI and add it to the trigger we are # attaching to if trigger_platform == "event": + event_data = {CONF_DEVICE_ID: config[CONF_DEVICE_ID]} + event_config = { + event.CONF_PLATFORM: "event", + event.CONF_EVENT_DATA: event_data, + } + + if ATTR_COMMAND_CLASS in config: + event_data[ATTR_COMMAND_CLASS] = config[ATTR_COMMAND_CLASS] + if trigger_type == ENTRY_CONTROL_NOTIFICATION: event_config[event.CONF_EVENT_TYPE] = ZWAVE_JS_NOTIFICATION_EVENT copy_available_params(config, event_data, [ATTR_EVENT_TYPE, ATTR_DATA_TYPE]) @@ -296,19 +359,53 @@ async def async_attach_trigger( hass, event_config, action, automation_info, platform_type="device" ) - state_config = {state.CONF_PLATFORM: "state"} + if trigger_platform == "state": + if trigger_type == NODE_STATUS: + state_config = {state.CONF_PLATFORM: "state"} - if trigger_platform == "state" and trigger_type == NODE_STATUS: - state_config[state.CONF_ENTITY_ID] = config[CONF_ENTITY_ID] - copy_available_params( - config, state_config, [state.CONF_FOR, state.CONF_FROM, state.CONF_TO] - ) + state_config[state.CONF_ENTITY_ID] = config[CONF_ENTITY_ID] + copy_available_params( + config, state_config, [state.CONF_FOR, state.CONF_FROM, state.CONF_TO] + ) + else: + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") state_config = state.TRIGGER_SCHEMA(state_config) return await state.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) + if trigger_platform == f"{DOMAIN}.value_updated": + # Try to get the value to make sure the value ID is valid + try: + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + get_zwave_value_from_config(node, config) + except (ValueError, vol.Invalid) as err: + raise HomeAssistantError("Invalid value specified") from err + + zwave_js_config = { + state.CONF_PLATFORM: trigger_platform, + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + } + copy_available_params( + config, + zwave_js_config, + [ + ATTR_COMMAND_CLASS, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_ENDPOINT, + ATTR_FROM, + ATTR_TO, + ], + ) + zwave_js_config = await trigger.async_validate_trigger_config( + hass, zwave_js_config + ) + return await trigger.async_attach_trigger( + hass, zwave_js_config, action, automation_info + ) + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") @@ -316,12 +413,14 @@ async def async_get_trigger_capabilities( hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: """List trigger capabilities.""" + trigger_type = config[CONF_TYPE] + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) value = ( get_zwave_value_from_config(node, config) if ATTR_PROPERTY in config else None ) # Add additional fields to the automation trigger UI - if config[CONF_TYPE] == NOTIFICATION_NOTIFICATION: + if trigger_type == NOTIFICATION_NOTIFICATION: return { "extra_fields": vol.Schema( { @@ -333,7 +432,7 @@ async def async_get_trigger_capabilities( ) } - if config[CONF_TYPE] == ENTRY_CONTROL_NOTIFICATION: + if trigger_type == ENTRY_CONTROL_NOTIFICATION: return { "extra_fields": vol.Schema( { @@ -343,7 +442,7 @@ async def async_get_trigger_capabilities( ) } - if config[CONF_TYPE] == NODE_STATUS: + if trigger_type == NODE_STATUS: return { "extra_fields": vol.Schema( { @@ -354,7 +453,7 @@ async def async_get_trigger_capabilities( ) } - if config[CONF_TYPE] in ( + if trigger_type in ( BASIC_VALUE_NOTIFICATION, CENTRAL_SCENE_VALUE_NOTIFICATION, SCENE_ACTIVATION_VALUE_NOTIFICATION, @@ -369,4 +468,47 @@ async def async_get_trigger_capabilities( return {"extra_fields": vol.Schema({vol.Optional(ATTR_VALUE): value_schema})} + if trigger_type == CONFIG_PARAMETER_VALUE_UPDATED: + # We can be more deliberate about the config parameter schema here because + # there are a limited number of types + if value.configuration_value_type == ConfigurationValueType.UNDEFINED: + return {} + if value.configuration_value_type == ConfigurationValueType.ENUMERATED: + value_schema = vol.In({int(k): v for k, v in value.metadata.states.items()}) + else: + value_schema = vol.All( + vol.Coerce(int), + vol.Range(min=value.metadata.min, max=value.metadata.max), + ) + return { + "extra_fields": vol.Schema( + { + vol.Optional(state.CONF_FROM): value_schema, + vol.Optional(state.CONF_TO): value_schema, + } + ) + } + + if trigger_type == VALUE_VALUE_UPDATED: + # Only show command classes on this node and exclude Configuration CC since it + # is already covered + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_COMMAND_CLASS): vol.In( + { + CommandClass(cc.id).value: CommandClass(cc.id).name + for cc in sorted(node.command_classes, key=lambda cc: cc.name) # type: ignore[no-any-return] + if cc.id != CommandClass.CONFIGURATION + } + ), + vol.Required(ATTR_PROPERTY): cv.string, + vol.Optional(ATTR_PROPERTY_KEY): cv.string, + vol.Optional(ATTR_ENDPOINT): cv.string, + vol.Optional(state.CONF_FROM): cv.string, + vol.Optional(state.CONF_TO): cv.string, + } + ) + } + return {} diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 628451a6215..cc5e241c09e 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -104,6 +104,8 @@ "event.value_notification.basic": "Basic CC event on {subtype}", "event.value_notification.central_scene": "Central Scene action on {subtype}", "event.value_notification.scene_activation": "Scene Activation on {subtype}", + "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", "state.node_status": "Node status changed" }, "condition_type": { diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index b742a011d19..7e366724f40 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -63,7 +63,9 @@ "event.value_notification.basic": "Basic CC event on {subtype}", "event.value_notification.central_scene": "Central Scene action on {subtype}", "event.value_notification.scene_activation": "Scene Activation on {subtype}", - "state.node_status": "Node status changed" + "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" } }, "options": { @@ -115,6 +117,5 @@ "title": "The Z-Wave JS add-on is starting." } } - }, - "title": "Z-Wave JS" + } } \ No newline at end of file diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 86e053a5882..f593bb406e8 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -951,6 +951,333 @@ async def test_get_trigger_capabilities_scene_activation_value_notification( ] +async def test_get_value_updated_value_triggers( + hass, client, lock_schlage_be469, integration +): + """Test we get the zwave_js.value_updated.value trigger from a zwave_js device.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "zwave_js.value_updated.value", + "device_id": device.id, + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_value_updated_value_fires( + hass, client, lock_schlage_be469, integration, calls +): + """Test for zwave_js.value_updated.value trigger firing.""" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "property_key": None, + "endpoint": None, + "from": "open", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "zwave_js.value_updated.value - " + "{{ trigger.platform}} - " + "{{ trigger.previous_value }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake value update that shouldn't trigger + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "insideHandlesCanOpenDoor", + "newValue": [True, False, False, False], + "prevValue": [False, False, False, False], + "propertyName": "insideHandlesCanOpenDoor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Publish fake value update that should trigger + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "closed", + "prevValue": "open", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == "zwave_js.value_updated.value - zwave_js.value_updated - open" + ) + + +async def test_get_trigger_capabilities_value_updated_value( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from a zwave_js.value_updated.value trigger.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "property_key": None, + "endpoint": None, + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "command_class", + "required": True, + "type": "select", + "options": [ + (133, "ASSOCIATION"), + (128, "BATTERY"), + (98, "DOOR_LOCK"), + (122, "FIRMWARE_UPDATE_MD"), + (114, "MANUFACTURER_SPECIFIC"), + (113, "NOTIFICATION"), + (152, "SECURITY"), + (99, "USER_CODE"), + (134, "VERSION"), + ], + }, + {"name": "property", "required": True, "type": "string"}, + {"name": "property_key", "optional": True, "type": "string"}, + {"name": "endpoint", "optional": True, "type": "string"}, + {"name": "from", "optional": True, "type": "string"}, + {"name": "to", "optional": True, "type": "string"}, + ] + + +async def test_get_value_updated_config_parameter_triggers( + hass, client, lock_schlage_be469, integration +): + """Test we get the zwave_js.value_updated.config_parameter trigger from a zwave_js device.""" + node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "zwave_js.value_updated.config_parameter", + "device_id": device.id, + "property": 3, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_value_updated_config_parameter_fires( + hass, client, lock_schlage_be469, integration, calls +): + """Test for zwave_js.value_updated.config_parameter trigger firing.""" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.config_parameter", + "property": 3, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + "from": 255, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "zwave_js.value_updated.config_parameter - " + "{{ trigger.platform}} - " + "{{ trigger.previous_value_raw }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake value update + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 3, + "newValue": 0, + "prevValue": 255, + "propertyName": "Beeper", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == "zwave_js.value_updated.config_parameter - zwave_js.value_updated - 255" + ) + + +async def test_get_trigger_capabilities_value_updated_config_parameter_range( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from a range zwave_js.value_updated.config_parameter trigger.""" + node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.config_parameter", + "property": 6, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-6 (User Slot Status)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "from", + "optional": True, + "valueMin": 0, + "valueMax": 255, + "type": "integer", + }, + { + "name": "to", + "optional": True, + "valueMin": 0, + "valueMax": 255, + "type": "integer", + }, + ] + + +async def test_get_trigger_capabilities_value_updated_config_parameter_enumerated( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from an enumerated zwave_js.value_updated.config_parameter trigger.""" + node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.config_parameter", + "property": 3, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "from", + "optional": True, + "options": [(0, "Disable Beeper"), (255, "Enable Beeper")], + "type": "select", + }, + { + "name": "to", + "optional": True, + "options": [(0, "Disable Beeper"), (255, "Enable Beeper")], + "type": "select", + }, + ] + + async def test_failure_scenarios(hass, client, hank_binary_switch, integration): """Test failure scenarios.""" with pytest.raises(HomeAssistantError): @@ -982,6 +1309,30 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): {}, ) + with pytest.raises(HomeAssistantError): + await async_attach_trigger( + hass, + {"type": "state.failed_type", "device_id": device.id}, + None, + {}, + ) + + with pytest.raises(HomeAssistantError): + await async_attach_trigger( + hass, + { + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": -1234, + "property_key": None, + "endpoint": None, + "from": "open", + }, + None, + {}, + ) + with patch( "homeassistant.components.zwave_js.device_trigger.async_get_node_from_device_id", return_value=None, From 726acc38c6ef846c3cb16372fd6843c0361abb98 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 21 Aug 2021 02:57:20 -0400 Subject: [PATCH 577/903] Improve Command Class choices for zwave_js device triggers and conditions (#54970) --- .../components/zwave_js/device_condition.py | 2 +- .../components/zwave_js/device_trigger.py | 2 +- .../zwave_js/test_device_condition.py | 18 +++++++++--------- .../components/zwave_js/test_device_trigger.py | 18 +++++++++--------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index 861c15322b3..2eac4b7d7b0 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -200,7 +200,7 @@ async def async_get_condition_capabilities( { vol.Required(ATTR_COMMAND_CLASS): vol.In( { - CommandClass(cc.id).value: CommandClass(cc.id).name + CommandClass(cc.id).value: cc.name for cc in sorted(node.command_classes, key=lambda cc: cc.name) # type: ignore[no-any-return] if cc.id != CommandClass.CONFIGURATION } diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index d2f7c18ca78..e0588e0ea4e 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -497,7 +497,7 @@ async def async_get_trigger_capabilities( { vol.Required(ATTR_COMMAND_CLASS): vol.In( { - CommandClass(cc.id).value: CommandClass(cc.id).name + CommandClass(cc.id).value: cc.name for cc in sorted(node.command_classes, key=lambda cc: cc.name) # type: ignore[no-any-return] if cc.id != CommandClass.CONFIGURATION } diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index e9ed2266e10..73ac9957071 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -431,15 +431,15 @@ async def test_get_condition_capabilities_value( assert capabilities and "extra_fields" in capabilities cc_options = [ - (133, "ASSOCIATION"), - (128, "BATTERY"), - (98, "DOOR_LOCK"), - (122, "FIRMWARE_UPDATE_MD"), - (114, "MANUFACTURER_SPECIFIC"), - (113, "NOTIFICATION"), - (152, "SECURITY"), - (99, "USER_CODE"), - (134, "VERSION"), + (133, "Association"), + (128, "Battery"), + (98, "Door Lock"), + (122, "Firmware Update Meta Data"), + (114, "Manufacturer Specific"), + (113, "Notification"), + (152, "Security"), + (99, "User Code"), + (134, "Version"), ] assert voluptuous_serialize.convert( diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index f593bb406e8..c7cd8e23943 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -1085,15 +1085,15 @@ async def test_get_trigger_capabilities_value_updated_value( "required": True, "type": "select", "options": [ - (133, "ASSOCIATION"), - (128, "BATTERY"), - (98, "DOOR_LOCK"), - (122, "FIRMWARE_UPDATE_MD"), - (114, "MANUFACTURER_SPECIFIC"), - (113, "NOTIFICATION"), - (152, "SECURITY"), - (99, "USER_CODE"), - (134, "VERSION"), + (133, "Association"), + (128, "Battery"), + (98, "Door Lock"), + (122, "Firmware Update Meta Data"), + (114, "Manufacturer Specific"), + (113, "Notification"), + (152, "Security"), + (99, "User Code"), + (134, "Version"), ], }, {"name": "property", "required": True, "type": "string"}, From de6e7ea016743e3dccbfe9bac0519650108ca387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 21 Aug 2021 10:20:09 +0300 Subject: [PATCH 578/903] Replace *args and **kwargs type hint collections with value types (#54955) --- homeassistant/components/guardian/switch.py | 4 ++-- homeassistant/components/netatmo/climate.py | 6 +++--- homeassistant/components/netatmo/light.py | 6 +++--- homeassistant/components/rainmachine/switch.py | 8 ++++---- homeassistant/components/simplisafe/lock.py | 4 ++-- homeassistant/components/switcher_kis/switch.py | 4 ++-- homeassistant/components/zwave_js/lock.py | 8 +++----- homeassistant/helpers/deprecation.py | 4 ++-- homeassistant/helpers/template.py | 2 +- 9 files changed, 22 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index f3621a72952..7343a1f5c27 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -201,7 +201,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while upgrading firmware: %s", err) - async def async_turn_off(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the valve off (closed).""" try: async with self._client: @@ -213,7 +213,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): self._attr_is_on = False self.async_write_ha_state() - async def async_turn_on(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the valve on (open).""" try: async with self._client: diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 6622c891df1..e71b6939982 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import Any, cast import pyatmo import voluptuous as vol @@ -439,7 +439,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): """Return a list of available preset modes.""" return SUPPORT_PRESET - async def async_set_temperature(self, **kwargs: dict) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature for 2 hours.""" temp = kwargs.get(ATTR_TEMPERATURE) if temp is None: @@ -589,7 +589,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): return {} - async def _async_service_set_schedule(self, **kwargs: dict) -> None: + async def _async_service_set_schedule(self, **kwargs: Any) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_id = None for sid, name in self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].items(): diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 6fe5e84e65a..34c0d023edc 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import Any, cast import pyatmo @@ -141,7 +141,7 @@ class NetatmoLight(NetatmoBase, LightEntity): """Return true if light is on.""" return self._is_on - async def async_turn_on(self, **kwargs: dict) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn camera floodlight on.""" _LOGGER.debug("Turn camera '%s' on", self.name) await self._data.async_set_state( @@ -150,7 +150,7 @@ class NetatmoLight(NetatmoBase, LightEntity): floodlight="on", ) - async def async_turn_off(self, **kwargs: dict) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn camera floodlight into auto mode.""" _LOGGER.debug("Turn camera '%s' to auto mode", self.name) await self._data.async_set_state( diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 9554b22d783..361a737218d 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -313,13 +313,13 @@ class RainMachineProgram(RainMachineSwitch): """Return a list of active zones associated with this program.""" return [z for z in self._data["wateringTimes"] if z["active"]] - async def async_turn_off(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the program off.""" await self._async_run_switch_coroutine( self._controller.programs.stop(self._uid) ) - async def async_turn_on(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the program on.""" await self._async_run_switch_coroutine( self._controller.programs.start(self._uid) @@ -353,11 +353,11 @@ class RainMachineProgram(RainMachineSwitch): class RainMachineZone(RainMachineSwitch): """A RainMachine zone.""" - async def async_turn_off(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" await self._async_run_switch_coroutine(self._controller.zones.stop(self._uid)) - async def async_turn_on(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the zone on.""" await self._async_run_switch_coroutine( self._controller.zones.start( diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index 3068e6209ae..e5f6faba10f 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -46,7 +46,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): self._lock = lock - async def async_lock(self, **kwargs: dict[str, Any]) -> None: + async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" try: await self._lock.lock() @@ -57,7 +57,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): self._attr_is_locked = True self.async_write_ha_state() - async def async_unlock(self, **kwargs: dict[str, Any]) -> None: + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" try: await self._lock.unlock() diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index c36fd0c208e..0eeeb881f45 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -137,13 +137,13 @@ class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): return bool(self.wrapper.data.device_state == DeviceState.ON) - async def async_turn_on(self, **kwargs: dict) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._async_call_api("control_device", Command.ON) self.control_result = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs: dict) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._async_call_api("control_device", Command.OFF) self.control_result = False diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index ad4a736d63e..696310b5ad1 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -103,9 +103,7 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): ] ) == int(self.info.primary_value.value) - async def _set_lock_state( - self, target_state: str, **kwargs: dict[str, Any] - ) -> None: + async def _set_lock_state(self, target_state: str, **kwargs: Any) -> None: """Set the lock state.""" target_value: ZwaveValue = self.get_zwave_value( LOCK_CMD_CLASS_TO_PROPERTY_MAP[self.info.primary_value.command_class] @@ -116,11 +114,11 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): STATE_TO_ZWAVE_MAP[self.info.primary_value.command_class][target_state], ) - async def async_lock(self, **kwargs: dict[str, Any]) -> None: + async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self._set_lock_state(STATE_LOCKED) - async def async_unlock(self, **kwargs: dict[str, Any]) -> None: + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" await self._set_lock_state(STATE_UNLOCKED) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index adf3d8a5d88..e20748913ba 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -87,7 +87,7 @@ def deprecated_class(replacement: str) -> Any: """Decorate class as deprecated.""" @functools.wraps(cls) - def deprecated_cls(*args: tuple, **kwargs: dict[str, Any]) -> Any: + def deprecated_cls(*args: Any, **kwargs: Any) -> Any: """Wrap for the original class.""" _print_deprecation_warning(cls, replacement, "class") return cls(*args, **kwargs) @@ -104,7 +104,7 @@ def deprecated_function(replacement: str) -> Callable[..., Callable]: """Decorate function as deprecated.""" @functools.wraps(func) - def deprecated_func(*args: tuple, **kwargs: dict[str, Any]) -> Any: + def deprecated_func(*args: Any, **kwargs: Any) -> Any: """Wrap for the original function.""" _print_deprecation_warning(func, replacement, "function") return func(*args, **kwargs) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 08b3956a490..cbeaa07aadc 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -151,7 +151,7 @@ def gen_result_wrapper(kls): class Wrapper(kls, ResultWrapper): """Wrapper of a kls that can store render_result.""" - def __init__(self, *args: tuple, render_result: str | None = None) -> None: + def __init__(self, *args: Any, render_result: str | None = None) -> None: super().__init__(*args) self.render_result = render_result From 5142ebfcc2688aa6b5be0d18a6cca82e358726a5 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 21 Aug 2021 09:34:32 +0200 Subject: [PATCH 579/903] =?UTF-8?q?Add=20fj=C3=A4r=C3=A5skupan=20light=20e?= =?UTF-8?q?ntity=20(#54918)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add fjäråskupan light * Update homeassistant/components/fjaraskupan/light.py Co-authored-by: Martin Hjelmare * Check property * Switch to default coordinator update * Type check constructor Co-authored-by: Martin Hjelmare --- .coveragerc | 1 + .../components/fjaraskupan/__init__.py | 2 +- homeassistant/components/fjaraskupan/light.py | 85 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/fjaraskupan/light.py diff --git a/.coveragerc b/.coveragerc index a3666ff0ac4..4b3525e278c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -321,6 +321,7 @@ omit = homeassistant/components/fjaraskupan/binary_sensor.py homeassistant/components/fjaraskupan/const.py homeassistant/components/fjaraskupan/fan.py + homeassistant/components/fjaraskupan/light.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py homeassistant/components/flic/binary_sensor.py diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 50c07a96c51..59a41e2e83b 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DISPATCH_DETECTION, DOMAIN -PLATFORMS = ["binary_sensor", "fan"] +PLATFORMS = ["binary_sensor", "fan", "light"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py new file mode 100644 index 00000000000..8c44460a099 --- /dev/null +++ b/homeassistant/components/fjaraskupan/light.py @@ -0,0 +1,85 @@ +"""Support for lights.""" +from __future__ import annotations + +from fjaraskupan import COMMAND_LIGHT_ON_OFF, Device, State + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + COLOR_MODE_BRIGHTNESS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeviceState, async_setup_entry_platform + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up tuya sensors dynamically through tuya discovery.""" + + def _constructor(device_state: DeviceState) -> list[Entity]: + return [ + Light( + device_state.coordinator, device_state.device, device_state.device_info + ) + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class Light(CoordinatorEntity[State], LightEntity): + """Light device.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + ) -> None: + """Init light entity.""" + super().__init__(coordinator) + self._device = device + self._attr_color_mode = COLOR_MODE_BRIGHTNESS + self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + self._attr_unique_id = device.address + self._attr_device_info = device_info + self._attr_name = device_info["name"] + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + await self._device.send_dim(int(kwargs[ATTR_BRIGHTNESS] * (100.0 / 255.0))) + else: + if not self.is_on: + await self._device.send_command(COMMAND_LIGHT_ON_OFF) + self.coordinator.async_set_updated_data(self._device.state) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + if self.is_on: + await self._device.send_command(COMMAND_LIGHT_ON_OFF) + self.coordinator.async_set_updated_data(self._device.state) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if data := self.coordinator.data: + return data.light_on + return False + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255.""" + if data := self.coordinator.data: + return int(data.dim_level * (255.0 / 100.0)) + return None From 69e413ac1ef9d109d6d3423cecb0edb9b1e46004 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 21 Aug 2021 10:41:23 +0200 Subject: [PATCH 580/903] Update pylint to 2.10.1 (#54963) * Update pylint to 2.10.0 * useless-suppression * Consider-using-tuple * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Use dict.items() * Add pylint disable * Use pylint 2.10.1 Co-authored-by: Paulus Schoutsen --- homeassistant/components/braviatv/config_flow.py | 2 +- homeassistant/components/camera/img_util.py | 2 -- homeassistant/components/climate/__init__.py | 2 -- homeassistant/components/isy994/binary_sensor.py | 14 ++++---------- homeassistant/components/light/__init__.py | 2 +- homeassistant/components/media_player/__init__.py | 4 ---- homeassistant/components/nfandroidtv/notify.py | 2 +- homeassistant/components/notify/__init__.py | 1 - homeassistant/components/rest/__init__.py | 2 +- homeassistant/components/supla/__init__.py | 2 +- homeassistant/components/surepetcare/__init__.py | 4 ---- homeassistant/components/telegram_bot/__init__.py | 2 +- homeassistant/components/vallox/__init__.py | 2 -- homeassistant/helpers/entity.py | 1 - requirements_test.txt | 2 +- 15 files changed, 11 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 159f3806d61..8e59033ffc8 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -157,7 +157,7 @@ class BraviaTVOptionsFlowHandler(config_entries.OptionsFlow): content_mapping = await self.hass.async_add_executor_job( braviarc.load_source_list ) - self.source_list = {item: item for item in [*content_mapping]} + self.source_list = {item: item for item in content_mapping} return await self.async_step_user() async def async_step_user( diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index 4cfb4fda278..279bc57672a 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -69,8 +69,6 @@ class TurboJPEGSingleton: def __init__(self) -> None: """Try to create TurboJPEG only once.""" - # pylint: disable=unused-private-member - # https://github.com/PyCQA/pylint/issues/4681 try: # TurboJPEG checks for libturbojpeg # when its created, but it imports diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 6a46c1986b8..98e37237792 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -504,7 +504,6 @@ class ClimateEntity(Entity): async def async_turn_on(self) -> None: """Turn the entity on.""" if hasattr(self, "turn_on"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.turn_on) # type: ignore[attr-defined] return @@ -518,7 +517,6 @@ class ClimateEntity(Entity): async def async_turn_off(self) -> None: """Turn the entity off.""" if hasattr(self, "turn_off"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.turn_off) # type: ignore[attr-defined] return diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index c091ca6f96a..58997eaa579 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -184,20 +184,14 @@ def _detect_device_type_and_class(node: Group | Node) -> (str, str): # Z-Wave Devices: if node.protocol == PROTO_ZWAVE: device_type = f"Z{node.zwave_props.category}" - for device_class in [*BINARY_SENSOR_DEVICE_TYPES_ZWAVE]: - if ( - node.zwave_props.category - in BINARY_SENSOR_DEVICE_TYPES_ZWAVE[device_class] - ): + for device_class, values in BINARY_SENSOR_DEVICE_TYPES_ZWAVE.items(): + if node.zwave_props.category in values: return device_class, device_type return (None, device_type) # Other devices (incl Insteon.) - for device_class in [*BINARY_SENSOR_DEVICE_TYPES_ISY]: - if any( - device_type.startswith(t) - for t in set(BINARY_SENSOR_DEVICE_TYPES_ISY[device_class]) - ): + for device_class, values in BINARY_SENSOR_DEVICE_TYPES_ISY.items(): + if any(device_type.startswith(t) for t in values): return device_class, device_type return (None, device_type) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 51f387a2cdb..0ef877e6247 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -529,7 +529,7 @@ class Profile: transition: int | None = None hs_color: tuple[float, float] | None = dataclasses.field(init=False) - SCHEMA = vol.Schema( # pylint: disable=invalid-name + SCHEMA = vol.Schema( vol.Any( vol.ExactSequence( ( diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 2b80736bc7a..b0030786ed7 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -813,7 +813,6 @@ class MediaPlayerEntity(Entity): async def async_toggle(self): """Toggle the power on the media player.""" if hasattr(self, "toggle"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.toggle) return @@ -828,7 +827,6 @@ class MediaPlayerEntity(Entity): This method is a coroutine. """ if hasattr(self, "volume_up"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.volume_up) return @@ -841,7 +839,6 @@ class MediaPlayerEntity(Entity): This method is a coroutine. """ if hasattr(self, "volume_down"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.volume_down) return @@ -851,7 +848,6 @@ class MediaPlayerEntity(Entity): async def async_media_play_pause(self): """Play or pause the media player.""" if hasattr(self, "media_play_pause"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.media_play_pause) return diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index 8cc1b0031f7..0f15b152038 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -201,7 +201,7 @@ class NFAndroidTVNotificationService(BaseNotificationService): if local_path is not None: # Check whether path is whitelisted in configuration.yaml if self.is_allowed_path(local_path): - return open(local_path, "rb") # pylint: disable=consider-using-with + return open(local_path, "rb") _LOGGER.warning("'%s' is not secure to load data from!", local_path) else: _LOGGER.warning("Neither URL nor local path found in params!") diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 41953ddfc75..00047f0a32b 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -183,7 +183,6 @@ class BaseNotificationService: if hasattr(self, "targets"): stale_targets = set(self.registered_targets) - # pylint: disable=no-member for name, target in self.targets.items(): # type: ignore target_name = slugify(f"{self._target_service_name_prefix}_{name}") if target_name in stale_targets: diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 42c342a2c84..8186db1c3c2 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -68,7 +68,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @callback def _async_setup_shared_data(hass: HomeAssistant): """Create shared data for platform config and rest coordinators.""" - hass.data[DOMAIN] = {key: [] for key in [REST_DATA, *COORDINATOR_AWARE_PLATFORMS]} + hass.data[DOMAIN] = {key: [] for key in (REST_DATA, *COORDINATOR_AWARE_PLATFORMS)} async def _async_process_config(hass, config) -> bool: diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 5ebd6d6ca48..c8862a37b61 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -104,7 +104,7 @@ async def discover_devices(hass, hass_config): async with async_timeout.timeout(SCAN_INTERVAL.total_seconds()): channels = { channel["id"]: channel - for channel in await server.get_channels( + for channel in await server.get_channels( # pylint: disable=cell-var-from-loop include=["iodevice", "state", "connected"] ) } diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 87a3260fc40..4ac27ce25a0 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -111,8 +111,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Lower, vol.In( [ - # https://github.com/PyCQA/pylint/issues/2062 - # pylint: disable=no-member LockState.UNLOCKED.name.lower(), LockState.LOCKED_IN.name.lower(), LockState.LOCKED_OUT.name.lower(), @@ -156,8 +154,6 @@ class SurePetcareAPI: async def set_lock_state(self, flap_id: int, state: str) -> None: """Update the lock state of a flap.""" - # https://github.com/PyCQA/pylint/issues/2062 - # pylint: disable=no-member if state == LockState.UNLOCKED.name.lower(): await self.surepy.sac.unlock(flap_id) elif state == LockState.LOCKED_IN.name.lower(): diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 02629e695fc..84b7249d203 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -286,7 +286,7 @@ def load_data( _LOGGER.warning("Can't load data in %s after %s retries", url, retry_num) elif filepath is not None: if hass.config.is_allowed_path(filepath): - return open(filepath, "rb") # pylint: disable=consider-using-with + return open(filepath, "rb") _LOGGER.warning("'%s' are not secure to load data from!", filepath) else: diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 27210e0c750..6f88afa66cf 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -40,7 +40,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -# pylint: disable=no-member PROFILE_TO_STR_SETTABLE = { VALLOX_PROFILE.HOME: "Home", VALLOX_PROFILE.AWAY: "Away", @@ -50,7 +49,6 @@ PROFILE_TO_STR_SETTABLE = { STR_TO_PROFILE = {v: k for (k, v) in PROFILE_TO_STR_SETTABLE.items()} -# pylint: disable=no-member PROFILE_TO_STR_REPORTABLE = { **{VALLOX_PROFILE.NONE: "None", VALLOX_PROFILE.EXTRA: "Extra"}, **PROFILE_TO_STR_SETTABLE, diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 63e84371fad..847dc062764 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -622,7 +622,6 @@ class Entity(ABC): await self.parallel_updates.acquire() try: - # pylint: disable=no-member if hasattr(self, "async_update"): task = self.hass.async_create_task(self.async_update()) # type: ignore elif hasattr(self, "update"): diff --git a/requirements_test.txt b/requirements_test.txt index 63e102ec77e..7aee2dfb332 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ jsonpickle==1.4.1 mock-open==1.4.0 mypy==0.910 pre-commit==2.14.0 -pylint==2.9.5 +pylint==2.10.1 pipdeptree==1.0.0 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 From 4bdeba8631e469eac00374935401741f2f0ae17f Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 21 Aug 2021 11:09:23 +0200 Subject: [PATCH 581/903] =?UTF-8?q?Add=20fj=C3=A4r=C3=A5skupan=20sensor=20?= =?UTF-8?q?(#54921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add fjäråskupan sensor * Update homeassistant/components/fjaraskupan/sensor.py Co-authored-by: Martin Hjelmare * Type the return value of constructor * Update __init__.py Co-authored-by: Martin Hjelmare --- .coveragerc | 1 + .../components/fjaraskupan/__init__.py | 2 +- .../components/fjaraskupan/sensor.py | 66 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/fjaraskupan/sensor.py diff --git a/.coveragerc b/.coveragerc index 4b3525e278c..35d60239a28 100644 --- a/.coveragerc +++ b/.coveragerc @@ -322,6 +322,7 @@ omit = homeassistant/components/fjaraskupan/const.py homeassistant/components/fjaraskupan/fan.py homeassistant/components/fjaraskupan/light.py + homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py homeassistant/components/flic/binary_sensor.py diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 59a41e2e83b..9d635e3bf7f 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DISPATCH_DETECTION, DOMAIN -PLATFORMS = ["binary_sensor", "fan", "light"] +PLATFORMS = ["binary_sensor", "fan", "light", "sensor"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py new file mode 100644 index 00000000000..306517c4146 --- /dev/null +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -0,0 +1,66 @@ +"""Support for sensors.""" +from __future__ import annotations + +from fjaraskupan import Device, State + +from homeassistant.components.sensor import ( + DEVICE_CLASS_SIGNAL_STRENGTH, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeviceState, async_setup_entry_platform + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors dynamically through discovery.""" + + def _constructor(device_state: DeviceState) -> list[Entity]: + return [ + RssiSensor( + device_state.coordinator, device_state.device, device_state.device_info + ) + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class RssiSensor(CoordinatorEntity[State], SensorEntity): + """Sensor device.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + ) -> None: + """Init sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{device.address}-signal-strength" + self._attr_device_info = device_info + self._attr_name = f"{device_info['name']} Signal Strength" + self._attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + self._attr_entity_registry_enabled_default = False + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + if data := self.coordinator.data: + return data.rssi + return None From 7e5ff825ddd717c4afb29aae970da67b69c2b3aa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 21 Aug 2021 11:46:00 +0200 Subject: [PATCH 582/903] Enable basic type checking for adguard (#54924) --- homeassistant/components/adguard/__init__.py | 2 +- homeassistant/components/adguard/config_flow.py | 8 ++++++-- homeassistant/components/adguard/sensor.py | 4 ++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 8f0c73a8a64..eedc1fe4b03 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -198,7 +198,7 @@ class AdGuardHomeDeviceEntity(AdGuardHomeEntity): """Return device information about this AdGuard Home instance.""" return { "identifiers": { - (DOMAIN, self.adguard.host, self.adguard.port, self.adguard.base_path) + (DOMAIN, self.adguard.host, self.adguard.port, self.adguard.base_path) # type: ignore }, "name": "AdGuard Home", "manufacturer": "AdGuard Team", diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py index bbb6d34954b..aa85345179e 100644 --- a/homeassistant/components/adguard/config_flow.py +++ b/homeassistant/components/adguard/config_flow.py @@ -51,6 +51,7 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN): self, errors: dict[str, str] | None = None ) -> FlowResult: """Show the Hass.io confirmation form to the user.""" + assert self._hassio_discovery return self.async_show_form( step_id="hassio_confirm", description_placeholders={"addon": self._hassio_discovery["addon"]}, @@ -73,11 +74,13 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN): session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) + username: str | None = user_input.get(CONF_USERNAME) + password: str | None = user_input.get(CONF_PASSWORD) adguard = AdGuardHome( user_input[CONF_HOST], port=user_input[CONF_PORT], - username=user_input.get(CONF_USERNAME), - password=user_input.get(CONF_PASSWORD), + username=username, # type:ignore[arg-type] + password=password, # type:ignore[arg-type] tls=user_input[CONF_SSL], verify_ssl=user_input[CONF_VERIFY_SSL], session=session, @@ -122,6 +125,7 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN): session = async_get_clientsession(self.hass, False) + assert self._hassio_discovery adguard = AdGuardHome( self._hassio_discovery[CONF_HOST], port=self._hassio_discovery[CONF_PORT], diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index a7f4dabde9f..8134d2c4d43 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -62,7 +62,7 @@ class AdGuardHomeSensor(AdGuardHomeDeviceEntity, SensorEntity): enabled_default: bool = True, ) -> None: """Initialize AdGuard Home sensor.""" - self._state = None + self._state: int | str | None = None self._unit_of_measurement = unit_of_measurement self.measurement = measurement @@ -82,7 +82,7 @@ class AdGuardHomeSensor(AdGuardHomeDeviceEntity, SensorEntity): ) @property - def native_value(self) -> str | None: + def native_value(self) -> int | str | None: """Return the state of the sensor.""" return self._state diff --git a/mypy.ini b/mypy.ini index 954c15725b3..94c13689f6b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1265,9 +1265,6 @@ no_implicit_optional = false warn_return_any = false warn_unreachable = false -[mypy-homeassistant.components.adguard.*] -ignore_errors = true - [mypy-homeassistant.components.almond.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index cb8be180af2..aec5843be38 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -14,7 +14,6 @@ from .model import Config, Integration # remove your component from this list to enable type checks. # Do your best to not add anything new here. IGNORED_MODULES: Final[list[str]] = [ - "homeassistant.components.adguard.*", "homeassistant.components.almond.*", "homeassistant.components.analytics.*", "homeassistant.components.atag.*", From efd15344e9c2af356e972130f62803413cf261b7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 21 Aug 2021 11:46:28 +0200 Subject: [PATCH 583/903] Enable basic type checking for analytics (#54928) --- homeassistant/components/analytics/__init__.py | 4 ++-- homeassistant/components/analytics/analytics.py | 9 +++++++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 944acc6ef9d..f7bdb303eb7 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -36,8 +36,8 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: @websocket_api.require_admin -@websocket_api.async_response @websocket_api.websocket_command({vol.Required("type"): "analytics"}) +@websocket_api.async_response async def websocket_analytics( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, @@ -52,13 +52,13 @@ async def websocket_analytics( @websocket_api.require_admin -@websocket_api.async_response @websocket_api.websocket_command( { vol.Required("type"): "analytics/preferences", vol.Required("preferences", default={}): PREFERENCE_SCHEMA, } ) +@websocket_api.async_response async def websocket_analytics_preferences( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 37aff988162..d7fa781945d 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -1,5 +1,6 @@ """Analytics helper class for the analytics integration.""" import asyncio +from typing import cast import uuid import aiohttp @@ -64,7 +65,11 @@ class Analytics: """Initialize the Analytics class.""" self.hass: HomeAssistant = hass self.session = async_get_clientsession(hass) - self._data = {ATTR_PREFERENCES: {}, ATTR_ONBOARDED: False, ATTR_UUID: None} + self._data: dict = { + ATTR_PREFERENCES: {}, + ATTR_ONBOARDED: False, + ATTR_UUID: None, + } self._store: Store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) @property @@ -103,7 +108,7 @@ class Analytics: async def load(self) -> None: """Load preferences.""" - stored = await self._store.async_load() + stored = cast(dict, await self._store.async_load()) if stored: self._data = stored diff --git a/mypy.ini b/mypy.ini index 94c13689f6b..59b292d398d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1268,9 +1268,6 @@ warn_unreachable = false [mypy-homeassistant.components.almond.*] ignore_errors = true -[mypy-homeassistant.components.analytics.*] -ignore_errors = true - [mypy-homeassistant.components.atag.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index aec5843be38..fda2e12a442 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -15,7 +15,6 @@ from .model import Config, Integration # Do your best to not add anything new here. IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.almond.*", - "homeassistant.components.analytics.*", "homeassistant.components.atag.*", "homeassistant.components.awair.*", "homeassistant.components.azure_event_hub.*", From fedd958dc021eab83523189ce1ba3561c71fcb7d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 21 Aug 2021 13:39:56 +0200 Subject: [PATCH 584/903] Enable basic type checking for atag (#54933) --- homeassistant/components/atag/climate.py | 2 +- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index 6bafd59ab82..1a5e2a597cf 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -47,7 +47,7 @@ class AtagThermostat(AtagEntity, ClimateEntity): self._attr_temperature_unit = coordinator.data.climate.temp_unit @property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> str | None: # type: ignore[override] """Return hvac operation ie. heat, cool mode.""" if self.coordinator.data.climate.hvac_mode in HVAC_MODES: return self.coordinator.data.climate.hvac_mode diff --git a/mypy.ini b/mypy.ini index 59b292d398d..1e7d7cecd60 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1268,9 +1268,6 @@ warn_unreachable = false [mypy-homeassistant.components.almond.*] ignore_errors = true -[mypy-homeassistant.components.atag.*] -ignore_errors = true - [mypy-homeassistant.components.awair.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index fda2e12a442..45779673e18 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -15,7 +15,6 @@ from .model import Config, Integration # Do your best to not add anything new here. IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.almond.*", - "homeassistant.components.atag.*", "homeassistant.components.awair.*", "homeassistant.components.azure_event_hub.*", "homeassistant.components.blueprint.*", From de354f96feb1d95b98e82b15348b9cd6dec5f2ce Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Sat, 21 Aug 2021 13:53:28 +0200 Subject: [PATCH 585/903] Remove unused string in P1 Monitor (#54911) --- homeassistant/components/p1_monitor/strings.json | 3 +-- homeassistant/components/p1_monitor/translations/en.json | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/p1_monitor/strings.json b/homeassistant/components/p1_monitor/strings.json index c28a7129006..fba7973528e 100644 --- a/homeassistant/components/p1_monitor/strings.json +++ b/homeassistant/components/p1_monitor/strings.json @@ -10,8 +10,7 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } } } \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/en.json b/homeassistant/components/p1_monitor/translations/en.json index 34b64082b43..4bd61c19bdc 100644 --- a/homeassistant/components/p1_monitor/translations/en.json +++ b/homeassistant/components/p1_monitor/translations/en.json @@ -1,8 +1,7 @@ { "config": { "error": { - "cannot_connect": "Failed to connect", - "unknown": "Unexpected error" + "cannot_connect": "Failed to connect" }, "step": { "user": { From 59809503d1c3419968a29f8d99101e1ac97be4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 21 Aug 2021 14:58:49 +0300 Subject: [PATCH 586/903] Various type hint related improvements (#54971) * Avoid some implicit generic Anys * Fix hassio discovery view type hints * Fix http view result type in assert message --- homeassistant/components/actiontec/const.py | 4 +++- homeassistant/components/hassio/discovery.py | 6 +++--- homeassistant/components/http/view.py | 2 +- homeassistant/helpers/condition.py | 4 +++- homeassistant/helpers/event.py | 10 +++++----- homeassistant/helpers/trace.py | 2 +- homeassistant/helpers/translation.py | 10 ++++++---- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/actiontec/const.py b/homeassistant/components/actiontec/const.py index 1043bd1bdb6..de309b68476 100644 --- a/homeassistant/components/actiontec/const.py +++ b/homeassistant/components/actiontec/const.py @@ -4,7 +4,9 @@ from __future__ import annotations import re from typing import Final -LEASES_REGEX: Final[re.Pattern] = re.compile( +# mypy: disallow-any-generics + +LEASES_REGEX: Final[re.Pattern[str]] = re.compile( r"(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})" + r"\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))" + r"\svalid\sfor:\s(?P(-?\d+))" diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index e7f8df3b61d..9f15ff6e8b8 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -8,7 +8,7 @@ from aiohttp.web_exceptions import HTTPServiceUnavailable from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import ATTR_NAME, ATTR_SERVICE, EVENT_HOMEASSISTANT_START -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from .const import ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_UUID from .handler import HassioAPIError @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_setup_discovery_view(hass: HomeAssistantView, hassio): +def async_setup_discovery_view(hass: HomeAssistant, hassio): """Discovery setup.""" hassio_discovery = HassIODiscovery(hass, hassio) hass.http.register_view(hassio_discovery) @@ -49,7 +49,7 @@ class HassIODiscovery(HomeAssistantView): name = "api:hassio_push:discovery" url = "/api/hassio_push/discovery/{uuid}" - def __init__(self, hass: HomeAssistantView, hassio): + def __init__(self, hass: HomeAssistant, hassio): """Initialize WebView.""" self.hass = hass self.hassio = hassio diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 9abf0914b06..129c43600c4 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -158,7 +158,7 @@ def request_handler_factory( else: assert ( False - ), f"Result should be None, string, bytes or Response. Got: {result}" + ), f"Result should be None, string, bytes or StreamResponse. Got: {result}" return web.Response(body=bresult, status=status_code) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 6f5e7c40d22..f90086f87ee 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -69,6 +69,8 @@ from .trace import ( trace_stack_top, ) +# mypy: disallow-any-generics + FROM_CONFIG_FORMAT = "{}_from_config" ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" @@ -113,7 +115,7 @@ def condition_trace_update_result(**kwargs: Any) -> None: @contextmanager -def trace_condition(variables: TemplateVarsType) -> Generator: +def trace_condition(variables: TemplateVarsType) -> Generator[TraceElement, None, None]: """Trace condition evaluation.""" should_pop = True trace_element = trace_stack_top(trace_stack_cv) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index ab54d159f5e..5d5f71d2fd5 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -71,8 +71,8 @@ class TrackStates: """ all_states: bool - entities: set - domains: set + entities: set[str] + domains: set[str] @dataclass @@ -394,7 +394,7 @@ def async_track_entity_registry_updated_event( @callback def _async_dispatch_domain_event( - hass: HomeAssistant, event: Event, callbacks: dict[str, list] + hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob]] ) -> None: domain = split_entity_id(event.data["entity_id"])[0] @@ -620,7 +620,7 @@ class _TrackStateChangeFiltered: self._listeners.pop(listener_name)() @callback - def _setup_entities_listener(self, domains: set, entities: set) -> None: + def _setup_entities_listener(self, domains: set[str], entities: set[str]) -> None: if domains: entities = entities.copy() entities.update(self.hass.states.async_entity_ids(domains)) @@ -634,7 +634,7 @@ class _TrackStateChangeFiltered: ) @callback - def _setup_domains_listener(self, domains: set) -> None: + def _setup_domains_listener(self, domains: set[str]) -> None: if not domains: return diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index e25cf814b2a..58b0dc19d43 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -21,7 +21,7 @@ class TraceElement: self._child_run_id: str | None = None self._error: Exception | None = None self.path: str = path - self._result: dict | None = None + self._result: dict[str, Any] | None = None self.reuse_by_child = False self._timestamp = dt_util.utcnow() diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index a77cf3c2227..e10c814389b 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -17,6 +17,8 @@ from homeassistant.loader import ( from homeassistant.util.async_ import gather_with_concurrency from homeassistant.util.json import load_json +# mypy: disallow-any-generics + _LOGGER = logging.getLogger(__name__) TRANSLATION_LOAD_LOCK = "translation_load_lock" @@ -24,7 +26,7 @@ TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache" LOCALE_EN = "en" -def recursive_flatten(prefix: Any, data: dict) -> dict[str, Any]: +def recursive_flatten(prefix: Any, data: dict[str, Any]) -> dict[str, Any]: """Return a flattened representation of dict data.""" output = {} for key, value in data.items(): @@ -212,7 +214,7 @@ class _TranslationCache: self, language: str, category: str, - components: set, + components: set[str], ) -> list[dict[str, dict[str, Any]]]: """Load resources into the cache.""" components_to_load = components - self.loaded.setdefault(language, set()) @@ -224,7 +226,7 @@ class _TranslationCache: return [cached.get(component, {}).get(category, {}) for component in components] - async def _async_load(self, language: str, components: set) -> None: + async def _async_load(self, language: str, components: set[str]) -> None: """Populate the cache for a given set of components.""" _LOGGER.debug( "Cache miss for %s: %s", @@ -247,7 +249,7 @@ class _TranslationCache: def _build_category_cache( self, language: str, - components: set, + components: set[str], translation_strings: dict[str, dict[str, Any]], ) -> None: """Extract resources into the cache.""" From 2cfd78bc49860319182abb99432afd2e2393a2ad Mon Sep 17 00:00:00 2001 From: carstenschroeder Date: Sat, 21 Aug 2021 14:17:19 +0200 Subject: [PATCH 587/903] Minor refactoring of keba integration (#54976) * minor refactoring * fix type annotation --- homeassistant/components/keba/sensor.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py index a1e0387c707..fc761074bc7 100644 --- a/homeassistant/components/keba/sensor.py +++ b/homeassistant/components/keba/sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( POWER_KILO_WATT, ) -from . import DOMAIN +from . import DOMAIN, KebaHandler async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -90,14 +90,13 @@ class KebaSensor(SensorEntity): def __init__( self, - keba, - entity_type, + keba: KebaHandler, + entity_type: str, description: SensorEntityDescription, - ): + ) -> None: """Initialize the KEBA Sensor.""" self._keba = keba self.entity_description = description - self._entity_type = entity_type self._attr_name = f"{keba.device_name} {description.name}" self._attr_unique_id = f"{keba.device_id}_{entity_type}" From c609236a6346ed54091992221a0c1a261aae5f86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Aug 2021 07:24:21 -0500 Subject: [PATCH 588/903] Move get_serial_by_id and human_readable_device_name to usb (#54968) --- homeassistant/components/usb/__init__.py | 32 ++++++++ homeassistant/components/zha/config_flow.py | 40 ++-------- homeassistant/components/zha/manifest.json | 2 +- tests/components/usb/test_init.py | 83 ++++++++++++++++++++- tests/components/zha/test_config_flow.py | 50 +------------ 5 files changed, 121 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index ff8bb5fae88..7fd27b32b9a 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import dataclasses import datetime import logging +import os import sys from serial.tools.list_ports import comports @@ -26,6 +27,37 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(minutes=60) +def human_readable_device_name( + device: str, + serial_number: str | None, + manufacturer: str | None, + description: str | None, + vid: str | None, + pid: str | None, +) -> str: + """Return a human readable name from USBDevice attributes.""" + device_details = f"{device}, s/n: {serial_number or 'n/a'}" + manufacturer_details = f" - {manufacturer}" if manufacturer else "" + vendor_details = f" - {vid}:{pid}" if vid else "" + full_details = f"{device_details}{manufacturer_details}{vendor_details}" + + if not description: + return full_details + return f"{description[:26]} - {full_details}" + + +def get_serial_by_id(dev_path: str) -> str: + """Return a /dev/serial/by-id match for given device if available.""" + by_id = "/dev/serial/by-id" + if not os.path.isdir(by_id): + return dev_path + + for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): + if os.path.realpath(path) == dev_path: + return path + return dev_path + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the USB Discovery integration.""" usb = await async_get_usb(hass) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index a305c97d436..2d8443642e7 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1,7 +1,6 @@ """Config flow for ZHA.""" from __future__ import annotations -import os from typing import Any import serial.tools.list_ports @@ -9,6 +8,7 @@ import voluptuous as vol from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries +from homeassistant.components import usb from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.typing import DiscoveryInfoType @@ -27,24 +27,6 @@ SUPPORTED_PORT_SETTINGS = ( ) -def _format_port_human_readable( - device: str, - serial_number: str | None, - manufacturer: str | None, - description: str | None, - vid: str | None, - pid: str | None, -) -> str: - device_details = f"{device}, s/n: {serial_number or 'n/a'}" - manufacturer_details = f" - {manufacturer}" if manufacturer else "" - vendor_details = f" - {vid}:{pid}" if vid else "" - full_details = f"{device_details}{manufacturer_details}{vendor_details}" - - if not description: - return full_details - return f"{description[:26]} - {full_details}" - - class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -81,7 +63,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): port = ports[list_of_ports.index(user_selection)] dev_path = await self.hass.async_add_executor_job( - get_serial_by_id, port.device + usb.get_serial_by_id, port.device ) auto_detected_data = await detect_radios(dev_path) if auto_detected_data is not None: @@ -145,12 +127,12 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if vid == "10C4" and pid == "8A2A" and "ZigBee" not in description: return self.async_abort(reason="not_zha_device") - dev_path = await self.hass.async_add_executor_job(get_serial_by_id, device) + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) self._auto_detected_data = await detect_radios(dev_path) if self._auto_detected_data is None: return self.async_abort(reason="not_zha_device") self._device_path = dev_path - self._title = _format_port_human_readable( + self._title = usb.human_readable_device_name( dev_path, serial_number, manufacturer, @@ -215,7 +197,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._device_path = user_input.get(CONF_DEVICE_PATH) if await app_cls.probe(user_input): serial_by_id = await self.hass.async_add_executor_job( - get_serial_by_id, user_input[CONF_DEVICE_PATH] + usb.get_serial_by_id, user_input[CONF_DEVICE_PATH] ) user_input[CONF_DEVICE_PATH] = serial_by_id return self.async_create_entry( @@ -255,15 +237,3 @@ async def detect_radios(dev_path: str) -> dict[str, Any] | None: return {CONF_RADIO_TYPE: radio.name, CONF_DEVICE: dev_config} return None - - -def get_serial_by_id(dev_path: str) -> str: - """Return a /dev/serial/by-id match for given device if available.""" - by_id = "/dev/serial/by-id" - if not os.path.isdir(by_id): - return dev_path - - for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): - if os.path.realpath(path) == dev_path: - return path - return dev_path diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 57d926042db..5df8cddc167 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -28,6 +28,6 @@ "name": "tube*" } ], - "after_dependencies": ["zeroconf"], + "after_dependencies": ["usb", "zeroconf"], "iot_class": "local_polling" } diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index cb547edc939..e511fac061e 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1,10 +1,12 @@ """Tests for the USB Discovery integration.""" import datetime +import os import sys -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, sentinel import pytest +from homeassistant.components import usb from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -271,3 +273,82 @@ async def test_non_matching_discovered_by_scanner_after_started(hass): await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 + + +def test_get_serial_by_id_no_dir(): + """Test serial by id conversion if there's no /dev/serial/by-id.""" + p1 = patch("os.path.isdir", MagicMock(return_value=False)) + p2 = patch("os.scandir") + with p1 as is_dir_mock, p2 as scan_mock: + res = usb.get_serial_by_id(sentinel.path) + assert res is sentinel.path + assert is_dir_mock.call_count == 1 + assert scan_mock.call_count == 0 + + +def test_get_serial_by_id(): + """Test serial by id conversion.""" + p1 = patch("os.path.isdir", MagicMock(return_value=True)) + p2 = patch("os.scandir") + + def _realpath(path): + if path is sentinel.matched_link: + return sentinel.path + return sentinel.serial_link_path + + p3 = patch("os.path.realpath", side_effect=_realpath) + with p1 as is_dir_mock, p2 as scan_mock, p3: + res = usb.get_serial_by_id(sentinel.path) + assert res is sentinel.path + assert is_dir_mock.call_count == 1 + assert scan_mock.call_count == 1 + + entry1 = MagicMock(spec_set=os.DirEntry) + entry1.is_symlink.return_value = True + entry1.path = sentinel.some_path + + entry2 = MagicMock(spec_set=os.DirEntry) + entry2.is_symlink.return_value = False + entry2.path = sentinel.other_path + + entry3 = MagicMock(spec_set=os.DirEntry) + entry3.is_symlink.return_value = True + entry3.path = sentinel.matched_link + + scan_mock.return_value = [entry1, entry2, entry3] + res = usb.get_serial_by_id(sentinel.path) + assert res is sentinel.matched_link + assert is_dir_mock.call_count == 2 + assert scan_mock.call_count == 2 + + +def test_human_readable_device_name(): + """Test human readable device name includes the passed data.""" + name = usb.human_readable_device_name( + "/dev/null", + "612020FD", + "Silicon Labs", + "HubZ Smart Home Controller - HubZ Z-Wave Com Port", + "10C4", + "8A2A", + ) + assert "/dev/null" in name + assert "612020FD" in name + assert "Silicon Labs" in name + assert "HubZ Smart Home Controller - HubZ Z-Wave Com Port"[:26] in name + assert "10C4" in name + assert "8A2A" in name + + name = usb.human_readable_device_name( + "/dev/null", + "612020FD", + "Silicon Labs", + None, + "10C4", + "8A2A", + ) + assert "/dev/null" in name + assert "612020FD" in name + assert "Silicon Labs" in name + assert "10C4" in name + assert "8A2A" in name diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index c603e665912..ed975f77eae 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,7 +1,6 @@ """Tests for ZHA config flow.""" -import os -from unittest.mock import AsyncMock, MagicMock, patch, sentinel +from unittest.mock import AsyncMock, MagicMock, patch import pytest import serial.tools.list_ports @@ -400,50 +399,3 @@ async def test_user_port_config(probe_mock, hass): ) assert result["data"][CONF_RADIO_TYPE] == "ezsp" assert probe_mock.await_count == 1 - - -def test_get_serial_by_id_no_dir(): - """Test serial by id conversion if there's no /dev/serial/by-id.""" - p1 = patch("os.path.isdir", MagicMock(return_value=False)) - p2 = patch("os.scandir") - with p1 as is_dir_mock, p2 as scan_mock: - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.path - assert is_dir_mock.call_count == 1 - assert scan_mock.call_count == 0 - - -def test_get_serial_by_id(): - """Test serial by id conversion.""" - p1 = patch("os.path.isdir", MagicMock(return_value=True)) - p2 = patch("os.scandir") - - def _realpath(path): - if path is sentinel.matched_link: - return sentinel.path - return sentinel.serial_link_path - - p3 = patch("os.path.realpath", side_effect=_realpath) - with p1 as is_dir_mock, p2 as scan_mock, p3: - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.path - assert is_dir_mock.call_count == 1 - assert scan_mock.call_count == 1 - - entry1 = MagicMock(spec_set=os.DirEntry) - entry1.is_symlink.return_value = True - entry1.path = sentinel.some_path - - entry2 = MagicMock(spec_set=os.DirEntry) - entry2.is_symlink.return_value = False - entry2.path = sentinel.other_path - - entry3 = MagicMock(spec_set=os.DirEntry) - entry3.is_symlink.return_value = True - entry3.path = sentinel.matched_link - - scan_mock.return_value = [entry1, entry2, entry3] - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.matched_link - assert is_dir_mock.call_count == 2 - assert scan_mock.call_count == 2 From 51434c5faaa159ed656f40293c7fd48d99c26604 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Aug 2021 07:24:45 -0500 Subject: [PATCH 589/903] Gracefully handle udev not available via OSError when setting up usb (#54967) --- homeassistant/components/usb/__init__.py | 2 +- tests/components/usb/test_init.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 7fd27b32b9a..115b0fc3de9 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -113,7 +113,7 @@ class USBDiscovery: try: context = Context() - except ImportError: + except (ImportError, OSError): return False monitor = Monitor.from_netlink(context) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index e511fac061e..f7b642c3390 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -243,7 +243,8 @@ async def test_discovered_by_scanner_after_started_no_vid_pid(hass): assert len(mock_config_flow.mock_calls) == 0 -async def test_non_matching_discovered_by_scanner_after_started(hass): +@pytest.mark.parametrize("exception_type", [ImportError, OSError]) +async def test_non_matching_discovered_by_scanner_after_started(hass, exception_type): """Test a device is discovered by the scanner after the started event that does not match.""" new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] @@ -258,7 +259,7 @@ async def test_non_matching_discovered_by_scanner_after_started(hass): ) ] - with patch("pyudev.Context", side_effect=ImportError), patch( + 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 From 33f660118f9078bc1fcb905d2f00470b61c84d3c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 21 Aug 2021 15:49:50 +0200 Subject: [PATCH 590/903] Add lazy_error_count to modbus (#54412) * Add lazy_error_count. * Use -= * Review comments. --- .coveragerc | 1 + homeassistant/components/modbus/__init__.py | 2 ++ homeassistant/components/modbus/base_platform.py | 8 ++++++++ homeassistant/components/modbus/binary_sensor.py | 5 +++++ homeassistant/components/modbus/climate.py | 5 +++++ homeassistant/components/modbus/const.py | 1 + homeassistant/components/modbus/cover.py | 7 ++++++- homeassistant/components/modbus/sensor.py | 5 +++++ tests/components/modbus/test_binary_sensor.py | 2 ++ tests/components/modbus/test_climate.py | 2 ++ tests/components/modbus/test_cover.py | 2 ++ tests/components/modbus/test_fan.py | 2 ++ tests/components/modbus/test_light.py | 2 ++ tests/components/modbus/test_sensor.py | 2 ++ tests/components/modbus/test_switch.py | 2 ++ 15 files changed, 47 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index 35d60239a28..de0c947eecb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -646,6 +646,7 @@ omit = homeassistant/components/modbus/cover.py homeassistant/components/modbus/climate.py homeassistant/components/modbus/modbus.py + homeassistant/components/modbus/sensor.py homeassistant/components/modbus/validators.py homeassistant/components/modem_callerid/sensor.py homeassistant/components/motion_blinds/__init__.py diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index e98a61257c6..0ff3b67c79f 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -65,6 +65,7 @@ from .const import ( CONF_DATA_TYPE, CONF_FANS, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_MSG_WAIT, @@ -136,6 +137,7 @@ BASE_COMPONENT_SCHEMA = vol.Schema( vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.positive_int, + vol.Optional(CONF_LAZY_ERROR, default=0): cv.positive_int, } ) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 468e61aefa8..1e2f1056db2 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -36,6 +36,7 @@ from .const import ( CALL_TYPE_X_REGISTER_HOLDINGS, CONF_DATA_TYPE, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_PRECISION, CONF_SCALE, CONF_STATE_OFF, @@ -76,6 +77,8 @@ class BasePlatform(Entity): self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_available = True self._attr_unit_of_measurement = None + self._lazy_error_count = entry[CONF_LAZY_ERROR] + self._lazy_errors = self._lazy_error_count @abstractmethod async def async_update(self, now=None): @@ -245,10 +248,15 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): ) self._call_active = False if result is None: + if self._lazy_errors: + self._lazy_errors -= 1 + return + self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return + self._lazy_errors = self._lazy_error_count self._attr_available = True if self._verify_type == CALL_TYPE_COIL: self._attr_is_on = bool(result.bits[0] & 1) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 08ebfc72880..adc5e2d28f1 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -57,10 +57,15 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): ) self._call_active = False if result is None: + if self._lazy_errors: + self._lazy_errors -= 1 + return + self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return + self._lazy_errors = self._lazy_error_count self._attr_is_on = result.bits[0] & 1 self._attr_available = True self.async_write_ha_state() diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 831f3c979cc..0a89610a2f5 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -162,9 +162,14 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._slave, register, self._count, register_type ) if result is None: + if self._lazy_errors: + self._lazy_errors -= 1 + return -1 + self._lazy_errors = self._lazy_error_count self._attr_available = False return -1 + self._lazy_errors = self._lazy_error_count self._value = self.unpack_structure_result(result.registers) self._attr_available = True diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 01e0fdd5e13..3bcd85053d2 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -28,6 +28,7 @@ CONF_FANS = "fans" CONF_HUB = "hub" CONF_INPUTS = "inputs" CONF_INPUT_TYPE = "input_type" +CONF_LAZY_ERROR = "lazy_error_count" CONF_MAX_TEMP = "max_temp" CONF_MIN_TEMP = "min_temp" CONF_MSG_WAIT = "message_wait_milliseconds" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 64165412d27..5fa77eb1cb8 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -146,9 +146,14 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): ) self._call_active = False if result is None: + if self._lazy_errors: + self._lazy_errors -= 1 + return + self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() - return None + return + self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._set_attr_state(bool(result.bits[0] & 1)) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 3165f416a6e..2041f8974da 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -64,10 +64,15 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): self._slave, self._address, self._count, self._input_type ) if result is None: + if self._lazy_errors: + self._lazy_errors -= 1 + return + self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return self._attr_native_value = self.unpack_structure_result(result.registers) + self._lazy_errors = self._lazy_error_count self._attr_available = True self.async_write_ha_state() diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index fb52ea11090..a710a8b0598 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, ) from homeassistant.const import ( CONF_ADDRESS, @@ -44,6 +45,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" CONF_SLAVE: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", + CONF_LAZY_ERROR: 10, } ] }, diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 16ef18a60ac..d7096d91b44 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -6,6 +6,7 @@ from homeassistant.components.climate.const import HVAC_MODE_AUTO from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, + CONF_LAZY_ERROR, CONF_TARGET_TEMP, DATA_TYPE_FLOAT32, DATA_TYPE_FLOAT64, @@ -49,6 +50,7 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}" CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, CONF_COUNT: 2, + CONF_LAZY_ERROR: 10, } ], }, diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index a315d8176ae..2f502587949 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -8,6 +8,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OPEN, @@ -54,6 +55,7 @@ ENTITY_ID = f"{COVER_DOMAIN}.{TEST_ENTITY_NAME}" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, + CONF_LAZY_ERROR: 10, } ] }, diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 821a5cace99..a54b2212fd5 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -10,6 +10,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_FANS, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -73,6 +74,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{TEST_ENTITY_NAME}" CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, + CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 486dfdc64f8..bb4bb7b08f9 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -9,6 +9,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -62,6 +63,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{TEST_ENTITY_NAME}" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_LAZY_ERROR: 10, } ] }, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index e69a6be41a4..07fe8ada2d0 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DATA_TYPE, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_PRECISION, CONF_REGISTERS, CONF_SCALE, @@ -62,6 +63,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" CONF_PRECISION: 0, CONF_SCALE: 1, CONF_OFFSET: 0, + CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index fb929d26caf..064d9cf7965 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -11,6 +11,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -70,6 +71,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{TEST_ENTITY_NAME}" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_LAZY_ERROR: 10, } ] }, From a7d8e2b817a22a96c114657ec8e6acb12df31e00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Aug 2021 09:30:45 -0500 Subject: [PATCH 591/903] Add support for USB discovery to zwave_js (#54938) Co-authored-by: Martin Hjelmare --- .../components/zwave_js/config_flow.py | 73 ++++++- .../components/zwave_js/manifest.json | 9 +- .../components/zwave_js/strings.json | 8 +- .../components/zwave_js/translations/en.json | 8 +- homeassistant/generated/usb.py | 15 ++ tests/components/zwave_js/test_config_flow.py | 194 ++++++++++++++++++ 6 files changed, 300 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index ced8b2c68cb..6b0fc7b692e 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -12,8 +12,9 @@ import voluptuous as vol from zwave_js_server.version import VersionInfo, get_server_version from homeassistant import config_entries, exceptions +from homeassistant.components import usb from homeassistant.components.hassio import is_hassio -from homeassistant.const import CONF_URL +from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import ( AbortFlow, @@ -286,6 +287,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): """Set up flow instance.""" super().__init__() self.use_addon = False + self._title: str | None = None @property def flow_manager(self) -> config_entries.ConfigEntriesFlowManager: @@ -309,6 +311,64 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_manual() + async def async_step_usb(self, discovery_info: dict[str, str]) -> FlowResult: + """Handle USB Discovery.""" + if not is_hassio(self.hass): + return self.async_abort(reason="discovery_requires_supervisor") + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + + vid = discovery_info["vid"] + pid = discovery_info["pid"] + serial_number = discovery_info["serial_number"] + device = discovery_info["device"] + manufacturer = discovery_info["manufacturer"] + description = discovery_info["description"] + # The Nortek sticks are a special case since they + # have a Z-Wave and a Zigbee radio. We need to reject + # the Zigbee radio. + if vid == "10C4" and pid == "8A2A" and "Z-Wave" not in description: + return self.async_abort(reason="not_zwave_device") + # Zooz uses this vid/pid, but so do 2652 sticks + if vid == "10C4" and pid == "EA60" and "2652" in description: + return self.async_abort(reason="not_zwave_device") + + addon_info = await self._async_get_addon_info() + if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.NOT_RUNNING): + return self.async_abort(reason="already_configured") + + await self.async_set_unique_id( + f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" + ) + self._abort_if_unique_id_configured() + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + self.usb_path = dev_path + self._title = usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + self.context["title_placeholders"] = {CONF_NAME: self._title} + return await self.async_step_usb_confirm() + + async def async_step_usb_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle USB Discovery confirmation.""" + if user_input is None: + return self.async_show_form( + step_id="usb_confirm", + description_placeholders={CONF_NAME: self._title}, + data_schema=vol.Schema({}), + ) + + return await self.async_step_on_supervisor({CONF_USE_ADDON: True}) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -352,6 +412,9 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): This flow is triggered by the Z-Wave JS add-on. """ + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" try: version_info = await async_get_version_info(self.hass, self.ws_address) @@ -422,7 +485,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_start_addon() - usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") + usb_path = addon_config.get(CONF_ADDON_DEVICE) or self.usb_path or "" network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, self.network_key or "") data_schema = vol.Schema( @@ -446,7 +509,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): discovery_info = await self._async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - if not self.unique_id: + if not self.unique_id or self.context["source"] == config_entries.SOURCE_USB: if not self.version_info: try: self.version_info = await async_get_version_info( @@ -471,6 +534,10 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): @callback def _async_create_entry_from_vars(self) -> FlowResult: """Return a config entry for the flow.""" + # Abort any other flows that may be in progress + for progress in self._async_in_progress(): + self.hass.config_entries.flow.async_abort(progress["flow_id"]) + return self.async_create_entry( title=TITLE, data={ diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 6f713ed2ef2..5c2d1f0db81 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -5,6 +5,11 @@ "documentation": "https://www.home-assistant.io/integrations/zwave_js", "requirements": ["zwave-js-server-python==0.29.0"], "codeowners": ["@home-assistant/z-wave"], - "dependencies": ["http", "websocket_api"], - "iot_class": "local_push" + "dependencies": ["usb", "http", "websocket_api"], + "iot_class": "local_push", + "usb": [ + {"vid":"0658","pid":"0200"}, + {"vid":"10C4","pid":"8A2A"}, + {"vid":"10C4","pid":"EA60"} + ] } diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index cc5e241c09e..d0bdec1a80c 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,11 +1,15 @@ { "config": { + "flow_title": "{name}", "step": { "manual": { "data": { "url": "[%key:common::config_flow::data::url%]" } }, + "usb_confirm": { + "description": "Do you want to setup {name} with the Z-Wave JS add-on?" + }, "on_supervisor": { "title": "Select connection method", "description": "Do you want to use the Z-Wave JS Supervisor add-on?", @@ -44,7 +48,9 @@ "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 JS add-on discovery info.", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "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." }, "progress": { "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 7e366724f40..6f5d08933db 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -8,7 +8,9 @@ "addon_start_failed": "Failed to start the Z-Wave JS add-on.", "already_configured": "Device is already configured", "already_in_progress": "Configuration flow is already in progress", - "cannot_connect": "Failed to connect" + "cannot_connect": "Failed to connect", + "discovery_requires_supervisor": "Discovery requires the supervisor.", + "not_zwave_device": "Discovered device is not a Z-Wave device." }, "error": { "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Invalid websocket URL", "unknown": "Unexpected error" }, + "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." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "The Z-Wave JS add-on is starting." + }, + "usb_confirm": { + "description": "Do you want to setup {name} with the Z-Wave JS add-on?" } } }, diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 717640ce2f8..cb672c736b2 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -25,5 +25,20 @@ USB = [ "domain": "zha", "vid": "10C4", "pid": "8A2A" + }, + { + "domain": "zwave_js", + "vid": "0658", + "pid": "0200" + }, + { + "domain": "zwave_js", + "vid": "10C4", + "pid": "8A2A" + }, + { + "domain": "zwave_js", + "vid": "10C4", + "pid": "EA60" } ] diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 7d02c215d45..393de228d87 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -20,6 +20,34 @@ ADDON_DISCOVERY_INFO = { } +USB_DISCOVERY_INFO = { + "device": "/dev/zwave", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zwave radio", + "manufacturer": "test", +} + +NORTEK_ZIGBEE_DISCOVERY_INFO = { + "device": "/dev/zigbee", + "pid": "8A2A", + "vid": "10C4", + "serial_number": "1234", + "description": "nortek zigbee radio", + "manufacturer": "nortek", +} + +CP2652_ZIGBEE_DISCOVERY_INFO = { + "device": "/dev/zigbee", + "pid": "EA60", + "vid": "10C4", + "serial_number": "", + "description": "cp2652", + "manufacturer": "generic", +} + + @pytest.fixture(name="persistent_notification", autouse=True) async def setup_persistent_notification(hass): """Set up persistent notification integration.""" @@ -383,6 +411,94 @@ async def test_abort_discovery_with_existing_entry( assert entry.data["url"] == "ws://host1:3001" +async def test_abort_hassio_discovery_with_existing_flow( + hass, supervisor, addon_options +): + """Test hassio discovery flow is aborted when another discovery has happened.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "form" + assert result["step_id"] == "usb_confirm" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_in_progress" + + +async def test_usb_discovery( + hass, + supervisor, + install_addon, + addon_options, + get_addon_discovery_info, + set_addon_options, + start_addon, +): + """Test usb discovery success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "form" + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == "progress" + assert result["step_id"] == "install_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert install_addon.call_args == call(hass, "core_zwave_js") + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert set_addon_options.call_args == call( + hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + ) + + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call(hass, "core_zwave_js") + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"]["usb_path"] == "/test" + assert result["data"]["integration_created_addon"] is True + assert result["data"]["use_addon"] is True + assert result["data"]["network_key"] == "abc123" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_discovery_addon_not_running( hass, supervisor, addon_installed, addon_options, set_addon_options, start_addon ): @@ -512,6 +628,84 @@ async def test_discovery_addon_not_installed( assert len(mock_setup_entry.mock_calls) == 1 +async def test_abort_usb_discovery_with_existing_flow(hass, supervisor, addon_options): + """Test usb discovery flow is aborted when another discovery has happened.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["type"] == "form" + assert result["step_id"] == "hassio_confirm" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result2["type"] == "abort" + assert result2["reason"] == "already_in_progress" + + +async def test_abort_usb_discovery_already_configured(hass, supervisor, addon_options): + """Test usb discovery flow is aborted when there is an existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, data={"url": "ws://localhost:3000"}, title=TITLE, unique_id=1234 + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_usb_discovery_requires_supervisor(hass): + """Test usb discovery flow is aborted when there is no supervisor.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "abort" + assert result["reason"] == "discovery_requires_supervisor" + + +async def test_usb_discovery_already_running(hass, supervisor, addon_running): + """Test usb discovery flow is aborted when the addon is running.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "discovery_info", + [ + NORTEK_ZIGBEE_DISCOVERY_INFO, + CP2652_ZIGBEE_DISCOVERY_INFO, + ], +) +async def test_abort_usb_discovery_aborts_specific_devices( + hass, supervisor, addon_options, discovery_info +): + """Test usb discovery flow is aborted on specific devices.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=discovery_info, + ) + assert result["type"] == "abort" + assert result["reason"] == "not_zwave_device" + + async def test_not_addon(hass, supervisor): """Test opting out of add-on on Supervisor.""" await setup.async_setup_component(hass, "persistent_notification", {}) From aa7c72a8b5d68e59214d181c1d8c2149001e86c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Aug 2021 12:00:39 -0500 Subject: [PATCH 592/903] Bump sqlalchemy to 1.4.23 (#54980) - Changelog: https://docs.sqlalchemy.org/en/14/changelog/changelog_14.html#change-1.4.23 --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 7e4c9d9b9fa..4558e11c076 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -2,7 +2,7 @@ "domain": "recorder", "name": "Recorder", "documentation": "https://www.home-assistant.io/integrations/recorder", - "requirements": ["sqlalchemy==1.4.17"], + "requirements": ["sqlalchemy==1.4.23"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index a2a197a0eb0..4796dac11a9 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -2,7 +2,7 @@ "domain": "sql", "name": "SQL", "documentation": "https://www.home-assistant.io/integrations/sql", - "requirements": ["sqlalchemy==1.4.17"], + "requirements": ["sqlalchemy==1.4.23"], "codeowners": ["@dgomes"], "iot_class": "local_polling" } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 99e6fa77bbf..669ad7f7484 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ pyyaml==5.4.1 requests==2.25.1 ruamel.yaml==0.15.100 scapy==2.4.5 -sqlalchemy==1.4.17 +sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 diff --git a/requirements_all.txt b/requirements_all.txt index cf746042737..e59469e072b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2200,7 +2200,7 @@ spotipy==2.18.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.17 +sqlalchemy==1.4.23 # homeassistant.components.srp_energy srpenergy==1.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d803cb63fa2..26efbca6e95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1231,7 +1231,7 @@ spotipy==2.18.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.17 +sqlalchemy==1.4.23 # homeassistant.components.srp_energy srpenergy==1.3.2 From 4a6ca8a04e5b1f84dcd80d7cab97726e887fbff1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 21 Aug 2021 19:09:42 +0200 Subject: [PATCH 593/903] Add `number` platform for Xiaomi Miio fan (#54977) --- .../components/xiaomi_miio/__init__.py | 2 +- homeassistant/components/xiaomi_miio/fan.py | 73 +--------- .../components/xiaomi_miio/number.py | 127 ++++++++++++++++-- .../components/xiaomi_miio/services.yaml | 75 ----------- 4 files changed, 115 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 97e52f84dfc..0559cab9461 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -46,7 +46,7 @@ _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] SWITCH_PLATFORMS = ["switch"] -FAN_PLATFORMS = ["fan", "select", "sensor", "switch"] +FAN_PLATFORMS = ["fan", "number", "select", "sensor", "switch"] HUMIDIFIER_PLATFORMS = [ "binary_sensor", "humidifier", diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index c3e3bce289a..54ce701cf92 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -35,9 +35,6 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_V3, FEATURE_RESET_FILTER, FEATURE_SET_EXTRA_FEATURES, - FEATURE_SET_FAN_LEVEL, - FEATURE_SET_FAVORITE_LEVEL, - FEATURE_SET_VOLUME, KEY_COORDINATOR, KEY_DEVICE, MODEL_AIRPURIFIER_2H, @@ -48,9 +45,6 @@ from .const import ( MODELS_PURIFIER_MIOT, SERVICE_RESET_FILTER, SERVICE_SET_EXTRA_FEATURES, - SERVICE_SET_FAN_LEVEL, - SERVICE_SET_FAVORITE_LEVEL, - SERVICE_SET_VOLUME, ) from .device import XiaomiCoordinatedMiioEntity @@ -64,9 +58,7 @@ CONF_MODEL = "model" ATTR_MODEL = "model" # Air Purifier -ATTR_FAVORITE_LEVEL = "favorite_level" ATTR_BRIGHTNESS = "brightness" -ATTR_LEVEL = "level" ATTR_FAN_LEVEL = "fan_level" ATTR_SLEEP_TIME = "sleep_time" ATTR_SLEEP_LEARN_COUNT = "sleep_mode_learn_count" @@ -74,14 +66,12 @@ ATTR_EXTRA_FEATURES = "extra_features" ATTR_FEATURES = "features" ATTR_TURBO_MODE_SUPPORTED = "turbo_mode_supported" ATTR_SLEEP_MODE = "sleep_mode" -ATTR_VOLUME = "volume" ATTR_USE_TIME = "use_time" ATTR_BUTTON_PRESSED = "button_pressed" # Map attributes to properties of the state object AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { ATTR_MODE: "mode", - ATTR_FAVORITE_LEVEL: "favorite_level", ATTR_EXTRA_FEATURES: "extra_features", ATTR_TURBO_MODE_SUPPORTED: "turbo_mode_supported", ATTR_BUTTON_PRESSED: "button_pressed", @@ -98,27 +88,20 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER = { AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, ATTR_USE_TIME: "use_time", - ATTR_VOLUME: "volume", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", } AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT = { ATTR_MODE: "mode", - ATTR_FAVORITE_LEVEL: "favorite_level", ATTR_USE_TIME: "use_time", - ATTR_FAN_LEVEL: "fan_level", } -AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 = { - **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, - ATTR_VOLUME: "volume", -} +AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 = AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { # Common set isn't used here. It's a very basic version of the device. ATTR_MODE: "mode", - ATTR_VOLUME: "volume", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", ATTR_EXTRA_FEATURES: "extra_features", @@ -164,33 +147,12 @@ PRESET_MODES_AIRFRESH = ["Auto", "Interval"] AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) -SERVICE_SCHEMA_FAVORITE_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_LEVEL): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=17))} -) - -SERVICE_SCHEMA_FAN_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_LEVEL): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=3))} -) - -SERVICE_SCHEMA_VOLUME = AIRPURIFIER_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_VOLUME): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100))} -) - SERVICE_SCHEMA_EXTRA_FEATURES = AIRPURIFIER_SERVICE_SCHEMA.extend( {vol.Required(ATTR_FEATURES): cv.positive_int} ) SERVICE_TO_METHOD = { SERVICE_RESET_FILTER: {"method": "async_reset_filter"}, - SERVICE_SET_FAVORITE_LEVEL: { - "method": "async_set_favorite_level", - "schema": SERVICE_SCHEMA_FAVORITE_LEVEL, - }, - SERVICE_SET_FAN_LEVEL: { - "method": "async_set_fan_level", - "schema": SERVICE_SCHEMA_FAN_LEVEL, - }, - SERVICE_SET_VOLUME: {"method": "async_set_volume", "schema": SERVICE_SCHEMA_VOLUME}, SERVICE_SET_EXTRA_FEATURES: { "method": "async_set_extra_features", "schema": SERVICE_SCHEMA_EXTRA_FEATURES, @@ -513,39 +475,6 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self.PRESET_MODE_MAPPING[preset_mode], ) - async def async_set_favorite_level(self, level: int = 1): - """Set the favorite level.""" - if self._device_features & FEATURE_SET_FAVORITE_LEVEL == 0: - return - - await self._try_command( - "Setting the favorite level of the miio device failed.", - self._device.set_favorite_level, - level, - ) - - async def async_set_fan_level(self, level: int = 1): - """Set the favorite level.""" - if self._device_features & FEATURE_SET_FAN_LEVEL == 0: - return - - await self._try_command( - "Setting the fan level of the miio device failed.", - self._device.set_fan_level, - level, - ) - - async def async_set_volume(self, volume: int = 50): - """Set the sound volume.""" - if self._device_features & FEATURE_SET_VOLUME == 0: - return - - await self._try_command( - "Setting the sound volume of the miio device failed.", - self._device.set_volume, - volume, - ) - async def async_set_extra_features(self, features: int = 1): """Set the extra features.""" if self._device_features & FEATURE_SET_EXTRA_FEATURES == 0: diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 9a4961bfdf0..d68d845cc5a 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -12,14 +12,40 @@ from .const import ( CONF_FLOW_TYPE, CONF_MODEL, DOMAIN, + FEATURE_FLAGS_AIRFRESH, + FEATURE_FLAGS_AIRHUMIDIFIER_CA4, + FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_MIIO, + FEATURE_FLAGS_AIRPURIFIER_MIOT, + FEATURE_FLAGS_AIRPURIFIER_PRO, + FEATURE_FLAGS_AIRPURIFIER_PRO_V7, + FEATURE_FLAGS_AIRPURIFIER_V1, + FEATURE_FLAGS_AIRPURIFIER_V3, + FEATURE_SET_FAN_LEVEL, + FEATURE_SET_FAVORITE_LEVEL, FEATURE_SET_MOTOR_SPEED, + FEATURE_SET_VOLUME, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRFRESH_VA2, + MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1, + MODEL_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_PRO, + MODEL_AIRPURIFIER_PRO_V7, + MODEL_AIRPURIFIER_V1, + MODEL_AIRPURIFIER_V3, + MODELS_PURIFIER_MIIO, + MODELS_PURIFIER_MIOT, ) from .device import XiaomiCoordinatedMiioEntity +ATTR_FAN_LEVEL = "fan_level" +ATTR_FAVORITE_LEVEL = "favorite_level" ATTR_MOTOR_SPEED = "motor_speed" +ATTR_VOLUME = "volume" @dataclass @@ -30,6 +56,7 @@ class XiaomiMiioNumberDescription(NumberEntityDescription): max_value: float | None = None step: float | None = None available_with_device_off: bool = True + method: str | None = None NUMBER_TYPES = { @@ -42,7 +69,47 @@ NUMBER_TYPES = { max_value=2000, step=10, available_with_device_off=False, + method="async_set_motor_speed", ), + FEATURE_SET_FAVORITE_LEVEL: XiaomiMiioNumberDescription( + key=ATTR_FAVORITE_LEVEL, + name="Favorite Level", + icon="mdi:star-cog", + min_value=0, + max_value=17, + step=1, + method="async_set_favorite_level", + ), + FEATURE_SET_FAN_LEVEL: XiaomiMiioNumberDescription( + key=ATTR_FAN_LEVEL, + name="Fan Level", + icon="mdi:fan", + min_value=1, + max_value=3, + step=1, + method="async_set_fan_level", + ), + FEATURE_SET_VOLUME: XiaomiMiioNumberDescription( + key=ATTR_VOLUME, + name="Volume", + icon="mdi:volume-high", + min_value=1, + max_value=100, + step=1, + method="async_set_volume", + ), +} + +MODEL_TO_FEATURES_MAP = { + MODEL_AIRHUMIDIFIER_CA1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + MODEL_AIRHUMIDIFIER_CA4: FEATURE_FLAGS_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + MODEL_AIRPURIFIER_2S: FEATURE_FLAGS_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_PRO: FEATURE_FLAGS_AIRPURIFIER_PRO, + MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, + MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, + MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, + MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH, } @@ -54,25 +121,32 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = config_entry.data[CONF_MODEL] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - if model != MODEL_AIRHUMIDIFIER_CA4: + if model in MODEL_TO_FEATURES_MAP: + features = MODEL_TO_FEATURES_MAP[model] + elif model in MODELS_PURIFIER_MIIO: + features = FEATURE_FLAGS_AIRPURIFIER_MIIO + elif model in MODELS_PURIFIER_MIOT: + features = FEATURE_FLAGS_AIRPURIFIER_MIOT + else: return - description = NUMBER_TYPES[FEATURE_SET_MOTOR_SPEED] - entities.append( - XiaomiAirHumidifierNumber( - f"{config_entry.title} {description.name}", - device, - config_entry, - f"{description.key}_{config_entry.unique_id}", - hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], - description, - ) - ) + for feature, description in NUMBER_TYPES.items(): + if feature & features: + entities.append( + XiaomiNumberEntity( + f"{config_entry.title} {description.name}", + device, + config_entry, + f"{description.key}_{config_entry.unique_id}", + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, + ) + ) async_add_entities(entities) -class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): +class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Representation of a generic Xiaomi attribute selector.""" def __init__(self, name, device, entry, unique_id, coordinator, description): @@ -108,7 +182,8 @@ class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): async def async_set_value(self, value): """Set an option of the miio device.""" - if await self.async_set_motor_speed(value): + method = getattr(self, self.entity_description.method) + if await method(value): self._attr_value = value self.async_write_ha_state() @@ -128,3 +203,27 @@ class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): self._device.set_speed, motor_speed, ) + + async def async_set_favorite_level(self, level: int = 1): + """Set the favorite level.""" + return await self._try_command( + "Setting the favorite level of the miio device failed.", + self._device.set_favorite_level, + level, + ) + + async def async_set_fan_level(self, level: int = 1): + """Set the fan level.""" + return await self._try_command( + "Setting the favorite level of the miio device failed.", + self._device.set_fan_level, + level, + ) + + async def async_set_volume(self, volume: int = 50): + """Set the volume.""" + return await self._try_command( + "Setting the volume of the miio device failed.", + self._device.set_volume, + volume, + ) diff --git a/homeassistant/components/xiaomi_miio/services.yaml b/homeassistant/components/xiaomi_miio/services.yaml index 250b0404a41..b8f81e1a34d 100644 --- a/homeassistant/components/xiaomi_miio/services.yaml +++ b/homeassistant/components/xiaomi_miio/services.yaml @@ -1,58 +1,3 @@ -fan_set_favorite_level: - name: Fan set favorite level - description: Set the favorite level. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - level: - name: Level - description: Level. - required: true - selector: - number: - min: 0 - max: 17 - -fan_set_fan_level: - name: Fan set level - description: Set the fan level. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - level: - name: Level - description: Level. - selector: - number: - min: 1 - max: 3 - -fan_set_volume: - name: Fan set volume - description: Set the sound volume. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - volume: - description: Volume. - required: true - selector: - number: - min: 0 - max: 100 - fan_reset_filter: name: Fan reset filter description: Reset the filter lifetime and usage. @@ -83,26 +28,6 @@ fan_set_extra_features: min: 0 max: 1 -fan_set_motor_speed: - name: Fan set motor speed - description: Set the target motor speed. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - motor_speed: - name: Motor speed - description: Set motor speed. - required: true - selector: - number: - min: 200 - max: 2000 - unit_of_measurement: 'RPM' - light_set_scene: name: Light set scene description: Set a fixed scene. From 6cefd558d8f60ac434845b6e69629fd7a4c4bcfd Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Sat, 21 Aug 2021 13:58:37 -0400 Subject: [PATCH 594/903] Set unique id to amcrest serial number (#54675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen Co-authored-by: Franck Nijhof Co-authored-by: Sean Vig --- homeassistant/components/amcrest/camera.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 1478c658d18..ac89c865862 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -365,10 +365,14 @@ class AmcrestCam(Camera): self._brand = "unknown" if self._model is None: resp = self._api.device_type.strip() + _LOGGER.debug("Device_type=%s", resp) if resp.startswith("type="): self._model = resp.split("=")[-1] else: self._model = "unknown" + if self._attr_unique_id is None: + self._attr_unique_id = self._api.serial_number.strip() + _LOGGER.debug("Assigned unique_id=%s", self._attr_unique_id) self.is_streaming = self._get_video() self._is_recording = self._get_recording() self._motion_detection_enabled = self._get_motion_detection() From 67d04b60824e35a3111c8391bf4ef369b661148a Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Sat, 21 Aug 2021 11:14:55 -0700 Subject: [PATCH 595/903] Use DataUpdateCoordinator for wemo (#54866) * Use DataUpdateCoordinator for wemo * Rename DeviceWrapper->DeviceCoordinator and make it a subclass of DataUpdateCoordinator * Rename async_update_data->_async_update_data to override base class * Rename: device -> coordinator --- homeassistant/components/wemo/__init__.py | 8 +- .../components/wemo/binary_sensor.py | 20 +- homeassistant/components/wemo/const.py | 1 - .../components/wemo/device_trigger.py | 6 +- homeassistant/components/wemo/entity.py | 184 +++-------------- homeassistant/components/wemo/fan.py | 67 +++---- homeassistant/components/wemo/light.py | 147 ++++++-------- homeassistant/components/wemo/sensor.py | 40 +--- homeassistant/components/wemo/switch.py | 97 +++++---- homeassistant/components/wemo/wemo_device.py | 82 ++++++-- tests/components/wemo/conftest.py | 1 + tests/components/wemo/entity_test_helpers.py | 185 ++++++------------ tests/components/wemo/test_binary_sensor.py | 6 - tests/components/wemo/test_fan.py | 19 +- tests/components/wemo/test_light_bridge.py | 78 ++++---- tests/components/wemo/test_light_dimmer.py | 19 +- tests/components/wemo/test_sensor.py | 39 +--- tests/components/wemo/test_switch.py | 19 +- tests/components/wemo/test_wemo_device.py | 118 ++++++++++- 19 files changed, 507 insertions(+), 629 deletions(-) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index aa7b5ff05c1..dd2ae173b51 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -152,7 +152,7 @@ class WemoDispatcher: if wemo.serialnumber in self._added_serial_numbers: return - device = await async_register_device(hass, self._config_entry, wemo) + coordinator = await async_register_device(hass, self._config_entry, wemo) for component in WEMO_MODEL_DISPATCH.get(wemo.model_name, [SWITCH_DOMAIN]): # Three cases: # - First time we see component, we need to load it and initialize the backlog @@ -160,7 +160,7 @@ class WemoDispatcher: # - Component is loaded, backlog is gone, dispatch discovery if component not in self._loaded_components: - hass.data[DOMAIN]["pending"][component] = [device] + hass.data[DOMAIN]["pending"][component] = [coordinator] self._loaded_components.add(component) hass.async_create_task( hass.config_entries.async_forward_entry_setup( @@ -169,13 +169,13 @@ class WemoDispatcher: ) elif component in hass.data[DOMAIN]["pending"]: - hass.data[DOMAIN]["pending"][component].append(device) + hass.data[DOMAIN]["pending"][component].append(coordinator) else: async_dispatcher_send( hass, f"{DOMAIN}.{component}", - device, + coordinator, ) self._added_serial_numbers.add(wemo.serialnumber) diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index f3ba5e0ec52..1f48a093cd6 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoSubscriptionEntity +from .entity import WemoEntity _LOGGER = logging.getLogger(__name__) @@ -14,24 +14,24 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo binary sensors.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator): """Handle a discovered Wemo device.""" - async_add_entities([WemoBinarySensor(device)]) + async_add_entities([WemoBinarySensor(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.binary_sensor", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor") ) ) -class WemoBinarySensor(WemoSubscriptionEntity, BinarySensorEntity): +class WemoBinarySensor(WemoEntity, BinarySensorEntity): """Representation a WeMo binary sensor.""" - def _update(self, force_update=True): - """Update the sensor state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) + @property + def is_on(self) -> bool: + """Return true if the state is on. Standby is on.""" + return self.wemo.get_state() diff --git a/homeassistant/components/wemo/const.py b/homeassistant/components/wemo/const.py index 79972affa48..ec59e713b0d 100644 --- a/homeassistant/components/wemo/const.py +++ b/homeassistant/components/wemo/const.py @@ -3,6 +3,5 @@ DOMAIN = "wemo" SERVICE_SET_HUMIDITY = "set_humidity" SERVICE_RESET_FILTER_LIFE = "reset_filter_life" -SIGNAL_WEMO_STATE_PUSH = f"{DOMAIN}.state_push" WEMO_SUBSCRIPTION_EVENT = f"{DOMAIN}_subscription_event" diff --git a/homeassistant/components/wemo/device_trigger.py b/homeassistant/components/wemo/device_trigger.py index ba2ac08ed74..da9a157e1a4 100644 --- a/homeassistant/components/wemo/device_trigger.py +++ b/homeassistant/components/wemo/device_trigger.py @@ -7,7 +7,7 @@ from homeassistant.components.homeassistant.triggers import event as event_trigg from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from .const import DOMAIN as WEMO_DOMAIN, WEMO_SUBSCRIPTION_EVENT -from .wemo_device import async_get_device +from .wemo_device import async_get_coordinator TRIGGER_TYPES = {EVENT_TYPE_LONG_PRESS} @@ -28,11 +28,11 @@ async def async_get_triggers(hass, device_id): CONF_DEVICE_ID: device_id, } - device = async_get_device(hass, device_id) + coordinator = async_get_coordinator(hass, device_id) triggers = [] # Check for long press support. - if device.supports_long_press: + if coordinator.supports_long_press: triggers.append( { # Required fields of TRIGGER_SCHEMA diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index 19035367ae5..4571d8f5eaa 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -1,49 +1,30 @@ """Classes shared among Wemo entities.""" from __future__ import annotations -import asyncio from collections.abc import Generator import contextlib import logging -import async_timeout -from pywemo import WeMoDevice from pywemo.exceptions import ActionException -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN as WEMO_DOMAIN, SIGNAL_WEMO_STATE_PUSH -from .wemo_device import DeviceWrapper +from .wemo_device import DeviceCoordinator _LOGGER = logging.getLogger(__name__) -class ExceptionHandlerStatus: - """Exit status from the _wemo_exception_handler context manager.""" +class WemoEntity(CoordinatorEntity): + """Common methods for Wemo entities.""" - # An exception if one was raised in the _wemo_exception_handler. - exception: Exception | None = None - - @property - def success(self) -> bool: - """Return True if the handler completed with no exception.""" - return self.exception is None - - -class WemoEntity(Entity): - """Common methods for Wemo entities. - - Requires that subclasses implement the _update method. - """ - - def __init__(self, wemo: WeMoDevice) -> None: + def __init__(self, coordinator: DeviceCoordinator) -> None: """Initialize the WeMo device.""" - self.wemo = wemo - self._state = None + super().__init__(coordinator) + self.wemo = coordinator.wemo + self._device_info = coordinator.device_info self._available = True - self._update_lock = None - self._has_polled = False @property def name(self) -> str: @@ -52,81 +33,8 @@ class WemoEntity(Entity): @property def available(self) -> bool: - """Return true if switch is available.""" - return self._available - - @contextlib.contextmanager - def _wemo_exception_handler( - self, message: str - ) -> Generator[ExceptionHandlerStatus, None, None]: - """Wrap device calls to set `_available` when wemo exceptions happen.""" - status = ExceptionHandlerStatus() - try: - yield status - except ActionException as err: - status.exception = err - _LOGGER.warning("Could not %s for %s (%s)", message, self.name, err) - self._available = False - else: - if not self._available: - _LOGGER.info("Reconnected to %s", self.name) - self._available = True - - def _update(self, force_update: bool | None = True): - """Update the device state.""" - raise NotImplementedError() - - async def async_added_to_hass(self) -> None: - """Wemo device added to Home Assistant.""" - # Define inside async context so we know our event loop - self._update_lock = asyncio.Lock() - - async def async_update(self) -> None: - """Update WeMo state. - - Wemo has an aggressive retry logic that sometimes can take over a - minute to return. If we don't get a state within the scan interval, - assume the Wemo switch is unreachable. If update goes through, it will - be made available again. - """ - # If an update is in progress, we don't do anything - if self._update_lock.locked(): - return - - try: - async with async_timeout.timeout( - self.platform.scan_interval.total_seconds() - 0.1 - ) as timeout: - await asyncio.shield(self._async_locked_update(True, timeout)) - except asyncio.TimeoutError: - _LOGGER.warning("Lost connection to %s", self.name) - self._available = False - - async def _async_locked_update( - self, force_update: bool, timeout: async_timeout.timeout | None = None - ) -> None: - """Try updating within an async lock.""" - async with self._update_lock: - await self.hass.async_add_executor_job(self._update, force_update) - self._has_polled = True - # When the timeout expires HomeAssistant is no longer waiting for an - # update from the device. Instead, the state needs to be updated - # asynchronously. This also handles the case where an update came - # directly from the device (device push). In that case no polling - # update was involved and the state also needs to be updated - # asynchronously. - if not timeout or timeout.expired: - self.async_write_ha_state() - - -class WemoSubscriptionEntity(WemoEntity): - """Common methods for Wemo devices that register for update callbacks.""" - - def __init__(self, device: DeviceWrapper) -> None: - """Initialize WemoSubscriptionEntity.""" - super().__init__(device.wemo) - self._device_id = device.device_id - self._device_info = device.device_info + """Return true if the device is available.""" + return super().available and self._available @property def unique_id(self) -> str: @@ -138,59 +46,17 @@ class WemoSubscriptionEntity(WemoEntity): """Return the device info.""" return self._device_info - @property - def is_on(self) -> bool: - """Return true if the state is on. Standby is on.""" - return self._state + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._available = True + super()._handle_coordinator_update() - @property - def should_poll(self) -> bool: - """Return True if the the device requires local polling, False otherwise. - - It is desirable to allow devices to enter periods of polling when the - callback subscription (device push) is not working. To work with the - entity platform polling logic, this entity needs to report True for - should_poll initially. That is required to cause the entity platform - logic to start the polling task (see the discussion in #47182). - - Polling can be disabled if three conditions are met: - 1. The device has polled to get the initial state (self._has_polled) and - to satisfy the entity platform constraint mentioned above. - 2. The polling was successful and the device is in a healthy state - (self.available). - 3. The pywemo subscription registry reports that there is an active - subscription and the subscription has been confirmed by receiving an - initial event. This confirms that device push notifications are - working correctly (registry.is_subscribed - this method is async safe). - """ - registry = self.hass.data[WEMO_DOMAIN]["registry"] - return not ( - self.available and self._has_polled and registry.is_subscribed(self.wemo) - ) - - async def async_added_to_hass(self) -> None: - """Wemo device added to Home Assistant.""" - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_WEMO_STATE_PUSH, self._async_subscription_callback - ) - ) - - async def _async_subscription_callback( - self, device_id: str, event_type: str, params: str - ) -> None: - """Update the state by the Wemo device.""" - # Only respond events for this device. - if device_id != self._device_id: - return - # If an update is in progress, we don't do anything - if self._update_lock.locked(): - return - - _LOGGER.debug("Subscription event (%s) for %s", event_type, self.name) - updated = await self.hass.async_add_executor_job( - self.wemo.subscription_update, event_type, params - ) - await self._async_locked_update(not updated) + @contextlib.contextmanager + def _wemo_exception_handler(self, message: str) -> Generator[None, None, None]: + """Wrap device calls to set `_available` when wemo exceptions happen.""" + try: + yield + except ActionException as err: + _LOGGER.warning("Could not %s for %s (%s)", message, self.name, err) + self._available = False diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 1582a0110cd..501011f841a 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -7,6 +7,7 @@ import math import voluptuous as vol from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity +from homeassistant.core import callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.percentage import ( @@ -20,7 +21,7 @@ from .const import ( SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY, ) -from .entity import WemoSubscriptionEntity +from .entity import WemoEntity SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 @@ -68,16 +69,16 @@ SET_HUMIDITY_SCHEMA = { async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo binary sensors.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator): """Handle a discovered Wemo device.""" - async_add_entities([WemoHumidifier(device)]) + async_add_entities([WemoHumidifier(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.fan", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("fan") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("fan") ) ) @@ -94,20 +95,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -class WemoHumidifier(WemoSubscriptionEntity, FanEntity): +class WemoHumidifier(WemoEntity, FanEntity): """Representation of a WeMo humidifier.""" - def __init__(self, device): + def __init__(self, coordinator): """Initialize the WeMo switch.""" - super().__init__(device) - self._fan_mode = WEMO_FAN_OFF - self._fan_mode_string = None - self._target_humidity = None - self._current_humidity = None - self._water_level = None - self._filter_life = None - self._filter_expired = None - self._last_fan_on_mode = WEMO_FAN_MEDIUM + super().__init__(coordinator) + if self.wemo.fan_mode != WEMO_FAN_OFF: + self._last_fan_on_mode = self.wemo.fan_mode + else: + self._last_fan_on_mode = WEMO_FAN_MEDIUM @property def icon(self): @@ -118,18 +115,18 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): def extra_state_attributes(self): """Return device specific state attributes.""" return { - ATTR_CURRENT_HUMIDITY: self._current_humidity, - ATTR_TARGET_HUMIDITY: self._target_humidity, - ATTR_FAN_MODE: self._fan_mode_string, - ATTR_WATER_LEVEL: self._water_level, - ATTR_FILTER_LIFE: self._filter_life, - ATTR_FILTER_EXPIRED: self._filter_expired, + ATTR_CURRENT_HUMIDITY: self.wemo.current_humidity_percent, + ATTR_TARGET_HUMIDITY: self.wemo.desired_humidity_percent, + ATTR_FAN_MODE: self.wemo.fan_mode_string, + ATTR_WATER_LEVEL: self.wemo.water_level_string, + ATTR_FILTER_LIFE: self.wemo.filter_life_percent, + ATTR_FILTER_EXPIRED: self.wemo.filter_expired, } @property def percentage(self) -> int: """Return the current speed percentage.""" - return ranged_value_to_percentage(SPEED_RANGE, self._fan_mode) + return ranged_value_to_percentage(SPEED_RANGE, self.wemo.fan_mode) @property def speed_count(self) -> int: @@ -141,21 +138,17 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): """Flag supported features.""" return SUPPORTED_FEATURES - def _update(self, force_update=True): - """Update the device state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.wemo.fan_mode != WEMO_FAN_OFF: + self._last_fan_on_mode = self.wemo.fan_mode + super()._handle_coordinator_update() - self._fan_mode = self.wemo.fan_mode - self._fan_mode_string = self.wemo.fan_mode_string - self._target_humidity = self.wemo.desired_humidity_percent - self._current_humidity = self.wemo.current_humidity_percent - self._water_level = self.wemo.water_level_string - self._filter_life = self.wemo.filter_life_percent - self._filter_expired = self.wemo.filter_expired - - if self.wemo.fan_mode != WEMO_FAN_OFF: - self._last_fan_on_mode = self.wemo.fan_mode + @property + def is_on(self) -> bool: + """Return true if the state is on.""" + return self.wemo.get_state() def turn_on( self, diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 8a098904bb0..ecb64296171 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -1,9 +1,9 @@ """Support for Belkin WeMo lights.""" import asyncio -from datetime import timedelta import logging -from homeassistant import util +from pywemo.ouimeaux_device import bridge + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -15,14 +15,13 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, LightEntity, ) +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoEntity, WemoSubscriptionEntity - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +from .entity import WemoEntity +from .wemo_device import DeviceCoordinator _LOGGER = logging.getLogger(__name__) @@ -31,77 +30,75 @@ SUPPORT_WEMO = ( ) # The WEMO_ constants below come from pywemo itself -WEMO_ON = 1 WEMO_OFF = 0 async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo lights.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator: DeviceCoordinator): """Handle a discovered Wemo device.""" - if device.wemo.model_name == "Dimmer": - async_add_entities([WemoDimmer(device)]) + if isinstance(coordinator.wemo, bridge.Bridge): + async_setup_bridge(hass, config_entry, async_add_entities, coordinator) else: - await hass.async_add_executor_job( - setup_bridge, hass, device.wemo, async_add_entities - ) + async_add_entities([WemoDimmer(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.light", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("light") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("light") ) ) -def setup_bridge(hass, bridge, async_add_entities): +@callback +def async_setup_bridge(hass, config_entry, async_add_entities, coordinator): """Set up a WeMo link.""" - lights = {} - - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_lights(): - """Update the WeMo led objects with latest info from the bridge.""" - bridge.bridge_update() + known_light_ids = set() + @callback + def async_update_lights(): + """Check to see if the bridge has any new lights.""" new_lights = [] - for light_id, device in bridge.Lights.items(): - if light_id not in lights: - lights[light_id] = WemoLight(device, update_lights) - new_lights.append(lights[light_id]) + for light_id, light in coordinator.wemo.Lights.items(): + if light_id not in known_light_ids: + known_light_ids.add(light_id) + new_lights.append(WemoLight(coordinator, light)) if new_lights: - hass.add_job(async_add_entities, new_lights) + async_add_entities(new_lights) - update_lights() + async_update_lights() + config_entry.async_on_unload(coordinator.async_add_listener(async_update_lights)) class WemoLight(WemoEntity, LightEntity): """Representation of a WeMo light.""" - def __init__(self, device, update_lights): + def __init__(self, coordinator: DeviceCoordinator, light: bridge.Light) -> None: """Initialize the WeMo light.""" - super().__init__(device) - self._update_lights = update_lights - self._brightness = None - self._hs_color = None - self._color_temp = None - self._is_on = None - self._unique_id = self.wemo.uniqueID - self._model_name = type(self.wemo).__name__ + super().__init__(coordinator) + self.light = light + self._unique_id = self.light.uniqueID + self._model_name = type(self.light).__name__ - async def async_added_to_hass(self): - """Wemo light added to Home Assistant.""" - # Define inside async context so we know our event loop - self._update_lock = asyncio.Lock() + @property + def name(self) -> str: + """Return the name of the device if any.""" + return self.light.name + + @property + def available(self) -> bool: + """Return true if the device is available.""" + return super().available and self.light.state.get("available") @property def unique_id(self): """Return the ID of this light.""" - return self.wemo.uniqueID + return self.light.uniqueID @property def device_info(self): @@ -116,22 +113,25 @@ class WemoLight(WemoEntity, LightEntity): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._brightness + return self.light.state.get("level", 255) @property def hs_color(self): """Return the hs color values of this light.""" - return self._hs_color + xy_color = self.light.state.get("color_xy") + if xy_color: + return color_util.color_xy_to_hs(*xy_color) + return None @property def color_temp(self): """Return the color temperature of this light in mireds.""" - return self._color_temp + return self.light.state.get("temperature_mireds") @property def is_on(self): """Return true if device is on.""" - return self._is_on + return self.light.state.get("onoff") != WEMO_OFF @property def supported_features(self): @@ -158,13 +158,14 @@ class WemoLight(WemoEntity, LightEntity): with self._wemo_exception_handler("turn on"): if xy_color is not None: - self.wemo.set_color(xy_color, transition=transition_time) + self.light.set_color(xy_color, transition=transition_time) if color_temp is not None: - self.wemo.set_temperature(mireds=color_temp, transition=transition_time) + self.light.set_temperature( + mireds=color_temp, transition=transition_time + ) - if self.wemo.turn_on(**turn_on_kwargs): - self._state["onoff"] = WEMO_ON + self.light.turn_on(**turn_on_kwargs) self.schedule_update_ha_state() @@ -173,37 +174,14 @@ class WemoLight(WemoEntity, LightEntity): transition_time = int(kwargs.get(ATTR_TRANSITION, 0)) with self._wemo_exception_handler("turn off"): - if self.wemo.turn_off(transition=transition_time): - self._state["onoff"] = WEMO_OFF + self.light.turn_off(transition=transition_time) self.schedule_update_ha_state() - def _update(self, force_update=True): - """Synchronize state with bridge.""" - with self._wemo_exception_handler("update status") as handler: - self._update_lights(no_throttle=force_update) - self._state = self.wemo.state - if handler.success: - self._is_on = self._state.get("onoff") != WEMO_OFF - self._brightness = self._state.get("level", 255) - self._color_temp = self._state.get("temperature_mireds") - xy_color = self._state.get("color_xy") - - if xy_color: - self._hs_color = color_util.color_xy_to_hs(*xy_color) - else: - self._hs_color = None - - -class WemoDimmer(WemoSubscriptionEntity, LightEntity): +class WemoDimmer(WemoEntity, LightEntity): """Representation of a WeMo dimmer.""" - def __init__(self, device): - """Initialize the WeMo dimmer.""" - super().__init__(device) - self._brightness = None - @property def supported_features(self): """Flag supported features.""" @@ -212,15 +190,13 @@ class WemoDimmer(WemoSubscriptionEntity, LightEntity): @property def brightness(self): """Return the brightness of this light between 1 and 100.""" - return self._brightness + wemo_brightness = int(self.wemo.get_brightness()) + return int((wemo_brightness * 255) / 100) - def _update(self, force_update=True): - """Update the device state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) - - wemobrightness = int(self.wemo.get_brightness(force_update)) - self._brightness = int((wemobrightness * 255) / 100) + @property + def is_on(self) -> bool: + """Return true if the state is on.""" + return self.wemo.get_state() def turn_on(self, **kwargs): """Turn the dimmer on.""" @@ -231,18 +207,15 @@ class WemoDimmer(WemoSubscriptionEntity, LightEntity): brightness = int((brightness / 255) * 100) with self._wemo_exception_handler("set brightness"): self.wemo.set_brightness(brightness) - self._state = WEMO_ON else: with self._wemo_exception_handler("turn on"): self.wemo.on() - self._state = WEMO_ON self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the dimmer off.""" with self._wemo_exception_handler("turn off"): - if self.wemo.off(): - self._state = WEMO_OFF + self.wemo.off() self.schedule_update_ha_state() diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 1fd55e4142e..5249ff8a4b9 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -1,7 +1,5 @@ """Support for power sensors in WeMo Insight devices.""" import asyncio -from datetime import timedelta -from typing import Callable from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -17,52 +15,35 @@ from homeassistant.const import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import StateType -from homeassistant.util import Throttle, convert +from homeassistant.util import convert from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoSubscriptionEntity -from .wemo_device import DeviceWrapper - -SCAN_INTERVAL = timedelta(seconds=10) +from .entity import WemoEntity +from .wemo_device import DeviceCoordinator async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo sensors.""" - async def _discovered_wemo(device: DeviceWrapper): + async def _discovered_wemo(coordinator: DeviceCoordinator): """Handle a discovered Wemo device.""" - - @Throttle(SCAN_INTERVAL) - def update_insight_params(): - device.wemo.update_insight_params() - async_add_entities( - [ - InsightCurrentPower(device, update_insight_params), - InsightTodayEnergy(device, update_insight_params), - ] + [InsightCurrentPower(coordinator), InsightTodayEnergy(coordinator)] ) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.sensor", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("sensor") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("sensor") ) ) -class InsightSensor(WemoSubscriptionEntity, SensorEntity): +class InsightSensor(WemoEntity, SensorEntity): """Common base for WeMo Insight power sensors.""" - _name_suffix: str - - def __init__(self, device: DeviceWrapper, update_insight_params: Callable) -> None: - """Initialize the WeMo Insight power sensor.""" - super().__init__(device) - self._update_insight_params = update_insight_params - @property def name(self) -> str: """Return the name of the entity if any.""" @@ -81,11 +62,6 @@ class InsightSensor(WemoSubscriptionEntity, SensorEntity): and super().available ) - def _update(self, force_update=True) -> None: - with self._wemo_exception_handler("update status"): - if force_update or not self.wemo.insight_params: - self._update_insight_params() - class InsightCurrentPower(InsightSensor): """Current instantaineous power consumption.""" diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index a7031d669a4..46e143902f9 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -3,13 +3,15 @@ import asyncio from datetime import datetime, timedelta import logging +from pywemo import CoffeeMaker, Insight, Maker + from homeassistant.components.switch import SwitchEntity from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import convert from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoSubscriptionEntity +from .entity import WemoEntity SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 @@ -33,63 +35,61 @@ WEMO_STANDBY = 8 async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo switches.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator): """Handle a discovered Wemo device.""" - async_add_entities([WemoSwitch(device)]) + async_add_entities([WemoSwitch(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.switch", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("switch") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("switch") ) ) -class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): +class WemoSwitch(WemoEntity, SwitchEntity): """Representation of a WeMo switch.""" - def __init__(self, device): - """Initialize the WeMo switch.""" - super().__init__(device) - self.insight_params = None - self.maker_params = None - self.coffeemaker_mode = None - self._mode_string = None - @property def extra_state_attributes(self): """Return the state attributes of the device.""" attr = {} - if self.maker_params: + if isinstance(self.wemo, Maker): # Is the maker sensor on or off. - if self.maker_params["hassensor"]: + if self.wemo.maker_params["hassensor"]: # Note a state of 1 matches the WeMo app 'not triggered'! - if self.maker_params["sensorstate"]: + if self.wemo.maker_params["sensorstate"]: attr[ATTR_SENSOR_STATE] = STATE_OFF else: attr[ATTR_SENSOR_STATE] = STATE_ON # Is the maker switch configured as toggle(0) or momentary (1). - if self.maker_params["switchmode"]: + if self.wemo.maker_params["switchmode"]: attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_MOMENTARY else: attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_TOGGLE - if self.insight_params or (self.coffeemaker_mode is not None): + if isinstance(self.wemo, (Insight, CoffeeMaker)): attr[ATTR_CURRENT_STATE_DETAIL] = self.detail_state - if self.insight_params: - attr["on_latest_time"] = WemoSwitch.as_uptime(self.insight_params["onfor"]) - attr["on_today_time"] = WemoSwitch.as_uptime(self.insight_params["ontoday"]) - attr["on_total_time"] = WemoSwitch.as_uptime(self.insight_params["ontotal"]) + if isinstance(self.wemo, Insight): + attr["on_latest_time"] = WemoSwitch.as_uptime( + self.wemo.insight_params["onfor"] + ) + attr["on_today_time"] = WemoSwitch.as_uptime( + self.wemo.insight_params["ontoday"] + ) + attr["on_total_time"] = WemoSwitch.as_uptime( + self.wemo.insight_params["ontotal"] + ) attr["power_threshold_w"] = ( - convert(self.insight_params["powerthreshold"], float, 0.0) / 1000.0 + convert(self.wemo.insight_params["powerthreshold"], float, 0.0) / 1000.0 ) - if self.coffeemaker_mode is not None: - attr[ATTR_COFFEMAKER_MODE] = self.coffeemaker_mode + if isinstance(self.wemo, CoffeeMaker): + attr[ATTR_COFFEMAKER_MODE] = self.wemo.mode return attr @@ -104,23 +104,25 @@ class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): @property def current_power_w(self): """Return the current power usage in W.""" - if self.insight_params: - return convert(self.insight_params["currentpower"], float, 0.0) / 1000.0 + if isinstance(self.wemo, Insight): + return ( + convert(self.wemo.insight_params["currentpower"], float, 0.0) / 1000.0 + ) @property def today_energy_kwh(self): """Return the today total energy usage in kWh.""" - if self.insight_params: - miliwatts = convert(self.insight_params["todaymw"], float, 0.0) + if isinstance(self.wemo, Insight): + miliwatts = convert(self.wemo.insight_params["todaymw"], float, 0.0) return round(miliwatts / (1000.0 * 1000.0 * 60), 2) @property def detail_state(self): """Return the state of the device.""" - if self.coffeemaker_mode is not None: - return self._mode_string - if self.insight_params: - standby_state = int(self.insight_params["state"]) + if isinstance(self.wemo, CoffeeMaker): + return self.wemo.mode_string + if isinstance(self.wemo, Insight): + standby_state = int(self.wemo.insight_params["state"]) if standby_state == WEMO_ON: return STATE_ON if standby_state == WEMO_OFF: @@ -132,36 +134,25 @@ class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): @property def icon(self): """Return the icon of device based on its type.""" - if self.wemo.model_name == "CoffeeMaker": + if isinstance(self.wemo, CoffeeMaker): return "mdi:coffee" return None + @property + def is_on(self) -> bool: + """Return true if the state is on. Standby is on.""" + return self.wemo.get_state() + def turn_on(self, **kwargs): """Turn the switch on.""" with self._wemo_exception_handler("turn on"): - if self.wemo.on(): - self._state = WEMO_ON + self.wemo.on() self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the switch off.""" with self._wemo_exception_handler("turn off"): - if self.wemo.off(): - self._state = WEMO_OFF + self.wemo.off() self.schedule_update_ha_state() - - def _update(self, force_update=True): - """Update the device state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) - - if self.wemo.model_name == "Insight": - self.insight_params = self.wemo.insight_params - self.insight_params["standby_state"] = self.wemo.get_standby_state - elif self.wemo.model_name == "Maker": - self.maker_params = self.wemo.maker_params - elif self.wemo.model_name == "CoffeeMaker": - self.coffeemaker_mode = self.wemo.mode - self._mode_string = self.wemo.mode_string diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 6fd1f4d5512..9423d0b8d1c 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -1,7 +1,10 @@ """Home Assistant wrapper for a pyWeMo device.""" +import asyncio +from datetime import timedelta import logging from pywemo import WeMoDevice +from pywemo.exceptions import ActionException from pywemo.subscribe import EVENT_TYPE_LONG_PRESS from homeassistant.config_entries import ConfigEntry @@ -14,28 +17,36 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import async_get as async_get_device_registry -from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, SIGNAL_WEMO_STATE_PUSH, WEMO_SUBSCRIPTION_EVENT +from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT _LOGGER = logging.getLogger(__name__) -class DeviceWrapper: +class DeviceCoordinator(DataUpdateCoordinator): """Home Assistant wrapper for a pyWeMo device.""" def __init__(self, hass: HomeAssistant, wemo: WeMoDevice, device_id: str) -> None: - """Initialize DeviceWrapper.""" + """Initialize DeviceCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=wemo.name, + update_interval=timedelta(seconds=30), + ) self.hass = hass self.wemo = wemo self.device_id = device_id self.device_info = _device_info(wemo) self.supports_long_press = wemo.supports_long_press() + self.update_lock = asyncio.Lock() def subscription_callback( self, _device: WeMoDevice, event_type: str, params: str ) -> None: """Receives push notifications from WeMo devices.""" + _LOGGER.debug("Subscription event (%s) for %s", event_type, self.wemo.name) if event_type == EVENT_TYPE_LONG_PRESS: self.hass.bus.fire( WEMO_SUBSCRIPTION_EVENT, @@ -48,9 +59,50 @@ class DeviceWrapper: }, ) else: - dispatcher_send( - self.hass, SIGNAL_WEMO_STATE_PUSH, self.device_id, event_type, params - ) + updated = self.wemo.subscription_update(event_type, params) + self.hass.add_job(self._async_subscription_callback(updated)) + + async def _async_subscription_callback(self, updated: bool) -> None: + """Update the state by the Wemo device.""" + # If an update is in progress, we don't do anything. + if self.update_lock.locked(): + return + try: + await self._async_locked_update(not updated) + except UpdateFailed as err: + self.last_exception = err + if self.last_update_success: + _LOGGER.exception("Subscription callback failed") + self.last_update_success = False + except Exception as err: # pylint: disable=broad-except + self.last_exception = err + self.last_update_success = False + _LOGGER.exception("Unexpected error fetching %s data: %s", self.name, err) + else: + self.async_set_updated_data(None) + + async def _async_update_data(self) -> None: + """Update WeMo state.""" + # No need to poll if the device will push updates. + registry = self.hass.data[DOMAIN]["registry"] + if registry.is_subscribed(self.wemo) and self.last_update_success: + return + + # If an update is in progress, we don't do anything. + if self.update_lock.locked(): + return + + await self._async_locked_update(True) + + async def _async_locked_update(self, force_update: bool) -> None: + """Try updating within an async lock.""" + async with self.update_lock: + try: + await self.hass.async_add_executor_job( + self.wemo.get_state, force_update + ) + except ActionException as err: + raise UpdateFailed("WeMo update failed") from err def _device_info(wemo: WeMoDevice): @@ -64,19 +116,21 @@ def _device_info(wemo: WeMoDevice): async def async_register_device( hass: HomeAssistant, config_entry: ConfigEntry, wemo: WeMoDevice -) -> DeviceWrapper: +) -> DeviceCoordinator: """Register a device with home assistant and enable pywemo event callbacks.""" + # Ensure proper communication with the device and get the initial state. + await hass.async_add_executor_job(wemo.get_state, True) + device_registry = async_get_device_registry(hass) entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, **_device_info(wemo) ) - registry = hass.data[DOMAIN]["registry"] - await hass.async_add_executor_job(registry.register, wemo) - - device = DeviceWrapper(hass, wemo, entry.id) + device = DeviceCoordinator(hass, wemo, entry.id) hass.data[DOMAIN].setdefault("devices", {})[entry.id] = device + registry = hass.data[DOMAIN]["registry"] registry.on(wemo, None, device.subscription_callback) + await hass.async_add_executor_job(registry.register, wemo) if device.supports_long_press: try: @@ -93,6 +147,6 @@ async def async_register_device( @callback -def async_get_device(hass: HomeAssistant, device_id: str) -> DeviceWrapper: - """Return DeviceWrapper for device_id.""" +def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordinator: + """Return DeviceCoordinator for device_id.""" return hass.data[DOMAIN]["devices"][device_id] diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index bf69318706c..6c597d51df4 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -35,6 +35,7 @@ async def async_pywemo_registry_fixture(): registry.semaphore.release() registry.on.side_effect = on_func + registry.is_subscribed.return_value = False with patch("pywemo.SubscriptionRegistry", return_value=registry): yield registry diff --git a/tests/components/wemo/entity_test_helpers.py b/tests/components/wemo/entity_test_helpers.py index 3d1a73941e6..6836f87a4a0 100644 --- a/tests/components/wemo/entity_test_helpers.py +++ b/tests/components/wemo/entity_test_helpers.py @@ -4,196 +4,133 @@ This is not a test module. These test methods are used by the platform test modu """ import asyncio import threading -from unittest.mock import patch -import async_timeout -import pywemo -from pywemo.ouimeaux_device.api.service import ActionException - -from homeassistant.components.homeassistant import ( - DOMAIN as HA_DOMAIN, - SERVICE_UPDATE_ENTITY, +from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN +from homeassistant.components.wemo import wemo_device +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNAVAILABLE, ) -from homeassistant.components.wemo.const import SIGNAL_WEMO_STATE_PUSH -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component -def _perform_registry_callback(hass, pywemo_registry, pywemo_device): +def _perform_registry_callback(coordinator): """Return a callable method to trigger a state callback from the device.""" async def async_callback(): - event = asyncio.Event() - - async def event_callback(e, *args): - event.set() - - stop_dispatcher_listener = async_dispatcher_connect( - hass, SIGNAL_WEMO_STATE_PUSH, event_callback + await coordinator.hass.async_add_executor_job( + coordinator.subscription_callback, coordinator.wemo, "", "" ) - # Cause a state update callback to be triggered by the device. - await hass.async_add_executor_job( - pywemo_registry.callbacks[pywemo_device.name], pywemo_device, "", "" - ) - await event.wait() - stop_dispatcher_listener() return async_callback -def _perform_async_update(hass, wemo_entity): +def _perform_async_update(coordinator): """Return a callable method to cause hass to update the state of the entity.""" - @callback - def async_callback(): - return hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) + async def async_callback(): + await coordinator._async_update_data() return async_callback -async def _async_multiple_call_helper( - hass, - pywemo_registry, - wemo_entity, - pywemo_device, - call1, - call2, - update_polling_method=None, -): +async def _async_multiple_call_helper(hass, pywemo_device, call1, call2): """Create two calls (call1 & call2) in parallel; verify only one polls the device. - The platform entity should only perform one update poll on the device at a time. - Any parallel updates that happen at the same time should be ignored. This is - verified by blocking in the update polling method. The polling method should - only be called once as a result of calling call1 & call2 simultaneously. + There should only be one poll on the device at a time. Any parallel updates + # that happen at the same time should be ignored. This is verified by blocking + in the get_state method. The polling method should only be called once as a + result of calling call1 & call2 simultaneously. """ - # get_state is called outside the event loop. Use non-async Python Event. event = threading.Event() waiting = asyncio.Event() + call_count = 0 - def get_update(force_update=True): + def get_state(force_update=None): + if force_update is None: + return + nonlocal call_count + call_count += 1 hass.add_job(waiting.set) event.wait() - update_polling_method = update_polling_method or pywemo_device.get_state - update_polling_method.side_effect = get_update + # Danger! Do not use a Mock side_effect here. The test will deadlock. When + # called though hass.async_add_executor_job, Mock objects !surprisingly! + # run in the same thread as the asyncio event loop. + # https://github.com/home-assistant/core/blob/1ba5c1c9fb1e380549cb655986b5f4d3873d7352/tests/common.py#L179 + pywemo_device.get_state = get_state # One of these two calls will block on `event`. The other will return right # away because the `_update_lock` is held. - _, pending = await asyncio.wait( + done, pending = await asyncio.wait( [call1(), call2()], return_when=asyncio.FIRST_COMPLETED ) + _ = [d.result() for d in done] # Allow any exceptions to be raised. # Allow the blocked call to return. await waiting.wait() event.set() + if pending: - await asyncio.wait(pending) + done, _ = await asyncio.wait(pending) + _ = [d.result() for d in done] # Allow any exceptions to be raised. # Make sure the state update only happened once. - update_polling_method.assert_called_once() + assert call_count == 1 async def test_async_update_locked_callback_and_update( - hass, pywemo_registry, wemo_entity, pywemo_device, **kwargs + hass, pywemo_device, wemo_entity ): """Test that a callback and a state update request can't both happen at the same time. When a state update is received via a callback from the device at the same time as hass is calling `async_update`, verify that only one of the updates proceeds. """ + coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) - callback = _perform_registry_callback(hass, pywemo_registry, pywemo_device) - update = _perform_async_update(hass, wemo_entity) - await _async_multiple_call_helper( - hass, pywemo_registry, wemo_entity, pywemo_device, callback, update, **kwargs - ) + callback = _perform_registry_callback(coordinator) + update = _perform_async_update(coordinator) + await _async_multiple_call_helper(hass, pywemo_device, callback, update) -async def test_async_update_locked_multiple_updates( - hass, pywemo_registry, wemo_entity, pywemo_device, **kwargs -): +async def test_async_update_locked_multiple_updates(hass, pywemo_device, wemo_entity): """Test that two hass async_update state updates do not proceed at the same time.""" + coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) - update = _perform_async_update(hass, wemo_entity) - await _async_multiple_call_helper( - hass, pywemo_registry, wemo_entity, pywemo_device, update, update, **kwargs - ) + update = _perform_async_update(coordinator) + await _async_multiple_call_helper(hass, pywemo_device, update, update) -async def test_async_update_locked_multiple_callbacks( - hass, pywemo_registry, wemo_entity, pywemo_device, **kwargs -): +async def test_async_update_locked_multiple_callbacks(hass, pywemo_device, wemo_entity): """Test that two device callback state updates do not proceed at the same time.""" + coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) - callback = _perform_registry_callback(hass, pywemo_registry, pywemo_device) - await _async_multiple_call_helper( - hass, pywemo_registry, wemo_entity, pywemo_device, callback, callback, **kwargs - ) + callback = _perform_registry_callback(coordinator) + await _async_multiple_call_helper(hass, pywemo_device, callback, callback) -async def test_async_locked_update_with_exception( - hass, - wemo_entity, - pywemo_device, - update_polling_method=None, - expected_state=STATE_OFF, +async def test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, domain ): - """Test that the entity becomes unavailable when communication is lost.""" - assert hass.states.get(wemo_entity.entity_id).state == expected_state - await async_setup_component(hass, HA_DOMAIN, {}) - update_polling_method = update_polling_method or pywemo_device.get_state - update_polling_method.side_effect = ActionException + """Test the avaliability when an On call fails and after an update. + + This test expects that the pywemo_device Mock has been setup to raise an + ActionException when the SERVICE_TURN_ON method is called and that the + state will be On after the update. + """ + await async_setup_component(hass, domain, {}) await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, + domain, + SERVICE_TURN_ON, {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, blocking=True, ) - assert hass.states.get(wemo_entity.entity_id).state == STATE_UNAVAILABLE - -async def test_async_update_with_timeout_and_recovery( - hass, wemo_entity, pywemo_device, expected_state=STATE_OFF -): - """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - assert hass.states.get(wemo_entity.entity_id).state == expected_state - await async_setup_component(hass, HA_DOMAIN, {}) - - event = threading.Event() - - def get_state(*args): - event.wait() - return 0 - - if hasattr(pywemo_device, "bridge_update"): - pywemo_device.bridge_update.side_effect = get_state - elif isinstance(pywemo_device, pywemo.Insight): - pywemo_device.update_insight_params.side_effect = get_state - else: - pywemo_device.get_state.side_effect = get_state - timeout = async_timeout.timeout(0) - - with patch("async_timeout.timeout", return_value=timeout): - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) - - assert hass.states.get(wemo_entity.entity_id).state == STATE_UNAVAILABLE - - # Check that the entity recovers and is available after the update succeeds. - event.set() + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") await hass.async_block_till_done() - assert hass.states.get(wemo_entity.entity_id).state == expected_state + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON diff --git a/tests/components/wemo/test_binary_sensor.py b/tests/components/wemo/test_binary_sensor.py index 1bf6f0f3bef..64e67162829 100644 --- a/tests/components/wemo/test_binary_sensor.py +++ b/tests/components/wemo/test_binary_sensor.py @@ -30,12 +30,6 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) async def test_binary_sensor_registry_state_callback( diff --git a/tests/components/wemo/test_fan.py b/tests/components/wemo/test_fan.py index 38055ba972c..dc450311e6a 100644 --- a/tests/components/wemo/test_fan.py +++ b/tests/components/wemo/test_fan.py @@ -1,7 +1,9 @@ """Tests for the Wemo fan entity.""" import pytest +from pywemo.exceptions import ActionException +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -32,12 +34,6 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) async def test_fan_registry_state_callback( @@ -82,6 +78,17 @@ async def test_fan_update_entity(hass, pywemo_registry, pywemo_device, wemo_enti assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """Test the avaliability when an On call fails and after an update.""" + pywemo_device.set_state.side_effect = ActionException + pywemo_device.get_state.return_value = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, FAN_DOMAIN + ) + + async def test_fan_reset_filter_service(hass, pywemo_device, wemo_entity): """Verify that SERVICE_RESET_FILTER_LIFE is registered and works.""" assert await hass.services.async_call( diff --git a/tests/components/wemo/test_light_bridge.py b/tests/components/wemo/test_light_bridge.py index 573f75a66d9..b00cfe30ef7 100644 --- a/tests/components/wemo/test_light_bridge.py +++ b/tests/components/wemo/test_light_bridge.py @@ -1,5 +1,5 @@ """Tests for the Wemo light entity via the bridge.""" -from unittest.mock import create_autospec, patch +from unittest.mock import create_autospec import pytest import pywemo @@ -8,10 +8,9 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.wemo.light import MIN_TIME_BETWEEN_SCANS +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from . import entity_test_helpers @@ -32,60 +31,53 @@ def pywemo_bridge_light_fixture(pywemo_device): light.uniqueID = pywemo_device.serialnumber light.name = pywemo_device.name light.bridge = pywemo_device - light.state = {"onoff": 0} + light.state = {"onoff": 0, "available": True} pywemo_device.Lights = {pywemo_device.serialnumber: light} return light -def _bypass_throttling(): - """Bypass the util.Throttle on the update_lights method.""" - utcnow = dt_util.utcnow() - - def increment_and_return_time(): - nonlocal utcnow - utcnow += MIN_TIME_BETWEEN_SCANS - return utcnow - - return patch("homeassistant.util.utcnow", side_effect=increment_and_return_time) +async def test_async_update_locked_callback_and_update( + hass, pywemo_bridge_light, wemo_entity, pywemo_device +): + """Test that a callback and a state update request can't both happen at the same time.""" + await entity_test_helpers.test_async_update_locked_callback_and_update( + hass, + pywemo_device, + wemo_entity, + ) async def test_async_update_locked_multiple_updates( - hass, pywemo_registry, pywemo_bridge_light, wemo_entity, pywemo_device + hass, pywemo_bridge_light, wemo_entity, pywemo_device ): """Test that two state updates do not proceed at the same time.""" - pywemo_device.bridge_update.reset_mock() - - with _bypass_throttling(): - await entity_test_helpers.test_async_update_locked_multiple_updates( - hass, - pywemo_registry, - wemo_entity, - pywemo_device, - update_polling_method=pywemo_device.bridge_update, - ) + await entity_test_helpers.test_async_update_locked_multiple_updates( + hass, + pywemo_device, + wemo_entity, + ) -async def test_async_update_with_timeout_and_recovery( +async def test_async_update_locked_multiple_callbacks( hass, pywemo_bridge_light, wemo_entity, pywemo_device ): - """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - with _bypass_throttling(): - await entity_test_helpers.test_async_update_with_timeout_and_recovery( - hass, wemo_entity, pywemo_device - ) + """Test that two device callback state updates do not proceed at the same time.""" + await entity_test_helpers.test_async_update_locked_multiple_callbacks( + hass, + pywemo_device, + wemo_entity, + ) -async def test_async_locked_update_with_exception( - hass, pywemo_bridge_light, wemo_entity, pywemo_device +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, pywemo_bridge_light, wemo_entity ): - """Test that the entity becomes unavailable when communication is lost.""" - with _bypass_throttling(): - await entity_test_helpers.test_async_locked_update_with_exception( - hass, - wemo_entity, - pywemo_device, - update_polling_method=pywemo_device.bridge_update, - ) + """Test the avaliability when an On call fails and after an update.""" + pywemo_bridge_light.turn_on.side_effect = pywemo.exceptions.ActionException + pywemo_bridge_light.state["onoff"] = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, LIGHT_DOMAIN + ) async def test_light_update_entity( @@ -95,7 +87,7 @@ async def test_light_update_entity( await async_setup_component(hass, HA_DOMAIN, {}) # On state. - pywemo_bridge_light.state = {"onoff": 1} + pywemo_bridge_light.state["onoff"] = 1 await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -105,7 +97,7 @@ async def test_light_update_entity( assert hass.states.get(wemo_entity.entity_id).state == STATE_ON # Off state. - pywemo_bridge_light.state = {"onoff": 0} + pywemo_bridge_light.state["onoff"] = 0 await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, diff --git a/tests/components/wemo/test_light_dimmer.py b/tests/components/wemo/test_light_dimmer.py index 45fdd01a643..830eb6dbdf4 100644 --- a/tests/components/wemo/test_light_dimmer.py +++ b/tests/components/wemo/test_light_dimmer.py @@ -1,11 +1,13 @@ """Tests for the Wemo standalone/non-bridge light entity.""" import pytest +from pywemo.exceptions import ActionException from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component @@ -30,12 +32,17 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) + + +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """Test the avaliability when an On call fails and after an update.""" + pywemo_device.on.side_effect = ActionException + pywemo_device.get_state.return_value = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, LIGHT_DOMAIN + ) async def test_light_registry_state_callback( diff --git a/tests/components/wemo/test_sensor.py b/tests/components/wemo/test_sensor.py index 3b8786131a7..a7f68429994 100644 --- a/tests/components/wemo/test_sensor.py +++ b/tests/components/wemo/test_sensor.py @@ -78,62 +78,33 @@ class InsightTestTemplate: # in the scope of this test module. They will run using the pywemo_model from # this test module (Insight). async def test_async_update_locked_multiple_updates( - self, hass, pywemo_registry, wemo_entity, pywemo_device + self, hass, pywemo_device, wemo_entity ): """Test that two hass async_update state updates do not proceed at the same time.""" - pywemo_device.subscription_update.return_value = False await entity_test_helpers.test_async_update_locked_multiple_updates( hass, - pywemo_registry, - wemo_entity, pywemo_device, - update_polling_method=pywemo_device.update_insight_params, + wemo_entity, ) async def test_async_update_locked_multiple_callbacks( - self, hass, pywemo_registry, wemo_entity, pywemo_device + self, hass, pywemo_device, wemo_entity ): """Test that two device callback state updates do not proceed at the same time.""" - pywemo_device.subscription_update.return_value = False await entity_test_helpers.test_async_update_locked_multiple_callbacks( hass, - pywemo_registry, - wemo_entity, pywemo_device, - update_polling_method=pywemo_device.update_insight_params, + wemo_entity, ) async def test_async_update_locked_callback_and_update( - self, hass, pywemo_registry, wemo_entity, pywemo_device + self, hass, pywemo_device, wemo_entity ): """Test that a callback and a state update request can't both happen at the same time.""" - pywemo_device.subscription_update.return_value = False await entity_test_helpers.test_async_update_locked_callback_and_update( hass, - pywemo_registry, - wemo_entity, pywemo_device, - update_polling_method=pywemo_device.update_insight_params, - ) - - async def test_async_locked_update_with_exception( - self, hass, wemo_entity, pywemo_device - ): - """Test that the entity becomes unavailable when communication is lost.""" - await entity_test_helpers.test_async_locked_update_with_exception( - hass, wemo_entity, - pywemo_device, - update_polling_method=pywemo_device.update_insight_params, - expected_state=self.EXPECTED_STATE_VALUE, - ) - - async def test_async_update_with_timeout_and_recovery( - self, hass, wemo_entity, pywemo_device - ): - """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - await entity_test_helpers.test_async_update_with_timeout_and_recovery( - hass, wemo_entity, pywemo_device, expected_state=self.EXPECTED_STATE_VALUE ) async def test_state_unavailable(self, hass, wemo_entity, pywemo_device): diff --git a/tests/components/wemo/test_switch.py b/tests/components/wemo/test_switch.py index 05151d38be8..1023498c792 100644 --- a/tests/components/wemo/test_switch.py +++ b/tests/components/wemo/test_switch.py @@ -1,11 +1,13 @@ """Tests for the Wemo switch entity.""" import pytest +from pywemo.exceptions import ActionException from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component @@ -30,12 +32,6 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) async def test_switch_registry_state_callback( @@ -78,3 +74,14 @@ async def test_switch_update_entity(hass, pywemo_registry, pywemo_device, wemo_e blocking=True, ) assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + + +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """Test the avaliability when an On call fails and after an update.""" + pywemo_device.on.side_effect = ActionException + pywemo_device.get_state.return_value = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, SWITCH_DOMAIN + ) diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index 38727a28424..6f3cc12a81a 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -1,16 +1,24 @@ """Tests for wemo_device.py.""" +import asyncio from unittest.mock import patch +import async_timeout import pytest -from pywemo import PyWeMoException +from pywemo.exceptions import ActionException, PyWeMoException +from pywemo.subscribe import EVENT_TYPE_LONG_PRESS +from homeassistant import runner from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC, wemo_device -from homeassistant.components.wemo.const import DOMAIN +from homeassistant.components.wemo.const import DOMAIN, WEMO_SUBSCRIPTION_EVENT +from homeassistant.core import callback from homeassistant.helpers import device_registry +from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from .conftest import MOCK_HOST +asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(True)) + @pytest.fixture def pywemo_model(): @@ -36,5 +44,107 @@ async def test_async_register_device_longpress_fails(hass, pywemo_device): dr = device_registry.async_get(hass) device_entries = list(dr.devices.values()) assert len(device_entries) == 1 - device_wrapper = wemo_device.async_get_device(hass, device_entries[0].id) - assert device_wrapper.supports_long_press is False + device = wemo_device.async_get_coordinator(hass, device_entries[0].id) + assert device.supports_long_press is False + + +async def test_long_press_event(hass, pywemo_registry, wemo_entity): + """Device fires a long press event.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + got_event = asyncio.Event() + event_data = {} + + @callback + def async_event_received(event): + nonlocal event_data + event_data = event.data + got_event.set() + + hass.bus.async_listen_once(WEMO_SUBSCRIPTION_EVENT, async_event_received) + + await hass.async_add_executor_job( + pywemo_registry.callbacks[device.wemo.name], + device.wemo, + EVENT_TYPE_LONG_PRESS, + "testing_params", + ) + + async with async_timeout.timeout(8): + await got_event.wait() + + assert event_data == { + "device_id": wemo_entity.device_id, + "name": device.wemo.name, + "params": "testing_params", + "type": EVENT_TYPE_LONG_PRESS, + "unique_id": device.wemo.serialnumber, + } + + +async def test_subscription_callback(hass, pywemo_registry, wemo_entity): + """Device processes a registry subscription callback.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device.last_update_success = False + + got_callback = asyncio.Event() + + @callback + def async_received_callback(): + got_callback.set() + + device.async_add_listener(async_received_callback) + + await hass.async_add_executor_job( + pywemo_registry.callbacks[device.wemo.name], device.wemo, "", "" + ) + + async with async_timeout.timeout(8): + await got_callback.wait() + assert device.last_update_success + + +async def test_subscription_update_action_exception(hass, pywemo_device, wemo_entity): + """Device handles ActionException on get_state properly.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device.last_update_success = True + + pywemo_device.subscription_update.return_value = False + pywemo_device.get_state.reset_mock() + pywemo_device.get_state.side_effect = ActionException + await hass.async_add_executor_job( + device.subscription_callback, pywemo_device, "", "" + ) + await hass.async_block_till_done() + + pywemo_device.get_state.assert_called_once_with(True) + assert device.last_update_success is False + assert isinstance(device.last_exception, UpdateFailed) + + +async def test_subscription_update_exception(hass, pywemo_device, wemo_entity): + """Device handles Exception on get_state properly.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device.last_update_success = True + + pywemo_device.subscription_update.return_value = False + pywemo_device.get_state.reset_mock() + pywemo_device.get_state.side_effect = Exception + await hass.async_add_executor_job( + device.subscription_callback, pywemo_device, "", "" + ) + await hass.async_block_till_done() + + pywemo_device.get_state.assert_called_once_with(True) + assert device.last_update_success is False + assert isinstance(device.last_exception, Exception) + + +async def test_async_update_data_subscribed( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """No update happens when the device is subscribed.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + pywemo_registry.is_subscribed.return_value = True + pywemo_device.get_state.reset_mock() + await device._async_update_data() + pywemo_device.get_state.assert_not_called() From f872594e267fc8bdd2272770a04b00358c5afadb Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 21 Aug 2021 20:19:56 +0200 Subject: [PATCH 596/903] Activate mypy for homematicip_cloud (#54950) * Please mypy. * Review comments. * Review comments. --- .../components/homematicip_cloud/__init__.py | 2 +- .../homematicip_cloud/alarm_control_panel.py | 4 ++-- .../homematicip_cloud/binary_sensor.py | 4 ++-- .../components/homematicip_cloud/climate.py | 6 ++--- .../homematicip_cloud/config_flow.py | 3 ++- .../components/homematicip_cloud/cover.py | 16 +++++++------- .../homematicip_cloud/generic_entity.py | 6 ++--- .../components/homematicip_cloud/hap.py | 21 +++++++++++++----- .../components/homematicip_cloud/light.py | 22 +++++++++---------- .../components/homematicip_cloud/sensor.py | 4 ++-- .../components/homematicip_cloud/services.py | 4 +++- .../components/homematicip_cloud/switch.py | 2 +- .../components/homematicip_cloud/weather.py | 6 +++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 15 files changed, 57 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 00604bbc8a6..14c80f56b1a 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -52,7 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entry.data[HMIPC_HAPID] for entry in hass.config_entries.async_entries(DOMAIN) }: - hass.async_add_job( + hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 87a8056b4b6..212737b7018 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from . import DOMAIN as HMIPC_DOMAIN -from .hap import HomematicipHAP +from .hap import AsyncHome, HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -41,7 +41,7 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): def __init__(self, hap: HomematicipHAP) -> None: """Initialize the alarm control panel.""" - self._home = hap.home + self._home: AsyncHome = hap.home _LOGGER.info("Setting up %s", self.name) @property diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 673dd6e9ea3..80dfa8316d0 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -85,7 +85,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP Cloud binary sensor from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [HomematicipCloudConnectionSensor(hap)] + entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)] for device in hap.home.devices: if isinstance(device, AsyncAccelerationSensor): entities.append(HomematicipAccelerationSensor(hap, device)) @@ -254,7 +254,7 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt return DEVICE_CLASS_OPENING @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if the contact interface is on/open.""" if self._device.functionalChannels[self._channel].windowState is None: return None diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 7ba90e0a9e4..1b6c2491e2e 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -262,7 +262,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): return self._home.get_functionalHome(IndoorClimateHome) @property - def _device_profiles(self) -> list[str]: + def _device_profiles(self) -> list[Any]: """Return the relevant profiles.""" return [ profile @@ -301,10 +301,10 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ) @property - def _relevant_profile_group(self) -> list[str]: + def _relevant_profile_group(self) -> dict[str, int]: """Return the relevant profile groups.""" if self._disabled_by_cooling_mode: - return [] + return {} return HEATING_PROFILES if self._heat_mode_enabled else COOLING_PROFILES diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 2baa99068ce..6cf6335b874 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -23,9 +23,10 @@ class HomematicipCloudFlowHandler(config_entries.ConfigFlow): VERSION = 1 + auth: HomematicipAuth + def __init__(self) -> None: """Initialize HomematicIP Cloud config flow.""" - self.auth = None async def async_step_user(self, user_input=None) -> FlowResult: """Handle a flow initialized by the user.""" diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 843f44510c1..0d0278ac455 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -37,7 +37,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP cover from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncBlindModule): entities.append(HomematicipBlindModule(hap, device)) @@ -72,14 +72,14 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): return DEVICE_CLASS_BLIND @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return current position of cover.""" if self._device.primaryShadingLevel is not None: return int((1 - self._device.primaryShadingLevel) * 100) return None @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current tilt position of cover.""" if self._device.secondaryShadingLevel is not None: return int((1 - self._device.secondaryShadingLevel) * 100) @@ -165,7 +165,7 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): return DEVICE_CLASS_SHUTTER @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return current position of cover.""" if self._device.functionalChannels[self._channel].shutterLevel is not None: return int( @@ -227,7 +227,7 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity): ) @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current tilt position of cover.""" if self._device.functionalChannels[self._channel].slatsLevel is not None: return int( @@ -267,7 +267,7 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP Garage Door Module.""" @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return current position of cover.""" door_state_to_position = { DoorState.CLOSED: 0, @@ -314,14 +314,14 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): return DEVICE_CLASS_SHUTTER @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return current position of cover.""" if self._device.shutterLevel is not None: return int((1 - self._device.shutterLevel) * 100) return None @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current tilt position of cover.""" if self._device.slatsLevel is not None: return int((1 - self._device.slatsLevel) * 100) diff --git a/homeassistant/components/homematicip_cloud/generic_entity.py b/homeassistant/components/homematicip_cloud/generic_entity.py index b9dd46d49d7..f1edff1854b 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/generic_entity.py @@ -13,7 +13,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN as HMIPC_DOMAIN -from .hap import HomematicipHAP +from .hap import AsyncHome, HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -82,7 +82,7 @@ class HomematicipGenericEntity(Entity): ) -> None: """Initialize the generic entity.""" self._hap = hap - self._home = hap.home + self._home: AsyncHome = hap.home self._device = device self._post = post self._channel = channel @@ -92,7 +92,7 @@ class HomematicipGenericEntity(Entity): _LOGGER.info("Setting up %s (%s)", self.name, self._device.modelType) @property - def device_info(self) -> DeviceInfo: + def device_info(self) -> DeviceInfo | None: """Return device specific attributes.""" # Only physical devices should be HA devices. if isinstance(self._device, AsyncDevice): diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index ad641c0f46d..5cccc9a9999 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -1,6 +1,9 @@ """Access point for the HomematicIP Cloud component.""" +from __future__ import annotations + import asyncio import logging +from typing import Any, Callable from homematicip.aio.auth import AsyncAuth from homematicip.aio.home import AsyncHome @@ -21,11 +24,12 @@ _LOGGER = logging.getLogger(__name__) class HomematicipAuth: """Manages HomematicIP client registration.""" + auth: AsyncAuth + def __init__(self, hass, config) -> None: """Initialize HomematicIP Cloud client registration.""" self.hass = hass self.config = config - self.auth = None async def async_setup(self) -> bool: """Connect to HomematicIP for registration.""" @@ -69,18 +73,19 @@ class HomematicipAuth: class HomematicipHAP: """Manages HomematicIP HTTP and WebSocket connection.""" + home: AsyncHome + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize HomematicIP Cloud connection.""" self.hass = hass self.config_entry = config_entry - self.home = None self._ws_close_requested = False - self._retry_task = None + self._retry_task: asyncio.Task | None = None self._tries = 0 self._accesspoint_connected = True - self.hmip_device_by_entity_id = {} - self.reset_connection_listener = None + self.hmip_device_by_entity_id: dict[str, Any] = {} + self.reset_connection_listener: Callable | None = None async def async_setup(self, tries: int = 0) -> bool: """Initialize connection.""" @@ -228,7 +233,11 @@ class HomematicipHAP: ) async def get_hap( - self, hass: HomeAssistant, hapid: str, authtoken: str, name: str + self, + hass: HomeAssistant, + hapid: str | None, + authtoken: str | None, + name: str | None, ) -> AsyncHome: """Create a HomematicIP access point object.""" home = AsyncHome(hass.loop, async_get_clientsession(hass)) diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index a2f2a6aea53..52ca9de2fe4 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -40,7 +40,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP Cloud lights from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): entities.append(HomematicipLightMeasuring(hap, device)) @@ -174,14 +174,14 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): hap, device, post="Bottom", channel=channel, is_multi_channel=True ) - self._color_switcher = { - RGBColorState.WHITE: [0.0, 0.0], - RGBColorState.RED: [0.0, 100.0], - RGBColorState.YELLOW: [60.0, 100.0], - RGBColorState.GREEN: [120.0, 100.0], - RGBColorState.TURQUOISE: [180.0, 100.0], - RGBColorState.BLUE: [240.0, 100.0], - RGBColorState.PURPLE: [300.0, 100.0], + self._color_switcher: dict[str, tuple[float, float]] = { + RGBColorState.WHITE: (0.0, 0.0), + RGBColorState.RED: (0.0, 100.0), + RGBColorState.YELLOW: (60.0, 100.0), + RGBColorState.GREEN: (120.0, 100.0), + RGBColorState.TURQUOISE: (180.0, 100.0), + RGBColorState.BLUE: (240.0, 100.0), + RGBColorState.PURPLE: (300.0, 100.0), } @property @@ -202,10 +202,10 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): return int((self._func_channel.dimLevel or 0.0) * 255) @property - def hs_color(self) -> tuple: + def hs_color(self) -> tuple[float, float]: """Return the hue and saturation color value [float, float].""" simple_rgb_color = self._func_channel.simpleRGBColorState - return self._color_switcher.get(simple_rgb_color, [0.0, 0.0]) + return self._color_switcher.get(simple_rgb_color, (0.0, 0.0)) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index df8ed33ded0..ae2bb9f0c6d 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -66,7 +66,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP Cloud sensors from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncHomeControlAccessPoint): entities.append(HomematicipAccesspointDutyCycle(hap, device)) @@ -155,7 +155,7 @@ class HomematicipHeatingThermostat(HomematicipGenericEntity, SensorEntity): super().__init__(hap, device, post="Heating") @property - def icon(self) -> str: + def icon(self) -> str | None: """Return the icon.""" if super().icon: return super().icon diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index bafe7599f06..45795f8858e 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -297,7 +297,9 @@ async def _set_active_climate_profile( async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None: """Service to dump the configuration of a Homematic IP Access Point.""" - config_path = service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir + config_path: str = ( + service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir or "." + ) config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] anonymize = service.data[ATTR_ANONYMIZE] diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 3ea52c9fb89..90188fd0322 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -34,7 +34,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP switch from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): # BrandSwitchMeasuring inherits PlugableSwitchMeasuring diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index dcd8ff4dff7..d371e305d87 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -1,4 +1,6 @@ """Support for HomematicIP Cloud weather devices.""" +from __future__ import annotations + from homematicip.aio.device import ( AsyncWeatherSensor, AsyncWeatherSensorPlus, @@ -50,7 +52,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP weather sensor from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncWeatherSensorPro): entities.append(HomematicipWeatherSensorPro(hap, device)) @@ -170,6 +172,6 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): return "Powered by Homematic IP" @property - def condition(self) -> str: + def condition(self) -> str | None: """Return the current condition.""" return HOME_WEATHER_CONDITION.get(self._device.weather.weatherCondition) diff --git a/mypy.ini b/mypy.ini index 1e7d7cecd60..883f5817f50 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1391,9 +1391,6 @@ ignore_errors = true [mypy-homeassistant.components.homekit_controller.*] ignore_errors = true -[mypy-homeassistant.components.homematicip_cloud.*] -ignore_errors = true - [mypy-homeassistant.components.honeywell.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 45779673e18..6d09ce30ce7 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -56,7 +56,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.home_plus_control.*", "homeassistant.components.homekit.*", "homeassistant.components.homekit_controller.*", - "homeassistant.components.homematicip_cloud.*", "homeassistant.components.honeywell.*", "homeassistant.components.humidifier.*", "homeassistant.components.iaqualink.*", From 17902c3ffa026f92e7efa6770bd32dbf9ca8d657 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 21 Aug 2021 20:28:31 +0200 Subject: [PATCH 597/903] Use EntityDescription - buienradar (#54317) --- homeassistant/components/buienradar/sensor.py | 912 ++++++++++++------ 1 file changed, 616 insertions(+), 296 deletions(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 1d349fe6f53..6dfbef9f931 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -1,4 +1,6 @@ """Support for Buienradar.nl weather service.""" +from __future__ import annotations + import logging from buienradar.constants import ( @@ -19,7 +21,7 @@ from buienradar.constants import ( WINDSPEED, ) -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -55,264 +57,582 @@ SCHEDULE_OK = 10 # When an error occurred, new call after (minutes): SCHEDULE_NOK = 2 -# Supported sensor types: -# Key: ['label', unit, icon] -SENSOR_TYPES = { - "stationname": ["Stationname", None, None, None], +STATIONNAME_LABEL = "Stationname" + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="stationname", + name=STATIONNAME_LABEL, + ), # new in json api (>1.0.0): - "barometerfc": ["Barometer value", None, "mdi:gauge", None], + SensorEntityDescription( + key="barometerfc", + name="Barometer value", + icon="mdi:gauge", + ), # new in json api (>1.0.0): - "barometerfcname": ["Barometer", None, "mdi:gauge", None], + SensorEntityDescription( + key="barometerfcname", + name="Barometer", + icon="mdi:gauge", + ), # new in json api (>1.0.0): - "barometerfcnamenl": ["Barometer", None, "mdi:gauge", None], - "condition": ["Condition", None, None, None], - "conditioncode": ["Condition code", None, None, None], - "conditiondetailed": ["Detailed condition", None, None, None], - "conditionexact": ["Full condition", None, None, None], - "symbol": ["Symbol", None, None, None], + SensorEntityDescription( + key="barometerfcnamenl", + name="Barometer", + icon="mdi:gauge", + ), + SensorEntityDescription( + key="condition", + name="Condition", + ), + SensorEntityDescription( + key="conditioncode", + name="Condition code", + ), + SensorEntityDescription( + key="conditiondetailed", + name="Detailed condition", + ), + SensorEntityDescription( + key="conditionexact", + name="Full condition", + ), + SensorEntityDescription( + key="symbol", + name="Symbol", + ), # new in json api (>1.0.0): - "feeltemperature": [ - "Feel temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "humidity": ["Humidity", PERCENTAGE, "mdi:water-percent", None], - "temperature": [ - "Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "groundtemperature": [ - "Ground temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy", None], - "windforce": ["Wind force", "Bft", "mdi:weather-windy", None], - "winddirection": ["Wind direction", None, "mdi:compass-outline", None], - "windazimuth": ["Wind direction azimuth", DEGREE, "mdi:compass-outline", None], - "pressure": ["Pressure", PRESSURE_HPA, "mdi:gauge", None], - "visibility": ["Visibility", LENGTH_KILOMETERS, None, None], - "windgust": ["Wind gust", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy", None], - "precipitation": [ - "Precipitation", - PRECIPITATION_MILLIMETERS_PER_HOUR, - "mdi:weather-pouring", - None, - ], - "irradiance": [ - "Irradiance", - IRRADIATION_WATTS_PER_SQUARE_METER, - "mdi:sunglasses", - None, - ], - "precipitation_forecast_average": [ - "Precipitation forecast average", - PRECIPITATION_MILLIMETERS_PER_HOUR, - "mdi:weather-pouring", - None, - ], - "precipitation_forecast_total": [ - "Precipitation forecast total", - LENGTH_MILLIMETERS, - "mdi:weather-pouring", - None, - ], + SensorEntityDescription( + key="feeltemperature", + name="Feel temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="groundtemperature", + name="Ground temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="windspeed", + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce", + name="Wind force", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="winddirection", + name="Wind direction", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth", + name="Wind direction azimuth", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="pressure", + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="visibility", + name="Visibility", + native_unit_of_measurement=LENGTH_KILOMETERS, + ), + SensorEntityDescription( + key="windgust", + name="Wind gust", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="precipitation", + name="Precipitation", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="irradiance", + name="Irradiance", + native_unit_of_measurement=IRRADIATION_WATTS_PER_SQUARE_METER, + icon="mdi:sunglasses", + ), + SensorEntityDescription( + key="precipitation_forecast_average", + name="Precipitation forecast average", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="precipitation_forecast_total", + name="Precipitation forecast total", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "rainlast24hour": [ - "Rain last 24h", - LENGTH_MILLIMETERS, - "mdi:weather-pouring", - None, - ], + SensorEntityDescription( + key="rainlast24hour", + name="Rain last 24h", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "rainlasthour": ["Rain last hour", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "temperature_1d": [ - "Temperature 1d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "temperature_2d": [ - "Temperature 2d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "temperature_3d": [ - "Temperature 3d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "temperature_4d": [ - "Temperature 4d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "temperature_5d": [ - "Temperature 5d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "mintemp_1d": [ - "Minimum temperature 1d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "mintemp_2d": [ - "Minimum temperature 2d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "mintemp_3d": [ - "Minimum temperature 3d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "mintemp_4d": [ - "Minimum temperature 4d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "mintemp_5d": [ - "Minimum temperature 5d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "rain_1d": ["Rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "rain_2d": ["Rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "rain_3d": ["Rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "rain_4d": ["Rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "rain_5d": ["Rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + SensorEntityDescription( + key="rainlasthour", + name="Rain last hour", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="temperature_1d", + name="Temperature 1d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_2d", + name="Temperature 2d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_3d", + name="Temperature 3d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_4d", + name="Temperature 4d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_5d", + name="Temperature 5d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_1d", + name="Minimum temperature 1d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_2d", + name="Minimum temperature 2d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_3d", + name="Minimum temperature 3d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_4d", + name="Minimum temperature 4d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_5d", + name="Minimum temperature 5d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="rain_1d", + name="Rain 1d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_2d", + name="Rain 2d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_3d", + name="Rain 3d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_4d", + name="Rain 4d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_5d", + name="Rain 5d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "minrain_1d": ["Minimum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "minrain_2d": ["Minimum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "minrain_3d": ["Minimum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "minrain_4d": ["Minimum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "minrain_5d": ["Minimum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + SensorEntityDescription( + key="minrain_1d", + name="Minimum rain 1d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_2d", + name="Minimum rain 2d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_3d", + name="Minimum rain 3d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_4d", + name="Minimum rain 4d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_5d", + name="Minimum rain 5d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "maxrain_1d": ["Maximum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "maxrain_2d": ["Maximum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "maxrain_3d": ["Maximum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "maxrain_4d": ["Maximum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "maxrain_5d": ["Maximum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "rainchance_1d": ["Rainchance 1d", PERCENTAGE, "mdi:weather-pouring", None], - "rainchance_2d": ["Rainchance 2d", PERCENTAGE, "mdi:weather-pouring", None], - "rainchance_3d": ["Rainchance 3d", PERCENTAGE, "mdi:weather-pouring", None], - "rainchance_4d": ["Rainchance 4d", PERCENTAGE, "mdi:weather-pouring", None], - "rainchance_5d": ["Rainchance 5d", PERCENTAGE, "mdi:weather-pouring", None], - "sunchance_1d": ["Sunchance 1d", PERCENTAGE, "mdi:weather-partly-cloudy", None], - "sunchance_2d": ["Sunchance 2d", PERCENTAGE, "mdi:weather-partly-cloudy", None], - "sunchance_3d": ["Sunchance 3d", PERCENTAGE, "mdi:weather-partly-cloudy", None], - "sunchance_4d": ["Sunchance 4d", PERCENTAGE, "mdi:weather-partly-cloudy", None], - "sunchance_5d": ["Sunchance 5d", PERCENTAGE, "mdi:weather-partly-cloudy", None], - "windforce_1d": ["Wind force 1d", "Bft", "mdi:weather-windy", None], - "windforce_2d": ["Wind force 2d", "Bft", "mdi:weather-windy", None], - "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy", None], - "windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy", None], - "windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy", None], - "windspeed_1d": [ - "Wind speed 1d", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - ], - "windspeed_2d": [ - "Wind speed 2d", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - ], - "windspeed_3d": [ - "Wind speed 3d", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - ], - "windspeed_4d": [ - "Wind speed 4d", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - ], - "windspeed_5d": [ - "Wind speed 5d", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - ], - "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline", None], - "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline", None], - "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline", None], - "winddirection_4d": ["Wind direction 4d", None, "mdi:compass-outline", None], - "winddirection_5d": ["Wind direction 5d", None, "mdi:compass-outline", None], - "windazimuth_1d": [ - "Wind direction azimuth 1d", - DEGREE, - "mdi:compass-outline", - None, - ], - "windazimuth_2d": [ - "Wind direction azimuth 2d", - DEGREE, - "mdi:compass-outline", - None, - ], - "windazimuth_3d": [ - "Wind direction azimuth 3d", - DEGREE, - "mdi:compass-outline", - None, - ], - "windazimuth_4d": [ - "Wind direction azimuth 4d", - DEGREE, - "mdi:compass-outline", - None, - ], - "windazimuth_5d": [ - "Wind direction azimuth 5d", - DEGREE, - "mdi:compass-outline", - None, - ], - "condition_1d": ["Condition 1d", None, None, None], - "condition_2d": ["Condition 2d", None, None, None], - "condition_3d": ["Condition 3d", None, None, None], - "condition_4d": ["Condition 4d", None, None, None], - "condition_5d": ["Condition 5d", None, None, None], - "conditioncode_1d": ["Condition code 1d", None, None, None], - "conditioncode_2d": ["Condition code 2d", None, None, None], - "conditioncode_3d": ["Condition code 3d", None, None, None], - "conditioncode_4d": ["Condition code 4d", None, None, None], - "conditioncode_5d": ["Condition code 5d", None, None, None], - "conditiondetailed_1d": ["Detailed condition 1d", None, None, None], - "conditiondetailed_2d": ["Detailed condition 2d", None, None, None], - "conditiondetailed_3d": ["Detailed condition 3d", None, None, None], - "conditiondetailed_4d": ["Detailed condition 4d", None, None, None], - "conditiondetailed_5d": ["Detailed condition 5d", None, None, None], - "conditionexact_1d": ["Full condition 1d", None, None, None], - "conditionexact_2d": ["Full condition 2d", None, None, None], - "conditionexact_3d": ["Full condition 3d", None, None, None], - "conditionexact_4d": ["Full condition 4d", None, None, None], - "conditionexact_5d": ["Full condition 5d", None, None, None], - "symbol_1d": ["Symbol 1d", None, None, None], - "symbol_2d": ["Symbol 2d", None, None, None], - "symbol_3d": ["Symbol 3d", None, None, None], - "symbol_4d": ["Symbol 4d", None, None, None], - "symbol_5d": ["Symbol 5d", None, None, None], -} + SensorEntityDescription( + key="maxrain_1d", + name="Maximum rain 1d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_2d", + name="Maximum rain 2d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_3d", + name="Maximum rain 3d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_4d", + name="Maximum rain 4d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_5d", + name="Maximum rain 5d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_1d", + name="Rainchance 1d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_2d", + name="Rainchance 2d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_3d", + name="Rainchance 3d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_4d", + name="Rainchance 4d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_5d", + name="Rainchance 5d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="sunchance_1d", + name="Sunchance 1d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_2d", + name="Sunchance 2d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_3d", + name="Sunchance 3d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_4d", + name="Sunchance 4d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_5d", + name="Sunchance 5d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="windforce_1d", + name="Wind force 1d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_2d", + name="Wind force 2d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_3d", + name="Wind force 3d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_4d", + name="Wind force 4d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_5d", + name="Wind force 5d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_1d", + name="Wind speed 1d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_2d", + name="Wind speed 2d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_3d", + name="Wind speed 3d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_4d", + name="Wind speed 4d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_5d", + name="Wind speed 5d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="winddirection_1d", + name="Wind direction 1d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_2d", + name="Wind direction 2d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_3d", + name="Wind direction 3d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_4d", + name="Wind direction 4d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_5d", + name="Wind direction 5d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_1d", + name="Wind direction azimuth 1d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_2d", + name="Wind direction azimuth 2d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_3d", + name="Wind direction azimuth 3d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_4d", + name="Wind direction azimuth 4d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_5d", + name="Wind direction azimuth 5d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="condition_1d", + name="Condition 1d", + ), + SensorEntityDescription( + key="condition_2d", + name="Condition 2d", + ), + SensorEntityDescription( + key="condition_3d", + name="Condition 3d", + ), + SensorEntityDescription( + key="condition_4d", + name="Condition 4d", + ), + SensorEntityDescription( + key="condition_5d", + name="Condition 5d", + ), + SensorEntityDescription( + key="conditioncode_1d", + name="Condition code 1d", + ), + SensorEntityDescription( + key="conditioncode_2d", + name="Condition code 2d", + ), + SensorEntityDescription( + key="conditioncode_3d", + name="Condition code 3d", + ), + SensorEntityDescription( + key="conditioncode_4d", + name="Condition code 4d", + ), + SensorEntityDescription( + key="conditioncode_5d", + name="Condition code 5d", + ), + SensorEntityDescription( + key="conditiondetailed_1d", + name="Detailed condition 1d", + ), + SensorEntityDescription( + key="conditiondetailed_2d", + name="Detailed condition 2d", + ), + SensorEntityDescription( + key="conditiondetailed_3d", + name="Detailed condition 3d", + ), + SensorEntityDescription( + key="conditiondetailed_4d", + name="Detailed condition 4d", + ), + SensorEntityDescription( + key="conditiondetailed_5d", + name="Detailed condition 5d", + ), + SensorEntityDescription( + key="conditionexact_1d", + name="Full condition 1d", + ), + SensorEntityDescription( + key="conditionexact_2d", + name="Full condition 2d", + ), + SensorEntityDescription( + key="conditionexact_3d", + name="Full condition 3d", + ), + SensorEntityDescription( + key="conditionexact_4d", + name="Full condition 4d", + ), + SensorEntityDescription( + key="conditionexact_5d", + name="Full condition 5d", + ), + SensorEntityDescription( + key="symbol_1d", + name="Symbol 1d", + ), + SensorEntityDescription( + key="symbol_2d", + name="Symbol 2d", + ), + SensorEntityDescription( + key="symbol_3d", + name="Symbol 3d", + ), + SensorEntityDescription( + key="symbol_4d", + name="Symbol 4d", + ), + SensorEntityDescription( + key="symbol_5d", + name="Symbol 5d", + ), +) async def async_setup_entry( @@ -342,8 +662,8 @@ async def async_setup_entry( ) entities = [ - BrSensor(sensor_type, config.get(CONF_NAME, "Buienradar"), coordinates) - for sensor_type in SENSOR_TYPES + BrSensor(config.get(CONF_NAME, "Buienradar"), coordinates, description) + for description in SENSOR_TYPES ] async_add_entities(entities) @@ -359,24 +679,21 @@ class BrSensor(SensorEntity): _attr_entity_registry_enabled_default = False _attr_should_poll = False - def __init__(self, sensor_type, client_name, coordinates): + def __init__(self, client_name, coordinates, description: SensorEntityDescription): """Initialize the sensor.""" - self._attr_name = f"{client_name} {SENSOR_TYPES[sensor_type][0]}" - self._attr_icon = SENSOR_TYPES[sensor_type][2] - self.type = sensor_type - self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.entity_description = description + self._attr_name = f"{client_name} {description.name}" self._measured = None self._attr_unique_id = "{:2.6f}{:2.6f}{}".format( - coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], sensor_type + coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], description.key ) - self._attr_device_class = SENSOR_TYPES[sensor_type][3] # All continuous sensors should be forced to be updated - self._attr_force_update = sensor_type != SYMBOL and not sensor_type.startswith( - CONDITION + self._attr_force_update = ( + description.key != SYMBOL and not description.key.startswith(CONDITION) ) - if sensor_type.startswith(PRECIPITATION_FORECAST): + if description.key.startswith(PRECIPITATION_FORECAST): self._timeframe = None @callback @@ -396,28 +713,29 @@ class BrSensor(SensorEntity): return False self._measured = data.get(MEASURED) + sensor_type = self.entity_description.key if ( - self.type.endswith("_1d") - or self.type.endswith("_2d") - or self.type.endswith("_3d") - or self.type.endswith("_4d") - or self.type.endswith("_5d") + sensor_type.endswith("_1d") + or sensor_type.endswith("_2d") + or sensor_type.endswith("_3d") + or sensor_type.endswith("_4d") + or sensor_type.endswith("_5d") ): # update forcasting sensors: fcday = 0 - if self.type.endswith("_2d"): + if sensor_type.endswith("_2d"): fcday = 1 - if self.type.endswith("_3d"): + if sensor_type.endswith("_3d"): fcday = 2 - if self.type.endswith("_4d"): + if sensor_type.endswith("_4d"): fcday = 3 - if self.type.endswith("_5d"): + if sensor_type.endswith("_5d"): fcday = 4 # update weather symbol & status text - if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): + if sensor_type.startswith(SYMBOL) or sensor_type.startswith(CONDITION): try: condition = data.get(FORECAST)[fcday].get(CONDITION) except IndexError: @@ -426,13 +744,13 @@ class BrSensor(SensorEntity): if condition: new_state = condition.get(CONDITION) - if self.type.startswith(SYMBOL): + if sensor_type.startswith(SYMBOL): new_state = condition.get(EXACTNL) - if self.type.startswith("conditioncode"): + if sensor_type.startswith("conditioncode"): new_state = condition.get(CONDCODE) - if self.type.startswith("conditiondetailed"): + if sensor_type.startswith("conditiondetailed"): new_state = condition.get(DETAILED) - if self.type.startswith("conditionexact"): + if sensor_type.startswith("conditionexact"): new_state = condition.get(EXACT) img = condition.get(IMAGE) @@ -443,11 +761,11 @@ class BrSensor(SensorEntity): return True return False - if self.type.startswith(WINDSPEED): + if sensor_type.startswith(WINDSPEED): # hass wants windspeeds in km/h not m/s, so convert: try: self._attr_native_value = data.get(FORECAST)[fcday].get( - self.type[:-3] + sensor_type[:-3] ) if self.state is not None: self._attr_native_value = round(self.state * 3.6, 1) @@ -458,25 +776,27 @@ class BrSensor(SensorEntity): # update all other sensors try: - self._attr_native_value = data.get(FORECAST)[fcday].get(self.type[:-3]) + self._attr_native_value = data.get(FORECAST)[fcday].get( + sensor_type[:-3] + ) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) return False - if self.type == SYMBOL or self.type.startswith(CONDITION): + if sensor_type == SYMBOL or sensor_type.startswith(CONDITION): # update weather symbol & status text condition = data.get(CONDITION) if condition: - if self.type == SYMBOL: + if sensor_type == SYMBOL: new_state = condition.get(EXACTNL) - if self.type == CONDITION: + if sensor_type == CONDITION: new_state = condition.get(CONDITION) - if self.type == "conditioncode": + if sensor_type == "conditioncode": new_state = condition.get(CONDCODE) - if self.type == "conditiondetailed": + if sensor_type == "conditiondetailed": new_state = condition.get(DETAILED) - if self.type == "conditionexact": + if sensor_type == "conditionexact": new_state = condition.get(EXACT) img = condition.get(IMAGE) @@ -488,32 +808,32 @@ class BrSensor(SensorEntity): return False - if self.type.startswith(PRECIPITATION_FORECAST): + if sensor_type.startswith(PRECIPITATION_FORECAST): # update nested precipitation forecast sensors nested = data.get(PRECIPITATION_FORECAST) self._timeframe = nested.get(TIMEFRAME) self._attr_native_value = nested.get( - self.type[len(PRECIPITATION_FORECAST) + 1 :] + sensor_type[len(PRECIPITATION_FORECAST) + 1 :] ) return True - if self.type in [WINDSPEED, WINDGUST]: + if sensor_type in [WINDSPEED, WINDGUST]: # hass wants windspeeds in km/h not m/s, so convert: - self._attr_native_value = data.get(self.type) + self._attr_native_value = data.get(sensor_type) if self.state is not None: - self._attr_native_value = round(data.get(self.type) * 3.6, 1) + self._attr_native_value = round(data.get(sensor_type) * 3.6, 1) return True - if self.type == VISIBILITY: + if sensor_type == VISIBILITY: # hass wants visibility in km (not m), so convert: - self._attr_native_value = data.get(self.type) + self._attr_native_value = data.get(sensor_type) if self.state is not None: self._attr_native_value = round(self.state / 1000, 1) return True # update all other sensors - self._attr_native_value = data.get(self.type) - if self.type.startswith(PRECIPITATION_FORECAST): + self._attr_native_value = data.get(sensor_type) + if sensor_type.startswith(PRECIPITATION_FORECAST): result = {ATTR_ATTRIBUTION: data.get(ATTRIBUTION)} if self._timeframe is not None: result[TIMEFRAME_LABEL] = "%d min" % (self._timeframe) @@ -522,7 +842,7 @@ class BrSensor(SensorEntity): result = { ATTR_ATTRIBUTION: data.get(ATTRIBUTION), - SENSOR_TYPES["stationname"][0]: data.get(STATIONNAME), + STATIONNAME_LABEL: data.get(STATIONNAME), } if self._measured is not None: # convert datetime (Europe/Amsterdam) into local datetime From dce816ee9655cc633b425ee01764b0fd1e28ef33 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 21 Aug 2021 20:30:42 +0200 Subject: [PATCH 598/903] Use EntityDescription - nzbget (#54427) --- homeassistant/components/nzbget/sensor.py | 125 +++++++++++----------- 1 file changed, 65 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 325438908a7..5bfde7e7c2b 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, @@ -22,22 +22,56 @@ from .coordinator import NZBGetDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "article_cache": ["ArticleCacheMB", "Article Cache", DATA_MEGABYTES], - "average_download_rate": [ - "AverageDownloadRate", - "Average Speed", - DATA_RATE_MEGABYTES_PER_SECOND, - ], - "download_paused": ["DownloadPaused", "Download Paused", None], - "download_rate": ["DownloadRate", "Speed", DATA_RATE_MEGABYTES_PER_SECOND], - "download_size": ["DownloadedSizeMB", "Size", DATA_MEGABYTES], - "free_disk_space": ["FreeDiskSpaceMB", "Disk Free", DATA_MEGABYTES], - "post_job_count": ["PostJobCount", "Post Processing Jobs", "Jobs"], - "post_paused": ["PostPaused", "Post Processing Paused", None], - "remaining_size": ["RemainingSizeMB", "Queue Size", DATA_MEGABYTES], - "uptime": ["UpTimeSec", "Uptime", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="ArticleCacheMB", + name="Article Cache", + native_unit_of_measurement=DATA_MEGABYTES, + ), + SensorEntityDescription( + key="AverageDownloadRate", + name="Average Speed", + native_unit_of_measurement=DATA_RATE_MEGABYTES_PER_SECOND, + ), + SensorEntityDescription( + key="DownloadPaused", + name="Download Paused", + ), + SensorEntityDescription( + key="DownloadRate", + name="Speed", + native_unit_of_measurement=DATA_RATE_MEGABYTES_PER_SECOND, + ), + SensorEntityDescription( + key="DownloadedSizeMB", + name="Size", + native_unit_of_measurement=DATA_MEGABYTES, + ), + SensorEntityDescription( + key="FreeDiskSpaceMB", + name="Disk Free", + native_unit_of_measurement=DATA_MEGABYTES, + ), + SensorEntityDescription( + key="PostJobCount", + name="Post Processing Jobs", + native_unit_of_measurement="Jobs", + ), + SensorEntityDescription( + key="PostPaused", + name="Post Processing Paused", + ), + SensorEntityDescription( + key="RemainingSizeMB", + name="Queue Size", + native_unit_of_measurement=DATA_MEGABYTES, + ), + SensorEntityDescription( + key="UpTimeSec", + name="Uptime", + device_class=DEVICE_CLASS_TIMESTAMP, + ), +) async def async_setup_entry( @@ -49,21 +83,12 @@ async def async_setup_entry( coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ DATA_COORDINATOR ] - sensors = [] + entities = [ + NZBGetSensor(coordinator, entry.entry_id, entry.data[CONF_NAME], description) + for description in SENSOR_TYPES + ] - for sensor_config in SENSOR_TYPES.values(): - sensors.append( - NZBGetSensor( - coordinator, - entry.entry_id, - entry.data[CONF_NAME], - sensor_config[0], - sensor_config[1], - sensor_config[2], - ) - ) - - async_add_entities(sensors) + async_add_entities(entities) class NZBGetSensor(NZBGetEntity, SensorEntity): @@ -74,53 +99,33 @@ class NZBGetSensor(NZBGetEntity, SensorEntity): coordinator: NZBGetDataUpdateCoordinator, entry_id: str, entry_name: str, - sensor_type: str, - sensor_name: str, - unit_of_measurement: str | None = None, + description: SensorEntityDescription, ) -> None: """Initialize a new NZBGet sensor.""" - self._sensor_type = sensor_type - self._unique_id = f"{entry_id}_{sensor_type}" - self._unit_of_measurement = unit_of_measurement + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" super().__init__( coordinator=coordinator, entry_id=entry_id, - name=f"{entry_name} {sensor_name}", + name=f"{entry_name} {description.name}", ) - @property - def device_class(self): - """Return the device class.""" - if "UpTimeSec" in self._sensor_type: - return DEVICE_CLASS_TIMESTAMP - - return None - - @property - def unique_id(self) -> str: - """Return the unique ID of the sensor.""" - return self._unique_id - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit that the state of sensor is expressed in.""" - return self._unit_of_measurement - @property def native_value(self): """Return the state of the sensor.""" - value = self.coordinator.data["status"].get(self._sensor_type) + sensor_type = self.entity_description.key + value = self.coordinator.data["status"].get(sensor_type) if value is None: - _LOGGER.warning("Unable to locate value for %s", self._sensor_type) + _LOGGER.warning("Unable to locate value for %s", sensor_type) return None - if "DownloadRate" in self._sensor_type and value > 0: + if "DownloadRate" in sensor_type and value > 0: # Convert download rate from Bytes/s to MBytes/s return round(value / 2 ** 20, 2) - if "UpTimeSec" in self._sensor_type and value > 0: + if "UpTimeSec" in sensor_type and value > 0: uptime = utcnow() - timedelta(seconds=value) return uptime.replace(microsecond=0).isoformat() From 05de7a78d1c87b7bcdce9dbba7d89aa4ec7db3d3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 21 Aug 2021 20:32:40 +0200 Subject: [PATCH 599/903] Use EntityDescription - trafikverket_weatherstation (#54430) --- .../trafikverket_weatherstation/sensor.py | 235 +++++++++--------- 1 file changed, 119 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 1435da6a988..5fe3c462a56 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -1,6 +1,8 @@ """Weather information for air and road temperature (by Trafikverket).""" +from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import timedelta import logging @@ -8,7 +10,11 @@ import aiohttp from pytrafikverket.trafikverket_weather import TrafikverketWeather import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, @@ -38,85 +44,102 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) SCAN_INTERVAL = timedelta(seconds=300) -SENSOR_TYPES = { - "air_temp": [ - "Air temperature", - TEMP_CELSIUS, - "air_temp", - "mdi:thermometer", - DEVICE_CLASS_TEMPERATURE, - ], - "road_temp": [ - "Road temperature", - TEMP_CELSIUS, - "road_temp", - "mdi:thermometer", - DEVICE_CLASS_TEMPERATURE, - ], - "precipitation": [ - "Precipitation type", - None, - "precipitationtype", - "mdi:weather-snowy-rainy", - None, - ], - "wind_direction": [ - "Wind direction", - DEGREE, - "winddirection", - "mdi:flag-triangle", - None, - ], - "wind_direction_text": [ - "Wind direction text", - None, - "winddirectiontext", - "mdi:flag-triangle", - None, - ], - "wind_speed": [ - "Wind speed", - SPEED_METERS_PER_SECOND, - "windforce", - "mdi:weather-windy", - None, - ], - "wind_speed_max": [ - "Wind speed max", - SPEED_METERS_PER_SECOND, - "windforcemax", - "mdi:weather-windy-variant", - None, - ], - "humidity": [ - "Humidity", - PERCENTAGE, - "humidity", - "mdi:water-percent", - DEVICE_CLASS_HUMIDITY, - ], - "precipitation_amount": [ - "Precipitation amount", - LENGTH_MILLIMETERS, - "precipitation_amount", - "mdi:cup-water", - None, - ], - "precipitation_amountname": [ - "Precipitation name", - None, - "precipitation_amountname", - "mdi:weather-pouring", - None, - ], -} + +@dataclass +class TrafikverketRequiredKeysMixin: + """Mixin for required keys.""" + + api_key: str + + +@dataclass +class TrafikverketSensorEntityDescription( + SensorEntityDescription, TrafikverketRequiredKeysMixin +): + """Describes Trafikverket sensor entity.""" + + +SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( + TrafikverketSensorEntityDescription( + key="air_temp", + api_key="air_temp", + name="Air temperature", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + TrafikverketSensorEntityDescription( + key="road_temp", + api_key="road_temp", + name="Road temperature", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + TrafikverketSensorEntityDescription( + key="precipitation", + api_key="precipitationtype", + name="Precipitation type", + icon="mdi:weather-snowy-rainy", + ), + TrafikverketSensorEntityDescription( + key="wind_direction", + api_key="winddirection", + name="Wind direction", + native_unit_of_measurement=DEGREE, + icon="mdi:flag-triangle", + ), + TrafikverketSensorEntityDescription( + key="wind_direction_text", + api_key="winddirectiontext", + name="Wind direction text", + icon="mdi:flag-triangle", + ), + TrafikverketSensorEntityDescription( + key="wind_speed", + api_key="windforce", + name="Wind speed", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + icon="mdi:weather-windy", + ), + TrafikverketSensorEntityDescription( + key="wind_speed_max", + api_key="windforcemax", + name="Wind speed max", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + icon="mdi:weather-windy-variant", + ), + TrafikverketSensorEntityDescription( + key="humidity", + api_key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + device_class=DEVICE_CLASS_HUMIDITY, + ), + TrafikverketSensorEntityDescription( + key="precipitation_amount", + api_key="precipitation_amount", + name="Precipitation amount", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:cup-water", + ), + TrafikverketSensorEntityDescription( + key="precipitation_amountname", + api_key="precipitation_amountname", + name="Precipitation name", + icon="mdi:weather-pouring", + ), +) + +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_NAME): cv.string, vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_STATION): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=[]): [vol.In(SENSOR_TYPES)], + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): [vol.In(SENSOR_KEYS)], } ) @@ -132,44 +155,37 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= weather_api = TrafikverketWeather(web_session, sensor_api) - dev = [] - for condition in config[CONF_MONITORED_CONDITIONS]: - dev.append( - TrafikverketWeatherStation( - weather_api, sensor_name, condition, sensor_station - ) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + TrafikverketWeatherStation( + weather_api, sensor_name, sensor_station, description ) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - if dev: - async_add_entities(dev, True) + async_add_entities(entities, True) class TrafikverketWeatherStation(SensorEntity): """Representation of a Trafikverket sensor.""" - def __init__(self, weather_api, name, sensor_type, sensor_station): + entity_description: TrafikverketSensorEntityDescription + + def __init__( + self, + weather_api, + name, + sensor_station, + description: TrafikverketSensorEntityDescription, + ): """Initialize the sensor.""" - self._client = name - self._name = SENSOR_TYPES[sensor_type][0] - self._type = sensor_type - self._state = None - self._unit = SENSOR_TYPES[sensor_type][1] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self._station = sensor_station self._weather_api = weather_api - self._icon = SENSOR_TYPES[sensor_type][3] - self._device_class = SENSOR_TYPES[sensor_type][4] self._weather = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._client} {self._name}" - - @property - def icon(self): - """Icon to use in the frontend.""" - return self._icon - @property def extra_state_attributes(self): """Return the state attributes of Trafikverket Weatherstation.""" @@ -179,26 +195,13 @@ class TrafikverketWeatherStation(SensorEntity): ATTR_MEASURE_TIME: self._weather.measure_time, } - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit - @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Get the latest data from Trafikverket and updates the states.""" try: self._weather = await self._weather_api.async_get_weather(self._station) - self._state = getattr(self._weather, SENSOR_TYPES[self._type][2]) + self._attr_native_value = getattr( + self._weather, self.entity_description.api_key + ) except (asyncio.TimeoutError, aiohttp.ClientError, ValueError) as error: _LOGGER.error("Could not fetch weather data: %s", error) From f9ebb29541d6d90ea3205c002f5ecf65879584cf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 21 Aug 2021 20:47:29 +0200 Subject: [PATCH 600/903] Use EntityDescription - starline (#54431) --- .../components/starline/binary_sensor.py | 83 +++++++++---- homeassistant/components/starline/sensor.py | 115 +++++++++++++----- homeassistant/components/starline/switch.py | 91 ++++++++++---- 3 files changed, 206 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index 3468d141cf6..e2e3d7ea4fa 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -1,57 +1,92 @@ """Reads vehicle status from StarLine API.""" +from __future__ import annotations + +from dataclasses import dataclass + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_DOOR, DEVICE_CLASS_LOCK, DEVICE_CLASS_POWER, DEVICE_CLASS_PROBLEM, BinarySensorEntity, + BinarySensorEntityDescription, ) from .account import StarlineAccount, StarlineDevice from .const import DOMAIN from .entity import StarlineEntity -SENSOR_TYPES = { - "hbrake": ["Hand Brake", DEVICE_CLASS_POWER], - "hood": ["Hood", DEVICE_CLASS_DOOR], - "trunk": ["Trunk", DEVICE_CLASS_DOOR], - "alarm": ["Alarm", DEVICE_CLASS_PROBLEM], - "door": ["Doors", DEVICE_CLASS_LOCK], -} + +@dataclass +class StarlineRequiredKeysMixin: + """Mixin for required keys.""" + + name_: str + + +@dataclass +class StarlineBinarySensorEntityDescription( + BinarySensorEntityDescription, StarlineRequiredKeysMixin +): + """Describes Starline binary_sensor entity.""" + + +BINARY_SENSOR_TYPES: tuple[StarlineBinarySensorEntityDescription, ...] = ( + StarlineBinarySensorEntityDescription( + key="hbrake", + name_="Hand Brake", + device_class=DEVICE_CLASS_POWER, + ), + StarlineBinarySensorEntityDescription( + key="hood", + name_="Hood", + device_class=DEVICE_CLASS_DOOR, + ), + StarlineBinarySensorEntityDescription( + key="trunk", + name_="Trunk", + device_class=DEVICE_CLASS_DOOR, + ), + StarlineBinarySensorEntityDescription( + key="alarm", + name_="Alarm", + device_class=DEVICE_CLASS_PROBLEM, + ), + StarlineBinarySensorEntityDescription( + key="door", + name_="Doors", + device_class=DEVICE_CLASS_LOCK, + ), +) async def async_setup_entry(hass, entry, async_add_entities): """Set up the StarLine sensors.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] - entities = [] - for device in account.api.devices.values(): - for key, value in SENSOR_TYPES.items(): - if key in device.car_state: - sensor = StarlineSensor(account, device, key, *value) - if sensor.is_on is not None: - entities.append(sensor) + entities = [ + sensor + for device in account.api.devices.values() + for description in BINARY_SENSOR_TYPES + if description.key in device.car_state + if (sensor := StarlineSensor(account, device, description)).is_on is not None + ] async_add_entities(entities) class StarlineSensor(StarlineEntity, BinarySensorEntity): """Representation of a StarLine binary sensor.""" + entity_description: StarlineBinarySensorEntityDescription + def __init__( self, account: StarlineAccount, device: StarlineDevice, - key: str, - name: str, - device_class: str, + description: StarlineBinarySensorEntityDescription, ) -> None: """Initialize sensor.""" - super().__init__(account, device, key, name) - self._device_class = device_class - - @property - def device_class(self): - """Return the class of the binary sensor.""" - return self._device_class + super().__init__(account, device, description.key, description.name_) + self.entity_description = description @property def is_on(self): diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 92c6acbab0b..26834cc384c 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -1,5 +1,13 @@ """Reads vehicle status from StarLine API.""" -from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE, SensorEntity +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + DEVICE_CLASS_TEMPERATURE, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ELECTRIC_POTENTIAL_VOLT, LENGTH_KILOMETERS, @@ -13,48 +21,94 @@ from .account import StarlineAccount, StarlineDevice from .const import DOMAIN from .entity import StarlineEntity -SENSOR_TYPES = { - "battery": ["Battery", None, ELECTRIC_POTENTIAL_VOLT, None], - "balance": ["Balance", None, None, "mdi:cash-multiple"], - "ctemp": ["Interior Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None], - "etemp": ["Engine Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None], - "gsm_lvl": ["GSM Signal", None, PERCENTAGE, None], - "fuel": ["Fuel Volume", None, None, "mdi:fuel"], - "errors": ["OBD Errors", None, None, "mdi:alert-octagon"], - "mileage": ["Mileage", None, LENGTH_KILOMETERS, "mdi:counter"], -} + +@dataclass +class StarlineRequiredKeysMixin: + """Mixin for required keys.""" + + name_: str + + +@dataclass +class StarlineSensorEntityDescription( + SensorEntityDescription, StarlineRequiredKeysMixin +): + """Describes Starline binary_sensor entity.""" + + +SENSOR_TYPES: tuple[StarlineSensorEntityDescription, ...] = ( + StarlineSensorEntityDescription( + key="battery", + name_="Battery", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + ), + StarlineSensorEntityDescription( + key="balance", + name_="Balance", + icon="mdi:cash-multiple", + ), + StarlineSensorEntityDescription( + key="ctemp", + name_="Interior Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + StarlineSensorEntityDescription( + key="etemp", + name_="Engine Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + StarlineSensorEntityDescription( + key="gsm_lvl", + name_="GSM Signal", + native_unit_of_measurement=PERCENTAGE, + ), + StarlineSensorEntityDescription( + key="fuel", + name_="Fuel Volume", + icon="mdi:fuel", + ), + StarlineSensorEntityDescription( + key="errors", + name_="OBD Errors", + icon="mdi:alert-octagon", + ), + StarlineSensorEntityDescription( + key="mileage", + name_="Mileage", + native_unit_of_measurement=LENGTH_KILOMETERS, + icon="mdi:counter", + ), +) async def async_setup_entry(hass, entry, async_add_entities): """Set up the StarLine sensors.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] - entities = [] - for device in account.api.devices.values(): - for key, value in SENSOR_TYPES.items(): - sensor = StarlineSensor(account, device, key, *value) - if sensor.state is not None: - entities.append(sensor) + entities = [ + sensor + for device in account.api.devices.values() + for description in SENSOR_TYPES + if (sensor := StarlineSensor(account, device, description)).state is not None + ] async_add_entities(entities) class StarlineSensor(StarlineEntity, SensorEntity): """Representation of a StarLine sensor.""" + entity_description: StarlineSensorEntityDescription + def __init__( self, account: StarlineAccount, device: StarlineDevice, - key: str, - name: str, - device_class: str, - unit: str, - icon: str, + description: StarlineSensorEntityDescription, ) -> None: """Initialize StarLine sensor.""" - super().__init__(account, device, key, name) - self._device_class = device_class - self._unit = unit - self._icon = icon + super().__init__(account, device, description.key, description.name_) + self.entity_description = description @property def icon(self): @@ -66,7 +120,7 @@ class StarlineSensor(StarlineEntity, SensorEntity): ) if self._key == "gsm_lvl": return icon_for_signal_level(signal_level=self._device.gsm_level_percent) - return self._icon + return self.entity_description.icon @property def native_value(self): @@ -100,12 +154,7 @@ class StarlineSensor(StarlineEntity, SensorEntity): return PERCENTAGE if type_value == "litres": return VOLUME_LITERS - return self._unit - - @property - def device_class(self): - """Return the class of the sensor.""" - return self._device_class + return self.entity_description.native_unit_of_measurement @property def extra_state_attributes(self): diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index c8afc41cb2d..684e7ecc662 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -1,51 +1,86 @@ """Support for StarLine switch.""" -from homeassistant.components.switch import SwitchEntity +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from .account import StarlineAccount, StarlineDevice from .const import DOMAIN from .entity import StarlineEntity -SWITCH_TYPES = { - "ign": ["Engine", "mdi:engine-outline", "mdi:engine-off-outline"], - "webasto": ["Webasto", "mdi:radiator", "mdi:radiator-off"], - "out": [ - "Additional Channel", - "mdi:access-point-network", - "mdi:access-point-network-off", - ], - "poke": ["Horn", "mdi:bullhorn-outline", "mdi:bullhorn-outline"], -} + +@dataclass +class StarlineRequiredKeysMixin: + """Mixin for required keys.""" + + name_: str + icon_on: str + icon_off: str + + +@dataclass +class StarlineSwitchEntityDescription( + SwitchEntityDescription, StarlineRequiredKeysMixin +): + """Describes Starline switch entity.""" + + +SWITCH_TYPES: tuple[StarlineSwitchEntityDescription, ...] = ( + StarlineSwitchEntityDescription( + key="ign", + name_="Engine", + icon_on="mdi:engine-outline", + icon_off="mdi:engine-off-outline", + ), + StarlineSwitchEntityDescription( + key="webasto", + name_="Webasto", + icon_on="mdi:radiator", + icon_off="mdi:radiator-off", + ), + StarlineSwitchEntityDescription( + key="out", + name_="Additional Channel", + icon_on="mdi:access-point-network", + icon_off="mdi:access-point-network-off", + ), + StarlineSwitchEntityDescription( + key="poke", + name_="Horn", + icon_on="mdi:bullhorn-outline", + icon_off="mdi:bullhorn-outline", + ), +) async def async_setup_entry(hass, entry, async_add_entities): """Set up the StarLine switch.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] - entities = [] - for device in account.api.devices.values(): - if device.support_state: - for key, value in SWITCH_TYPES.items(): - switch = StarlineSwitch(account, device, key, *value) - if switch.is_on is not None: - entities.append(switch) + entities = [ + switch + for device in account.api.devices.values() + if device.support_state + for description in SWITCH_TYPES + if (switch := StarlineSwitch(account, device, description)).is_on is not None + ] async_add_entities(entities) class StarlineSwitch(StarlineEntity, SwitchEntity): """Representation of a StarLine switch.""" + entity_description: StarlineSwitchEntityDescription + def __init__( self, account: StarlineAccount, device: StarlineDevice, - key: str, - name: str, - icon_on: str, - icon_off: str, + description: StarlineSwitchEntityDescription, ) -> None: """Initialize the switch.""" - super().__init__(account, device, key, name) - self._icon_on = icon_on - self._icon_off = icon_off + super().__init__(account, device, description.key, description.name_) + self.entity_description = description @property def available(self): @@ -62,7 +97,11 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): @property def icon(self): """Icon to use in the frontend, if any.""" - return self._icon_on if self.is_on else self._icon_off + return ( + self.entity_description.icon_on + if self.is_on + else self.entity_description.icon_off + ) @property def assumed_state(self): From 4916016648704851d87dd730e6d18acc9c0a6427 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 21 Aug 2021 20:53:25 +0200 Subject: [PATCH 601/903] Use EntityDescription - envirophat (#54944) --- homeassistant/components/envirophat/sensor.py | 232 +++++++++++------- 1 file changed, 144 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/envirophat/sensor.py b/homeassistant/components/envirophat/sensor.py index a41b1678faa..48f53709c40 100644 --- a/homeassistant/components/envirophat/sensor.py +++ b/homeassistant/components/envirophat/sensor.py @@ -1,11 +1,17 @@ """Support for Enviro pHAT sensors.""" +from __future__ import annotations + from datetime import timedelta import importlib import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_DISPLAY_OPTIONS, CONF_NAME, @@ -24,29 +30,104 @@ CONF_USE_LEDS = "use_leds" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -SENSOR_TYPES = { - "light": ["light", " ", "mdi:weather-sunny", None], - "light_red": ["light_red", " ", "mdi:invert-colors", None], - "light_green": ["light_green", " ", "mdi:invert-colors", None], - "light_blue": ["light_blue", " ", "mdi:invert-colors", None], - "accelerometer_x": ["accelerometer_x", "G", "mdi:earth", None], - "accelerometer_y": ["accelerometer_y", "G", "mdi:earth", None], - "accelerometer_z": ["accelerometer_z", "G", "mdi:earth", None], - "magnetometer_x": ["magnetometer_x", " ", "mdi:magnet", None], - "magnetometer_y": ["magnetometer_y", " ", "mdi:magnet", None], - "magnetometer_z": ["magnetometer_z", " ", "mdi:magnet", None], - "temperature": ["temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "pressure": ["pressure", PRESSURE_HPA, "mdi:gauge", None], - "voltage_0": ["voltage_0", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "voltage_1": ["voltage_1", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "voltage_2": ["voltage_2", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "voltage_3": ["voltage_3", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="light", + name="light", + icon="mdi:weather-sunny", + ), + SensorEntityDescription( + key="light_red", + name="light_red", + icon="mdi:invert-colors", + ), + SensorEntityDescription( + key="light_green", + name="light_green", + icon="mdi:invert-colors", + ), + SensorEntityDescription( + key="light_blue", + name="light_blue", + icon="mdi:invert-colors", + ), + SensorEntityDescription( + key="accelerometer_x", + name="accelerometer_x", + native_unit_of_measurement="G", + icon="mdi:earth", + ), + SensorEntityDescription( + key="accelerometer_y", + name="accelerometer_y", + native_unit_of_measurement="G", + icon="mdi:earth", + ), + SensorEntityDescription( + key="accelerometer_z", + name="accelerometer_z", + native_unit_of_measurement="G", + icon="mdi:earth", + ), + SensorEntityDescription( + key="magnetometer_x", + name="magnetometer_x", + icon="mdi:magnet", + ), + SensorEntityDescription( + key="magnetometer_y", + name="magnetometer_y", + icon="mdi:magnet", + ), + SensorEntityDescription( + key="magnetometer_z", + name="magnetometer_z", + icon="mdi:magnet", + ), + SensorEntityDescription( + key="temperature", + name="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="pressure", + name="pressure", + native_unit_of_measurement=PRESSURE_HPA, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="voltage_0", + name="voltage_0", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="voltage_1", + name="voltage_1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="voltage_2", + name="voltage_2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="voltage_3", + name="voltage_3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)): [ - vol.In(SENSOR_TYPES) + vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_KEYS)): [ + vol.In(SENSOR_KEYS) ], vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_USE_LEDS, default=False): cv.boolean, @@ -64,85 +145,60 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = EnvirophatData(envirophat, config.get(CONF_USE_LEDS)) - dev = [] - for variable in config[CONF_DISPLAY_OPTIONS]: - dev.append(EnvirophatSensor(data, variable)) - - add_entities(dev, True) + display_options = config[CONF_DISPLAY_OPTIONS] + entities = [ + EnvirophatSensor(data, description) + for description in SENSOR_TYPES + if description.key in display_options + ] + add_entities(entities, True) class EnvirophatSensor(SensorEntity): """Representation of an Enviro pHAT sensor.""" - def __init__(self, data, sensor_types): + def __init__(self, data, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._name = SENSOR_TYPES[sensor_types][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_types][1] - self.type = sensor_types - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES[self.type][3] - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement def update(self): """Get the latest data and updates the states.""" self.data.update() - if self.type == "light": - self._state = self.data.light - if self.type == "light_red": - self._state = self.data.light_red - if self.type == "light_green": - self._state = self.data.light_green - if self.type == "light_blue": - self._state = self.data.light_blue - if self.type == "accelerometer_x": - self._state = self.data.accelerometer_x - if self.type == "accelerometer_y": - self._state = self.data.accelerometer_y - if self.type == "accelerometer_z": - self._state = self.data.accelerometer_z - if self.type == "magnetometer_x": - self._state = self.data.magnetometer_x - if self.type == "magnetometer_y": - self._state = self.data.magnetometer_y - if self.type == "magnetometer_z": - self._state = self.data.magnetometer_z - if self.type == "temperature": - self._state = self.data.temperature - if self.type == "pressure": - self._state = self.data.pressure - if self.type == "voltage_0": - self._state = self.data.voltage_0 - if self.type == "voltage_1": - self._state = self.data.voltage_1 - if self.type == "voltage_2": - self._state = self.data.voltage_2 - if self.type == "voltage_3": - self._state = self.data.voltage_3 + sensor_type = self.entity_description.key + if sensor_type == "light": + self._attr_native_value = self.data.light + elif sensor_type == "light_red": + self._attr_native_value = self.data.light_red + elif sensor_type == "light_green": + self._attr_native_value = self.data.light_green + elif sensor_type == "light_blue": + self._attr_native_value = self.data.light_blue + elif sensor_type == "accelerometer_x": + self._attr_native_value = self.data.accelerometer_x + elif sensor_type == "accelerometer_y": + self._attr_native_value = self.data.accelerometer_y + elif sensor_type == "accelerometer_z": + self._attr_native_value = self.data.accelerometer_z + elif sensor_type == "magnetometer_x": + self._attr_native_value = self.data.magnetometer_x + elif sensor_type == "magnetometer_y": + self._attr_native_value = self.data.magnetometer_y + elif sensor_type == "magnetometer_z": + self._attr_native_value = self.data.magnetometer_z + elif sensor_type == "temperature": + self._attr_native_value = self.data.temperature + elif sensor_type == "pressure": + self._attr_native_value = self.data.pressure + elif sensor_type == "voltage_0": + self._attr_native_value = self.data.voltage_0 + elif sensor_type == "voltage_1": + self._attr_native_value = self.data.voltage_1 + elif sensor_type == "voltage_2": + self._attr_native_value = self.data.voltage_2 + elif sensor_type == "voltage_3": + self._attr_native_value = self.data.voltage_3 class EnvirophatData: From ebb8ad308e2e62194cf3463e8481595b5eed0c0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Aug 2021 14:25:28 -0500 Subject: [PATCH 602/903] Fix nmap_tracker typing (#54858) Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof --- .../components/nmap_tracker/__init__.py | 29 +++++++++--------- .../components/nmap_tracker/config_flow.py | 23 ++++++++------ .../components/nmap_tracker/const.py | 15 +++++----- .../components/nmap_tracker/device_tracker.py | 30 +++++++++++-------- mypy.ini | 3 -- script/hassfest/mypy_config.py | 1 - 6 files changed, 55 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 87e9ad895af..78465fbe91d 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from functools import partial import logging +from typing import Final import aiohttp from getmac import get_mac_address @@ -34,19 +35,17 @@ from .const import ( ) # Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n' -NMAP_TRANSIENT_FAILURE = "Assertion failed: htn.toclock_running == true" -MAX_SCAN_ATTEMPTS = 16 -OFFLINE_SCANS_TO_MARK_UNAVAILABLE = 3 +NMAP_TRANSIENT_FAILURE: Final = "Assertion failed: htn.toclock_running == true" +MAX_SCAN_ATTEMPTS: Final = 16 +OFFLINE_SCANS_TO_MARK_UNAVAILABLE: Final = 3 -def short_hostname(hostname): +def short_hostname(hostname: str) -> str: """Return the first part of the hostname.""" - if hostname is None: - return None return hostname.split(".")[0] -def human_readable_name(hostname, vendor, mac_address): +def human_readable_name(hostname: str, vendor: str, mac_address: str) -> str: """Generate a human readable name.""" if hostname: return short_hostname(hostname) @@ -65,7 +64,7 @@ class NmapDevice: ipv4: str manufacturer: str reason: str - last_update: datetime.datetime + last_update: datetime offline_scans: int @@ -74,9 +73,9 @@ class NmapTrackedDevices: def __init__(self) -> None: """Initialize the data.""" - self.tracked: dict = {} - self.ipv4_last_mac: dict = {} - self.config_entry_owner: dict = {} + self.tracked: dict[str, NmapDevice] = {} + self.ipv4_last_mac: dict[str, str] = {} + self.config_entry_owner: dict[str, str] = {} _LOGGER = logging.getLogger(__name__) @@ -132,7 +131,9 @@ def signal_device_update(mac_address) -> str: class NmapDeviceScanner: """This class scans for devices using nmap.""" - def __init__(self, hass, entry, devices): + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, devices: NmapTrackedDevices + ) -> None: """Initialize the scanner.""" self.devices = devices self.home_interval = None @@ -150,9 +151,9 @@ class NmapDeviceScanner: self._exclude = None self._scan_interval = None - self._known_mac_addresses = {} + self._known_mac_addresses: dict[str, str] = {} self._finished_first_scan = False - self._last_results = [] + self._last_results: list[NmapDevice] = [] self._mac_vendor_lookup = None async def async_setup(self): diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index a6d7d3ee74e..2d25b62f1d2 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -10,6 +10,7 @@ from homeassistant import config_entries from homeassistant.components import network from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL from homeassistant.components.network.const import MDNS_TARGET_IP +from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult @@ -40,7 +41,7 @@ async def async_get_network(hass: HomeAssistant) -> str: return str(ip_network(f"{local_ip}/{network_prefix}", False)) -def _normalize_ips_and_network(hosts_str): +def _normalize_ips_and_network(hosts_str: str) -> list[str] | None: """Check if a list of hosts are all ips or ip networks.""" normalized_hosts = [] @@ -74,7 +75,7 @@ def _normalize_ips_and_network(hosts_str): return normalized_hosts -def normalize_input(user_input): +def normalize_input(user_input: dict[str, Any]) -> dict[str, str]: """Validate hosts and exclude are valid.""" errors = {} normalized_hosts = _normalize_ips_and_network(user_input[CONF_HOSTS]) @@ -92,7 +93,9 @@ def normalize_input(user_input): return errors -async def _async_build_schema_with_user_input(hass, user_input, include_options): +async def _async_build_schema_with_user_input( + hass: HomeAssistant, user_input: dict[str, Any], include_options: bool +) -> vol.Schema: hosts = user_input.get(CONF_HOSTS, await async_get_network(hass)) exclude = user_input.get( CONF_EXCLUDE, await network.async_get_source_ip(hass, MDNS_TARGET_IP) @@ -126,7 +129,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Initialize options flow.""" self.options = dict(config_entry.options) - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle options flow.""" errors = {} if user_input is not None: @@ -152,9 +157,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize config flow.""" - self.options = {} + self.options: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -183,14 +188,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - def _async_is_unique_host_list(self, user_input): + def _async_is_unique_host_list(self, user_input: dict[str, Any]) -> bool: hosts = _normalize_ips_and_network(user_input[CONF_HOSTS]) for entry in self._async_current_entries(): if _normalize_ips_and_network(entry.options[CONF_HOSTS]) == hosts: return False return True - async def async_step_import(self, user_input=None): + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Handle import from yaml.""" if not self._async_is_unique_host_list(user_input): return self.async_abort(reason="already_configured") @@ -203,6 +208,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/nmap_tracker/const.py b/homeassistant/components/nmap_tracker/const.py index f8b467d2f19..e25368b22cc 100644 --- a/homeassistant/components/nmap_tracker/const.py +++ b/homeassistant/components/nmap_tracker/const.py @@ -1,14 +1,15 @@ """The Nmap Tracker integration.""" +from typing import Final -DOMAIN = "nmap_tracker" +DOMAIN: Final = "nmap_tracker" -PLATFORMS = ["device_tracker"] +PLATFORMS: Final = ["device_tracker"] -NMAP_TRACKED_DEVICES = "nmap_tracked_devices" +NMAP_TRACKED_DEVICES: Final = "nmap_tracked_devices" # Interval in minutes to exclude devices from a scan while they are home -CONF_HOME_INTERVAL = "home_interval" -CONF_OPTIONS = "scan_options" -DEFAULT_OPTIONS = "-F -T4 --min-rate 10 --host-timeout 5s" +CONF_HOME_INTERVAL: Final = "home_interval" +CONF_OPTIONS: Final = "scan_options" +DEFAULT_OPTIONS: Final = "-F -T4 --min-rate 10 --host-timeout 5s" -TRACKER_SCAN_INTERVAL = 120 +TRACKER_SCAN_INTERVAL: Final = 120 diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index fcf9ae6189e..5ec9f2fcb9a 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -1,13 +1,14 @@ """Support for scanning a network with nmap.""" +from __future__ import annotations import logging -from typing import Callable +from typing import Any, Callable import voluptuous as vol from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, SOURCE_TYPE_ROUTER, ) from homeassistant.components.device_tracker.config_entry import ScannerEntity @@ -18,8 +19,10 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import ConfigType -from . import NmapDeviceScanner, short_hostname, signal_device_update +from . import NmapDevice, NmapDeviceScanner, short_hostname, signal_device_update from .const import ( CONF_HOME_INTERVAL, CONF_OPTIONS, @@ -30,7 +33,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + +PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOSTS): cv.ensure_list, vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, @@ -40,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_get_scanner(hass, config): +async def async_get_scanner(hass: HomeAssistant, config: ConfigType) -> None: """Validate the configuration and return a Nmap scanner.""" validated_config = config[DEVICE_TRACKER_DOMAIN] @@ -110,7 +114,7 @@ class NmapTrackerEntity(ScannerEntity): self._active = active @property - def _device(self) -> bool: + def _device(self) -> NmapDevice: """Get latest device state.""" return self._tracked[self._mac_address] @@ -140,8 +144,10 @@ class NmapTrackerEntity(ScannerEntity): return self._mac_address @property - def hostname(self) -> str: + def hostname(self) -> str | None: """Return hostname of the device.""" + if not self._device.hostname: + return None return short_hostname(self._device.hostname) @property @@ -150,7 +156,7 @@ class NmapTrackerEntity(ScannerEntity): return SOURCE_TYPE_ROUTER @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device information.""" return { "connections": {(CONNECTION_NETWORK_MAC, self._mac_address)}, @@ -164,7 +170,7 @@ class NmapTrackerEntity(ScannerEntity): return False @property - def icon(self): + def icon(self) -> str: """Return device icon.""" return "mdi:lan-connect" if self._active else "mdi:lan-disconnect" @@ -174,7 +180,7 @@ class NmapTrackerEntity(ScannerEntity): self._active = online @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the attributes.""" return { "last_time_reachable": self._device.last_update.isoformat( @@ -184,12 +190,12 @@ class NmapTrackerEntity(ScannerEntity): } @callback - def async_on_demand_update(self, online: bool): + def async_on_demand_update(self, online: bool) -> None: """Update state.""" self.async_process_update(online) self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register state update callback.""" self.async_on_remove( async_dispatcher_connect( diff --git a/mypy.ini b/mypy.ini index 883f5817f50..37007884be2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1499,9 +1499,6 @@ ignore_errors = true [mypy-homeassistant.components.nilu.*] ignore_errors = true -[mypy-homeassistant.components.nmap_tracker.*] -ignore_errors = true - [mypy-homeassistant.components.nsw_fuel_station.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 6d09ce30ce7..2015b9983c7 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -92,7 +92,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.nest.legacy.*", "homeassistant.components.nightscout.*", "homeassistant.components.nilu.*", - "homeassistant.components.nmap_tracker.*", "homeassistant.components.nsw_fuel_station.*", "homeassistant.components.nuki.*", "homeassistant.components.nws.*", From 8d69475d7146ec829dbc9c985831861274694557 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Aug 2021 14:38:02 -0500 Subject: [PATCH 603/903] Fix recorder shutdown race and i/o in event loop (#54979) --- homeassistant/components/recorder/__init__.py | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 897a4eb3c94..17215eb9845 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -14,6 +14,7 @@ from typing import Any, Callable, NamedTuple from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm.session import Session from sqlalchemy.pool import StaticPool import voluptuous as vol @@ -568,8 +569,13 @@ class Recorder(threading.Thread): start = statistics.get_start_time() self.queue.put(StatisticsTask(start)) + @callback def _async_setup_periodic_tasks(self): """Prepare periodic tasks.""" + if self.hass.is_stopping or not self.get_session: + # Home Assistant is shutting down + return + # Run nightly tasks at 4:12am async_track_time_change( self.hass, self.async_nightly_tasks, hour=4, minute=12, second=0 @@ -580,29 +586,6 @@ class Recorder(threading.Thread): self.hass, self.async_hourly_statistics, minute=12, second=0 ) - # Add tasks for missing statistics runs - now = dt_util.utcnow() - last_hour = now.replace(minute=0, second=0, microsecond=0) - start = now - timedelta(days=self.keep_days) - start = start.replace(minute=0, second=0, microsecond=0) - - if not self.get_session: - # Home Assistant is shutting down - return - - # Find the newest statistics run, if any - with session_scope(session=self.get_session()) as session: - last_run = session.query(func.max(StatisticsRuns.start)).scalar() - if last_run: - start = max(start, process_timestamp(last_run) + timedelta(hours=1)) - - # Add tasks - while start < last_hour: - end = start + timedelta(hours=1) - _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) - self.queue.put(StatisticsTask(start)) - start = start + timedelta(hours=1) - def run(self): """Start processing events to save.""" shutdown_task = object() @@ -996,7 +979,7 @@ class Recorder(threading.Thread): self.get_session = None def _setup_run(self): - """Log the start of the current run.""" + """Log the start of the current run and schedule any needed jobs.""" with session_scope(session=self.get_session()) as session: start = self.recording_start end_incomplete_runs(session, start) @@ -1004,9 +987,28 @@ class Recorder(threading.Thread): session.add(self.run_info) session.flush() session.expunge(self.run_info) + self._schedule_compile_missing_statistics(session) self._open_event_session() + def _schedule_compile_missing_statistics(self, session: Session) -> None: + """Add tasks for missing statistics runs.""" + now = dt_util.utcnow() + last_hour = now.replace(minute=0, second=0, microsecond=0) + start = now - timedelta(days=self.keep_days) + start = start.replace(minute=0, second=0, microsecond=0) + + # Find the newest statistics run, if any + if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): + start = max(start, process_timestamp(last_run) + timedelta(hours=1)) + + # Add tasks + while start < last_hour: + end = start + timedelta(hours=1) + _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) + self.queue.put(StatisticsTask(start)) + start = start + timedelta(hours=1) + def _end_session(self): """End the recorder session.""" if self.event_session is None: From a5902fbe29dadca329a4d8d328a0c43fe55ce06a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 21 Aug 2021 21:40:18 +0200 Subject: [PATCH 604/903] Synology sensor name clarification (#54262) --- homeassistant/components/synology_dsm/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 633c264f3c8..1c1f94c5d60 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -236,7 +236,7 @@ UTILISATION_SENSORS: dict[str, EntityInfo] = { ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:network_up": { - ATTR_NAME: "Network Up", + ATTR_NAME: "Upload Throughput", ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_KILOBYTES_PER_SECOND, ATTR_ICON: "mdi:upload", ATTR_DEVICE_CLASS: None, @@ -244,7 +244,7 @@ UTILISATION_SENSORS: dict[str, EntityInfo] = { ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:network_down": { - ATTR_NAME: "Network Down", + ATTR_NAME: "Download Throughput", ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_KILOBYTES_PER_SECOND, ATTR_ICON: "mdi:download", ATTR_DEVICE_CLASS: None, From 9de24300d0aa52b5a16a627949c421846134ed8d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 21 Aug 2021 21:46:24 +0200 Subject: [PATCH 605/903] VSCode switch to terminal.integrated.profiles (#54301) --- .devcontainer/devcontainer.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index efcc0380748..2f94441940e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -24,7 +24,12 @@ "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, - "terminal.integrated.shell.linux": "/usr/bin/zsh", + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/usr/bin/zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", "yaml.customTags": [ "!input scalar", "!secret scalar", From 5329dccd8be02887ee4e61975a4f067b4310b434 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 21 Aug 2021 14:55:51 -0500 Subject: [PATCH 606/903] Remove ctalkington from directv codeowner (#54988) --- CODEOWNERS | 1 - homeassistant/components/directv/manifest.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 0b3d7500a21..5a2828711c0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -119,7 +119,6 @@ homeassistant/components/dexcom/* @gagebenne homeassistant/components/dhcp/* @bdraco homeassistant/components/dht/* @thegardenmonkey homeassistant/components/digital_ocean/* @fabaff -homeassistant/components/directv/* @ctalkington homeassistant/components/discogs/* @thibmaek homeassistant/components/doorbird/* @oblogic7 @bdraco homeassistant/components/dsmr/* @Robbie1221 @frenck diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index 6d69ba2fd5a..3fba13121f1 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -3,7 +3,7 @@ "name": "DirecTV", "documentation": "https://www.home-assistant.io/integrations/directv", "requirements": ["directv==0.4.0"], - "codeowners": ["@ctalkington"], + "codeowners": [], "quality_scale": "gold", "config_flow": true, "ssdp": [ From 0403ea715e2f40b8579bf1be60359acb7c1e3d86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Aug 2021 14:56:49 -0500 Subject: [PATCH 607/903] Add known devices to USB Vendor / Product IDs (#54986) Co-authored-by: kpine --- homeassistant/components/zha/manifest.json | 8 ++++---- homeassistant/components/zwave_js/manifest.json | 6 +++--- homeassistant/loader.py | 7 ++++++- script/hassfest/manifest.py | 1 + script/hassfest/usb.py | 7 ++++++- tests/test_loader.py | 16 ++++++++++++---- 6 files changed, 32 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 5df8cddc167..93d9816d339 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -16,10 +16,10 @@ "zigpy-znp==0.5.3" ], "usb": [ - {"vid":"10C4","pid":"EA60"}, - {"vid":"1CF1","pid":"0030"}, - {"vid":"1A86","pid":"7523"}, - {"vid":"10C4","pid":"8A2A"} + {"vid":"10C4","pid":"EA60","known_devices":["slae.sh cc2652rb stick"]}, + {"vid":"1CF1","pid":"0030","known_devices":["Conbee II"]}, + {"vid":"1A86","pid":"7523","known_devices":["Electrolama zig-a-zig-ah"]}, + {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]} ], "codeowners": ["@dmulcahey", "@adminiuga"], "zeroconf": [ diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 5c2d1f0db81..23a1546a421 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,8 +8,8 @@ "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", "usb": [ - {"vid":"0658","pid":"0200"}, - {"vid":"10C4","pid":"8A2A"}, - {"vid":"10C4","pid":"EA60"} + {"vid":"0658","pid":"0200","known_devices":["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"]}, + {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}, + {"vid":"10C4","pid":"EA60","known_devices":["Aeotec Z-Stick 7", "Silicon Labs UZB-7", "Zooz ZST10 700"]} ] } diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 57244d9ec7b..e186c5d24ba 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -230,7 +230,12 @@ async def async_get_usb(hass: HomeAssistant) -> list[dict[str, str]]: if not integration.usb: continue for entry in integration.usb: - usb.append({"domain": integration.domain, **entry}) + usb.append( + { + "domain": integration.domain, + **{k: v for k, v in entry.items() if k != "known_devices"}, + } + ) return usb diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index acb2a999fe3..8c9776ed7c9 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -210,6 +210,7 @@ MANIFEST_SCHEMA = vol.Schema( { vol.Optional("vid"): vol.All(str, verify_uppercase), vol.Optional("pid"): vol.All(str, verify_uppercase), + vol.Optional("known_devices"): [str], } ) ], diff --git a/script/hassfest/usb.py b/script/hassfest/usb.py index 49da04ee03f..6377fdcb8af 100644 --- a/script/hassfest/usb.py +++ b/script/hassfest/usb.py @@ -33,7 +33,12 @@ def generate_and_validate(integrations: list[dict[str, str]]) -> str: continue for entry in match_types: - match_list.append({"domain": domain, **entry}) + match_list.append( + { + "domain": domain, + **{k: v for k, v in entry.items() if k != "known_devices"}, + } + ) return BASE.format(json.dumps(match_list, indent=4)) diff --git a/tests/test_loader.py b/tests/test_loader.py index 2c5eb91d0fb..9786c9fdcfb 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -369,10 +369,18 @@ def _get_test_integration_with_usb_matcher(hass, name, config_flow): "dependencies": [], "requirements": [], "usb": [ - {"vid": "10C4", "pid": "EA60"}, - {"vid": "1CF1", "pid": "0030"}, - {"vid": "1A86", "pid": "7523"}, - {"vid": "10C4", "pid": "8A2A"}, + { + "vid": "10C4", + "pid": "EA60", + "known_devices": ["slae.sh cc2652rb stick"], + }, + {"vid": "1CF1", "pid": "0030", "known_devices": ["Conbee II"]}, + { + "vid": "1A86", + "pid": "7523", + "known_devices": ["Electrolama zig-a-zig-ah"], + }, + {"vid": "10C4", "pid": "8A2A", "known_devices": ["Nortek HUSBZB-1"]}, ], }, ) From a931e35a145c633d5ee16d6b5c32c20b64d3a150 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 21 Aug 2021 21:59:06 +0200 Subject: [PATCH 608/903] Use EntityDescription - google_wifi (#54941) --- .../components/google_wifi/sensor.py | 156 +++++++++++------- tests/components/google_wifi/test_sensor.py | 14 +- 2 files changed, 101 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 4a062edaae2..46cad2afe08 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -1,11 +1,18 @@ """Support for retrieving status info from Google Wifi/OnHub routers.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import logging import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_MONITORED_CONDITIONS, @@ -32,25 +39,70 @@ ENDPOINT = "/api/v1/status" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) -MONITORED_CONDITIONS = { - ATTR_CURRENT_VERSION: [ - ["software", "softwareVersion"], - None, - "mdi:checkbox-marked-circle-outline", - ], - ATTR_NEW_VERSION: [["software", "updateNewVersion"], None, "mdi:update"], - ATTR_UPTIME: [["system", "uptime"], TIME_DAYS, "mdi:timelapse"], - ATTR_LAST_RESTART: [["system", "uptime"], None, "mdi:restart"], - ATTR_LOCAL_IP: [["wan", "localIpAddress"], None, "mdi:access-point-network"], - ATTR_STATUS: [["wan", "online"], None, "mdi:google"], -} + +@dataclass +class GoogleWifiRequiredKeysMixin: + """Mixin for required keys.""" + + primary_key: str + sensor_key: str + + +@dataclass +class GoogleWifiSensorEntityDescription( + SensorEntityDescription, GoogleWifiRequiredKeysMixin +): + """Describes GoogleWifi sensor entity.""" + + +SENSOR_TYPES: tuple[GoogleWifiSensorEntityDescription, ...] = ( + GoogleWifiSensorEntityDescription( + key=ATTR_CURRENT_VERSION, + primary_key="software", + sensor_key="softwareVersion", + icon="mdi:checkbox-marked-circle-outline", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_NEW_VERSION, + primary_key="software", + sensor_key="updateNewVersion", + icon="mdi:update", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_UPTIME, + primary_key="system", + sensor_key="uptime", + native_unit_of_measurement=TIME_DAYS, + icon="mdi:timelapse", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_LAST_RESTART, + primary_key="system", + sensor_key="uptime", + icon="mdi:restart", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_LOCAL_IP, + primary_key="wan", + sensor_key="localIpAddress", + icon="mdi:access-point-network", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_STATUS, + primary_key="wan", + sensor_key="online", + icon="mdi:google", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(MONITORED_CONDITIONS) - ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] + ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } ) @@ -58,64 +110,42 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Google Wifi sensor.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - conditions = config.get(CONF_MONITORED_CONDITIONS) + name = config[CONF_NAME] + host = config[CONF_HOST] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] - api = GoogleWifiAPI(host, conditions) - dev = [] - for condition in conditions: - dev.append(GoogleWifiSensor(api, name, condition)) - - add_entities(dev, True) + api = GoogleWifiAPI(host, monitored_conditions) + entities = [ + GoogleWifiSensor(api, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + add_entities(entities, True) class GoogleWifiSensor(SensorEntity): """Representation of a Google Wifi sensor.""" - def __init__(self, api, name, variable): + entity_description: GoogleWifiSensorEntityDescription + + def __init__(self, api, name, description: GoogleWifiSensorEntityDescription): """Initialize a Google Wifi sensor.""" + self.entity_description = description self._api = api - self._name = name - self._state = None - - variable_info = MONITORED_CONDITIONS[variable] - self._var_name = variable - self._var_units = variable_info[1] - self._var_icon = variable_info[2] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name}_{self._var_name}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._var_icon - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._var_units + self._attr_name = f"{name}_{description.key}" @property def available(self): """Return availability of Google Wifi API.""" return self._api.available - @property - def native_value(self): - """Return the state of the device.""" - return self._state - def update(self): """Get the latest data from the Google Wifi API.""" self._api.update() if self.available: - self._state = self._api.data[self._var_name] + self._attr_native_value = self._api.data[self.entity_description.key] else: - self._state = None + self._attr_native_value = None class GoogleWifiAPI: @@ -155,13 +185,15 @@ class GoogleWifiAPI: def data_format(self): """Format raw data into easily accessible dict.""" - for attr_key in self.conditions: - value = MONITORED_CONDITIONS[attr_key] + for description in SENSOR_TYPES: + if description.key not in self.conditions: + continue + attr_key = description.key try: - primary_key = value[0][0] - sensor_key = value[0][1] - if primary_key in self.raw_data: - sensor_value = self.raw_data[primary_key][sensor_key] + if description.primary_key in self.raw_data: + sensor_value = self.raw_data[description.primary_key][ + description.sensor_key + ] # Format sensor for better readability if attr_key == ATTR_NEW_VERSION and sensor_value == "0.0.0.0": sensor_value = "Latest" @@ -185,7 +217,7 @@ class GoogleWifiAPI: _LOGGER.error( "Router does not support %s field. " "Please remove %s from monitored_conditions", - sensor_key, + description.sensor_key, attr_key, ) self.data[attr_key] = STATE_UNKNOWN diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index 9b430fa5fae..59c95d9883b 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -75,14 +75,14 @@ def setup_api(hass, data, requests_mock): sensor_dict = {} with patch("homeassistant.util.dt.now", return_value=now): requests_mock.get(resource, text=data, status_code=200) - conditions = google_wifi.MONITORED_CONDITIONS.keys() + conditions = google_wifi.SENSOR_KEYS api = google_wifi.GoogleWifiAPI("localhost", conditions) - for condition, cond_list in google_wifi.MONITORED_CONDITIONS.items(): - sensor_dict[condition] = { - "sensor": google_wifi.GoogleWifiSensor(api, NAME, condition), - "name": f"{NAME}_{condition}", - "units": cond_list[1], - "icon": cond_list[2], + for desc in google_wifi.SENSOR_TYPES: + sensor_dict[desc.key] = { + "sensor": google_wifi.GoogleWifiSensor(api, NAME, desc), + "name": f"{NAME}_{desc.key}", + "units": desc.native_unit_of_measurement, + "icon": desc.icon, } for name in sensor_dict: sensor = sensor_dict[name]["sensor"] From 42f7f19be5486dc0a826008e9eb5b04bbbc98c2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Aug 2021 16:06:44 -0500 Subject: [PATCH 609/903] Switch periodic USB scanning to on-demand websocket when observer is not available (#54953) --- homeassistant/components/usb/__init__.py | 68 +++++++++++++++------- homeassistant/components/usb/manifest.json | 1 + tests/components/usb/test_init.py | 51 ++++++++++------ 3 files changed, 81 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 115b0fc3de9..36a63a36642 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,29 +2,31 @@ from __future__ import annotations import dataclasses -import datetime import logging import os import sys from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_usb +from .const import DOMAIN from .flow import FlowDispatcher, USBFlow from .models import USBDevice from .utils import usb_device_from_port _LOGGER = logging.getLogger(__name__) -# Perodic scanning only happens on non-linux systems -SCAN_INTERVAL = datetime.timedelta(minutes=60) +REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown def human_readable_device_name( @@ -63,6 +65,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: usb = await async_get_usb(hass) usb_discovery = USBDiscovery(hass, FlowDispatcher(hass), usb) await usb_discovery.async_setup() + hass.data[DOMAIN] = usb_discovery + websocket_api.async_register_command(hass, websocket_usb_scan) + return True @@ -80,31 +85,23 @@ class USBDiscovery: self.flow_dispatcher = flow_dispatcher self.usb = usb self.seen: set[tuple[str, ...]] = set() + self.observer_active = False + self._request_debouncer: Debouncer | None = None async def async_setup(self) -> None: """Set up USB Discovery.""" - if not await self._async_start_monitor(): - await self._async_start_scanner() + await self._async_start_monitor() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) async def async_start(self, event: Event) -> None: """Start USB Discovery and run a manual scan.""" self.flow_dispatcher.async_start() - await self.hass.async_add_executor_job(self.scan_serial) + await self._async_scan_serial() - async def _async_start_scanner(self) -> None: - """Perodic scan with pyserial when the observer is not available.""" - stop_track = async_track_time_interval( - self.hass, lambda now: self.scan_serial(), SCAN_INTERVAL - ) - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, callback(lambda event: stop_track()) - ) - - async def _async_start_monitor(self) -> bool: + async def _async_start_monitor(self) -> None: """Start monitoring hardware with pyudev.""" if not sys.platform.startswith("linux"): - return False + return from pyudev import ( # pylint: disable=import-outside-toplevel Context, Monitor, @@ -114,7 +111,7 @@ class USBDiscovery: try: context = Context() except (ImportError, OSError): - return False + return monitor = Monitor.from_netlink(context) monitor.filter_by(subsystem="tty") @@ -125,7 +122,7 @@ class USBDiscovery: self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, lambda event: observer.stop() ) - return True + self.observer_active = True def _device_discovered(self, device): """Call when the observer discovers a new usb tty device.""" @@ -168,3 +165,34 @@ class USBDiscovery: def scan_serial(self) -> None: """Scan serial ports.""" self.hass.add_job(self._async_process_ports, comports()) + + async def _async_scan_serial(self) -> None: + """Scan serial ports.""" + self._async_process_ports(await self.hass.async_add_executor_job(comports)) + + async def async_request_scan_serial(self) -> None: + """Request a serial scan.""" + if not self._request_debouncer: + self._request_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=REQUEST_SCAN_COOLDOWN, + immediate=True, + function=self._async_scan_serial, + ) + await self._request_debouncer.async_call() + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "usb/scan"}) +@websocket_api.async_response +async def websocket_usb_scan( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, +) -> None: + """Scan for new usb devices.""" + usb_discovery: USBDiscovery = hass.data[DOMAIN] + if not usb_discovery.observer_active: + await usb_discovery.async_request_scan_serial() + connection.send_result(msg["id"]) diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 274b9593f06..fd22882b8b3 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -7,6 +7,7 @@ "pyserial==3.5" ], "codeowners": ["@bdraco"], + "dependencies": ["websocket_api"], "quality_scale": "internal", "iot_class": "local_push" } \ No newline at end of file diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index f7b642c3390..a290ef9fa4c 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1,5 +1,4 @@ """Tests for the USB Discovery integration.""" -import datetime import os import sys from unittest.mock import MagicMock, patch, sentinel @@ -9,12 +8,9 @@ import pytest from homeassistant.components import usb from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from . import slae_sh_device -from tests.common import async_fire_time_changed - @pytest.mark.skipif( not sys.platform.startswith("linux"), @@ -113,8 +109,8 @@ async def test_removal_by_observer_before_started(hass): assert len(mock_config_flow.mock_calls) == 0 -async def test_discovered_by_scanner_after_started(hass): - """Test a device is discovered by the scanner after the started event.""" +async def test_discovered_by_websocket_scan(hass, hass_ws_client): + """Test a device is discovered from websocket scan.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] mock_comports = [ @@ -139,15 +135,18 @@ async def test_discovered_by_scanner_after_started(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + ws_client = await hass_ws_client(hass) + 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() assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "test1" -async def test_discovered_by_scanner_after_started_match_vid_only(hass): - """Test a device is discovered by the scanner after the started event only matching vid.""" +async def test_discovered_by_websocket_scan_match_vid_only(hass, hass_ws_client): + """Test a device is discovered from websocket scan only matching vid.""" new_usb = [{"domain": "test1", "vid": "3039"}] mock_comports = [ @@ -172,15 +171,18 @@ async def test_discovered_by_scanner_after_started_match_vid_only(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + ws_client = await hass_ws_client(hass) + 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() assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "test1" -async def test_discovered_by_scanner_after_started_match_vid_wrong_pid(hass): - """Test a device is discovered by the scanner after the started event only matching vid but wrong pid.""" +async def test_discovered_by_websocket_scan_match_vid_wrong_pid(hass, hass_ws_client): + """Test a device is discovered from websocket scan only matching vid but wrong pid.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] mock_comports = [ @@ -205,14 +207,17 @@ async def test_discovered_by_scanner_after_started_match_vid_wrong_pid(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + ws_client = await hass_ws_client(hass) + 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() assert len(mock_config_flow.mock_calls) == 0 -async def test_discovered_by_scanner_after_started_no_vid_pid(hass): - """Test a device is discovered by the scanner after the started event with no vid or pid.""" +async def test_discovered_by_websocket_no_vid_pid(hass, hass_ws_client): + """Test a device is discovered from websocket scan with no vid or pid.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] mock_comports = [ @@ -237,15 +242,20 @@ async def test_discovered_by_scanner_after_started_no_vid_pid(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + ws_client = await hass_ws_client(hass) + 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() assert len(mock_config_flow.mock_calls) == 0 @pytest.mark.parametrize("exception_type", [ImportError, OSError]) -async def test_non_matching_discovered_by_scanner_after_started(hass, exception_type): - """Test a device is discovered by the scanner after the started event that does not match.""" +async def test_non_matching_discovered_by_scanner_after_started( + hass, exception_type, hass_ws_client +): + """Test a websocket scan that does not match.""" new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] mock_comports = [ @@ -270,7 +280,10 @@ async def test_non_matching_discovered_by_scanner_after_started(hass, exception_ await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + ws_client = await hass_ws_client(hass) + 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() assert len(mock_config_flow.mock_calls) == 0 From d8eedaf9fd0dd998c16a63706de586267ba3bf69 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 21 Aug 2021 23:58:25 +0200 Subject: [PATCH 610/903] Upgrade PyTurboJPEG to 1.5.2 (#54992) --- homeassistant/components/camera/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index 6a27999c7fe..4c3ab704e1f 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -3,7 +3,7 @@ "name": "Camera", "documentation": "https://www.home-assistant.io/integrations/camera", "dependencies": ["http"], - "requirements": ["PyTurboJPEG==1.5.0"], + "requirements": ["PyTurboJPEG==1.5.2"], "after_dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/requirements_all.txt b/requirements_all.txt index e59469e072b..9772506f72f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -58,7 +58,7 @@ PySocks==1.7.1 PyTransportNSW==0.1.1 # homeassistant.components.camera -PyTurboJPEG==1.5.0 +PyTurboJPEG==1.5.2 # homeassistant.components.vicare PyViCare==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26efbca6e95..c885f13594b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -27,7 +27,7 @@ PyRMVtransport==0.3.2 PyTransportNSW==0.1.1 # homeassistant.components.camera -PyTurboJPEG==1.5.0 +PyTurboJPEG==1.5.2 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 From 06fcf51754f991d8648bfb1ef6f0128aefc117fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Aug 2021 17:55:10 -0500 Subject: [PATCH 611/903] Bump python-yeelight to 0.7.3 (#54982) --- homeassistant/components/yeelight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 31d884628e1..8d3d7be6f33 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.2", "async-upnp-client==0.20.0"], + "requirements": ["yeelight==0.7.3", "async-upnp-client==0.20.0"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/requirements_all.txt b/requirements_all.txt index 9772506f72f..ba568abd683 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2435,7 +2435,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.2 +yeelight==0.7.3 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c885f13594b..9cf48e548fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1361,7 +1361,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.2 +yeelight==0.7.3 # homeassistant.components.youless youless-api==0.12 From 45baf88862bf4f05cc6b10744df7804acd1bd19c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 22 Aug 2021 00:11:30 +0000 Subject: [PATCH 612/903] [ci skip] Translation update --- .../components/airtouch4/translations/he.json | 14 +++++++++++++ .../components/airtouch4/translations/nl.json | 4 +++- .../components/ambee/translations/ca.json | 2 +- .../binary_sensor/translations/he.json | 4 ++++ .../binary_sensor/translations/nl.json | 3 +++ .../components/blebox/translations/ca.json | 2 +- .../components/bsblan/translations/ca.json | 2 +- .../cloudflare/translations/nl.json | 2 +- .../components/elgato/translations/ca.json | 2 +- .../fjaraskupan/translations/ca.json | 13 ++++++++++++ .../fjaraskupan/translations/de.json | 13 ++++++++++++ .../fjaraskupan/translations/en.json | 2 +- .../fjaraskupan/translations/et.json | 13 ++++++++++++ .../fjaraskupan/translations/he.json | 8 +++++++ .../fjaraskupan/translations/nl.json | 13 ++++++++++++ .../components/ipp/translations/ca.json | 2 +- .../modern_forms/translations/ca.json | 2 +- .../p1_monitor/translations/ca.json | 17 +++++++++++++++ .../p1_monitor/translations/de.json | 17 +++++++++++++++ .../p1_monitor/translations/en.json | 3 ++- .../p1_monitor/translations/et.json | 17 +++++++++++++++ .../p1_monitor/translations/he.json | 16 ++++++++++++++ .../p1_monitor/translations/nl.json | 17 +++++++++++++++ .../rainforest_eagle/translations/ca.json | 1 + .../rainforest_eagle/translations/de.json | 21 +++++++++++++++++++ .../rainforest_eagle/translations/et.json | 1 + .../rainforest_eagle/translations/he.json | 21 +++++++++++++++++++ .../rainforest_eagle/translations/nl.json | 21 +++++++++++++++++++ .../rainforest_eagle/translations/ru.json | 20 ++++++++++++++++++ .../components/smappee/translations/ca.json | 2 +- .../components/tractive/translations/he.json | 3 ++- .../components/vacuum/translations/ar.json | 1 + .../components/vacuum/translations/bn.json | 7 +++++++ .../components/vacuum/translations/bs.json | 7 +++++++ .../components/vacuum/translations/cy.json | 7 +++++++ .../components/vacuum/translations/en_GB.json | 7 +++++++ .../components/vacuum/translations/eo.json | 7 +++++++ .../components/vacuum/translations/fa.json | 1 + .../components/vacuum/translations/gl.json | 7 +++++++ .../components/vacuum/translations/ja.json | 7 +++++++ .../components/vacuum/translations/ka.json | 7 +++++++ .../components/vacuum/translations/lt.json | 7 +++++++ .../components/vacuum/translations/pt-BR.json | 7 ++++++- .../vacuum/translations/sr-Latn.json | 7 +++++++ .../components/vacuum/translations/sr.json | 7 +++++++ .../components/vacuum/translations/ta.json | 7 +++++++ .../components/vacuum/translations/te.json | 3 ++- .../components/vacuum/translations/ur.json | 7 +++++++ .../components/wled/translations/ca.json | 2 +- .../yamaha_musiccast/translations/ca.json | 2 +- .../components/yeelight/translations/ca.json | 2 +- .../components/yeelight/translations/de.json | 2 +- .../components/yeelight/translations/et.json | 2 +- .../components/zha/translations/ca.json | 4 ++++ .../components/zha/translations/de.json | 4 ++++ .../components/zha/translations/et.json | 4 ++++ .../components/zwave_js/translations/ca.json | 12 +++++++++-- .../components/zwave_js/translations/de.json | 12 +++++++++-- .../components/zwave_js/translations/en.json | 3 ++- .../components/zwave_js/translations/et.json | 12 +++++++++-- 60 files changed, 416 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/airtouch4/translations/he.json create mode 100644 homeassistant/components/fjaraskupan/translations/ca.json create mode 100644 homeassistant/components/fjaraskupan/translations/de.json create mode 100644 homeassistant/components/fjaraskupan/translations/et.json create mode 100644 homeassistant/components/fjaraskupan/translations/he.json create mode 100644 homeassistant/components/fjaraskupan/translations/nl.json create mode 100644 homeassistant/components/p1_monitor/translations/ca.json create mode 100644 homeassistant/components/p1_monitor/translations/de.json create mode 100644 homeassistant/components/p1_monitor/translations/et.json create mode 100644 homeassistant/components/p1_monitor/translations/he.json create mode 100644 homeassistant/components/p1_monitor/translations/nl.json create mode 100644 homeassistant/components/rainforest_eagle/translations/de.json create mode 100644 homeassistant/components/rainforest_eagle/translations/he.json create mode 100644 homeassistant/components/rainforest_eagle/translations/nl.json create mode 100644 homeassistant/components/rainforest_eagle/translations/ru.json create mode 100644 homeassistant/components/vacuum/translations/bn.json create mode 100644 homeassistant/components/vacuum/translations/bs.json create mode 100644 homeassistant/components/vacuum/translations/cy.json create mode 100644 homeassistant/components/vacuum/translations/en_GB.json create mode 100644 homeassistant/components/vacuum/translations/eo.json create mode 100644 homeassistant/components/vacuum/translations/gl.json create mode 100644 homeassistant/components/vacuum/translations/ja.json create mode 100644 homeassistant/components/vacuum/translations/ka.json create mode 100644 homeassistant/components/vacuum/translations/lt.json create mode 100644 homeassistant/components/vacuum/translations/sr-Latn.json create mode 100644 homeassistant/components/vacuum/translations/sr.json create mode 100644 homeassistant/components/vacuum/translations/ta.json create mode 100644 homeassistant/components/vacuum/translations/ur.json diff --git a/homeassistant/components/airtouch4/translations/he.json b/homeassistant/components/airtouch4/translations/he.json new file mode 100644 index 00000000000..887a102c99a --- /dev/null +++ b/homeassistant/components/airtouch4/translations/he.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/nl.json b/homeassistant/components/airtouch4/translations/nl.json index d6137499b3e..b0697ea04bf 100644 --- a/homeassistant/components/airtouch4/translations/nl.json +++ b/homeassistant/components/airtouch4/translations/nl.json @@ -4,13 +4,15 @@ "already_configured": "Apparaat is al geconfigureerd" }, "error": { + "cannot_connect": "Kan geen verbinding maken", "no_units": "Kan geen AirTouch 4-groepen vinden." }, "step": { "user": { "data": { "host": "Host" - } + }, + "title": "Stel uw AirTouch 4 verbindingsgegevens in." } } } diff --git a/homeassistant/components/ambee/translations/ca.json b/homeassistant/components/ambee/translations/ca.json index ab3c9cb949e..ac48eea1cd6 100644 --- a/homeassistant/components/ambee/translations/ca.json +++ b/homeassistant/components/ambee/translations/ca.json @@ -21,7 +21,7 @@ "longitude": "Longitud", "name": "Nom" }, - "description": "Configura Ambee per a integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 d'Ambee amb Home Assistant." } } } diff --git a/homeassistant/components/binary_sensor/translations/he.json b/homeassistant/components/binary_sensor/translations/he.json index c345b1a94ce..4142759dbec 100644 --- a/homeassistant/components/binary_sensor/translations/he.json +++ b/homeassistant/components/binary_sensor/translations/he.json @@ -101,6 +101,10 @@ "off": "\u05e0\u05e7\u05d9", "on": "\u05d6\u05d5\u05d4\u05d4" }, + "update": { + "off": "\u05e2\u05d3\u05db\u05e0\u05d9", + "on": "\u05e2\u05d3\u05db\u05d5\u05df \u05d6\u05de\u05d9\u05df" + }, "vibration": { "off": "\u05e0\u05e7\u05d9", "on": "\u05d6\u05d5\u05d4\u05d4" diff --git a/homeassistant/components/binary_sensor/translations/nl.json b/homeassistant/components/binary_sensor/translations/nl.json index b44dd3449eb..f395335c627 100644 --- a/homeassistant/components/binary_sensor/translations/nl.json +++ b/homeassistant/components/binary_sensor/translations/nl.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} detecteert geen probleem", "is_no_smoke": "{entity_name} detecteert geen rook", "is_no_sound": "{entity_name} detecteert geen geluid", + "is_no_update": "{entity_name} is up-to-date", "is_no_vibration": "{entity_name} detecteert geen trillingen", "is_not_bat_low": "{entity_name} batterij is normaal", "is_not_cold": "{entity_name} is niet koud", @@ -62,6 +63,7 @@ "no_problem": "{entity_name} gestopt met het detecteren van het probleem", "no_smoke": "{entity_name} gestopt met het detecteren van rook", "no_sound": "{entity_name} gestopt met het detecteren van geluid", + "no_update": "{entity_name} werd ge\u00fcpdatet", "no_vibration": "{entity_name} gestopt met het detecteren van trillingen", "not_bat_low": "{entity_name} batterij normaal", "not_cold": "{entity_name} werd niet koud", @@ -181,6 +183,7 @@ "on": "Gedetecteerd" }, "update": { + "off": "Up-to-date", "on": "Update beschikbaar" }, "vibration": { diff --git a/homeassistant/components/blebox/translations/ca.json b/homeassistant/components/blebox/translations/ca.json index d2b25c7590a..96a3a9f37ad 100644 --- a/homeassistant/components/blebox/translations/ca.json +++ b/homeassistant/components/blebox/translations/ca.json @@ -16,7 +16,7 @@ "host": "Adre\u00e7a IP", "port": "Port" }, - "description": "Configura el teu dispositiu BleBox per a integrar-lo a Home Assistant.", + "description": "Configura la integraci\u00f3 d'un dispositiu BleBox amb Home Assistant.", "title": "Configuraci\u00f3 del dispositiu BleBox" } } diff --git a/homeassistant/components/bsblan/translations/ca.json b/homeassistant/components/bsblan/translations/ca.json index 8a9e3f3e533..7bcae685f35 100644 --- a/homeassistant/components/bsblan/translations/ca.json +++ b/homeassistant/components/bsblan/translations/ca.json @@ -16,7 +16,7 @@ "port": "Port", "username": "Nom d'usuari" }, - "description": "Configura un dispositiu BSB-Lan per a integrar-lo amb Home Assistant.", + "description": "Configura la integraci\u00f3 d'un dispositiu BSB-Lan amb Home Assistant.", "title": "Connexi\u00f3 amb dispositiu BSB-Lan" } } diff --git a/homeassistant/components/cloudflare/translations/nl.json b/homeassistant/components/cloudflare/translations/nl.json index 517743be9aa..5a1bf188a29 100644 --- a/homeassistant/components/cloudflare/translations/nl.json +++ b/homeassistant/components/cloudflare/translations/nl.json @@ -28,7 +28,7 @@ "data": { "api_token": "API-token" }, - "description": "Voor deze integratie is een API-token vereist dat is gemaakt met Zone:Zone:Lezen en Zone:DNS:Bewerk machtigingen voor alle zones in uw account.", + "description": "Voor deze integratie is een API-token vereist dat is gemaakt met Zone:Zone:Read en Zone:DNS:Edit machtigingen voor alle zones in uw account.", "title": "Verbinden met Cloudflare" }, "zone": { diff --git a/homeassistant/components/elgato/translations/ca.json b/homeassistant/components/elgato/translations/ca.json index 354e67e00b0..79acea81004 100644 --- a/homeassistant/components/elgato/translations/ca.json +++ b/homeassistant/components/elgato/translations/ca.json @@ -14,7 +14,7 @@ "host": "Amfitri\u00f3", "port": "Port" }, - "description": "Configura l'Elgato Light per integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 d'Elgato Light amb Home Assistant." }, "zeroconf_confirm": { "description": "Vols afegir a Home Assistant l'Elgato Light amb n\u00famero de s\u00e8rie `{serial_number}`?", diff --git a/homeassistant/components/fjaraskupan/translations/ca.json b/homeassistant/components/fjaraskupan/translations/ca.json new file mode 100644 index 00000000000..56172862caa --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/ca.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'han trobat dispositius a la xarxa", + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "step": { + "confirm": { + "description": "Vols configurar Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/de.json b/homeassistant/components/fjaraskupan/translations/de.json new file mode 100644 index 00000000000..d1150e177c7 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/de.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + }, + "step": { + "confirm": { + "description": "M\u00f6chtest du Fj\u00e4r\u00e5skupan einrichten?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/en.json b/homeassistant/components/fjaraskupan/translations/en.json index 206d0c9cbdb..c0616b6b9e6 100644 --- a/homeassistant/components/fjaraskupan/translations/en.json +++ b/homeassistant/components/fjaraskupan/translations/en.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Do you want to set up Fjäråskupan?" + "description": "Do you want to set up Fj\u00e4r\u00e5skupan?" } } } diff --git a/homeassistant/components/fjaraskupan/translations/et.json b/homeassistant/components/fjaraskupan/translations/et.json new file mode 100644 index 00000000000..57fe9c81c8f --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/et.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "V\u00f5rgust ei leitud \u00fchtegi seadet", + "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." + }, + "step": { + "confirm": { + "description": "Kas soovid seadistada Fj\u00e4r\u00e5skupani?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/he.json b/homeassistant/components/fjaraskupan/translations/he.json new file mode 100644 index 00000000000..380dbc5d7fc --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/he.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/nl.json b/homeassistant/components/fjaraskupan/translations/nl.json new file mode 100644 index 00000000000..498ef7af1be --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/nl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "step": { + "confirm": { + "description": "Wil je Fj\u00e4r\u00e5skupan opzetten?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ipp/translations/ca.json b/homeassistant/components/ipp/translations/ca.json index e3669e6d458..6d91535942a 100644 --- a/homeassistant/components/ipp/translations/ca.json +++ b/homeassistant/components/ipp/translations/ca.json @@ -23,7 +23,7 @@ "ssl": "Utilitza un certificat SSL", "verify_ssl": "Verifica el certificat SSL" }, - "description": "Configura la impressora amb el protocol d'impressi\u00f3 per Internet (IPP) per integrar-la amb Home Assistant.", + "description": "Configura la integraci\u00f3 amb Home Assistant d'una impressora amb protocol d'impressi\u00f3 per Internet (IPP).", "title": "Enlla\u00e7 d'impressora" }, "zeroconf_confirm": { diff --git a/homeassistant/components/modern_forms/translations/ca.json b/homeassistant/components/modern_forms/translations/ca.json index cea3bc7b685..e7a70e80b93 100644 --- a/homeassistant/components/modern_forms/translations/ca.json +++ b/homeassistant/components/modern_forms/translations/ca.json @@ -16,7 +16,7 @@ "data": { "host": "Amfitri\u00f3" }, - "description": "Configura el teu ventilador Modern Forms per integrar-lo a Home Assistant." + "description": "Configura la integraci\u00f3 d'un ventilador Modern Forms amb Home Assistant." }, "zeroconf_confirm": { "description": "Vols afegir el ventilador de Modern Forms anomenat `{name}` a Home Assistant?", diff --git a/homeassistant/components/p1_monitor/translations/ca.json b/homeassistant/components/p1_monitor/translations/ca.json new file mode 100644 index 00000000000..8d83cfd5026 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/ca.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom" + }, + "description": "Configura la integraci\u00f3 de P1 Monitor amb Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/de.json b/homeassistant/components/p1_monitor/translations/de.json new file mode 100644 index 00000000000..31a19a7a195 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "Richte den P1-Monitor zur Integration mit Home Assistant ein." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/en.json b/homeassistant/components/p1_monitor/translations/en.json index 4bd61c19bdc..34b64082b43 100644 --- a/homeassistant/components/p1_monitor/translations/en.json +++ b/homeassistant/components/p1_monitor/translations/en.json @@ -1,7 +1,8 @@ { "config": { "error": { - "cannot_connect": "Failed to connect" + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" }, "step": { "user": { diff --git a/homeassistant/components/p1_monitor/translations/et.json b/homeassistant/components/p1_monitor/translations/et.json new file mode 100644 index 00000000000..96ab4e46491 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/et.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nimi" + }, + "description": "Seadista P1 -monitor Home Assistantiga sidumiseks." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/he.json b/homeassistant/components/p1_monitor/translations/he.json new file mode 100644 index 00000000000..fbd52ea83ec --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/he.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/nl.json b/homeassistant/components/p1_monitor/translations/nl.json new file mode 100644 index 00000000000..fbb81d70c5d --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Naam" + }, + "description": "Stel P1 Monitor in om te integreren met Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/ca.json b/homeassistant/components/rainforest_eagle/translations/ca.json index 5670a555d5e..8d9fb32f127 100644 --- a/homeassistant/components/rainforest_eagle/translations/ca.json +++ b/homeassistant/components/rainforest_eagle/translations/ca.json @@ -12,6 +12,7 @@ "user": { "data": { "cloud_id": "ID del n\u00favol", + "host": "Amfitri\u00f3", "install_code": "Codi d'instal\u00b7laci\u00f3" } } diff --git a/homeassistant/components/rainforest_eagle/translations/de.json b/homeassistant/components/rainforest_eagle/translations/de.json new file mode 100644 index 00000000000..7fb839ec5c3 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "cloud_id": "Cloud-ID", + "host": "Host", + "install_code": "Installations-Code" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/et.json b/homeassistant/components/rainforest_eagle/translations/et.json index e6e0c2fbe5f..336f7e34e8d 100644 --- a/homeassistant/components/rainforest_eagle/translations/et.json +++ b/homeassistant/components/rainforest_eagle/translations/et.json @@ -12,6 +12,7 @@ "user": { "data": { "cloud_id": "Pilveteenuse ID", + "host": "Host", "install_code": "Paigalduskood" } } diff --git a/homeassistant/components/rainforest_eagle/translations/he.json b/homeassistant/components/rainforest_eagle/translations/he.json new file mode 100644 index 00000000000..7a313e6cb4e --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "cloud_id": "\u05de\u05d6\u05d4\u05d4 \u05e2\u05e0\u05df", + "host": "\u05de\u05d0\u05e8\u05d7", + "install_code": "\u05e7\u05d5\u05d3 \u05d4\u05ea\u05e7\u05e0\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/nl.json b/homeassistant/components/rainforest_eagle/translations/nl.json new file mode 100644 index 00000000000..e0f5b0ca502 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "cloud_id": "Cloud ID", + "host": "Host", + "install_code": "Installatiecode" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/ru.json b/homeassistant/components/rainforest_eagle/translations/ru.json new file mode 100644 index 00000000000..e731e89fa82 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "cloud_id": "Cloud ID", + "install_code": "\u041a\u043e\u0434 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smappee/translations/ca.json b/homeassistant/components/smappee/translations/ca.json index a630c81e0a7..71337e6a44d 100644 --- a/homeassistant/components/smappee/translations/ca.json +++ b/homeassistant/components/smappee/translations/ca.json @@ -15,7 +15,7 @@ "data": { "environment": "Entorn" }, - "description": "Configura el teu Smappee per a integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 de Smappee amb Home Assistant." }, "local": { "data": { diff --git a/homeassistant/components/tractive/translations/he.json b/homeassistant/components/tractive/translations/he.json index 1cccac175a0..10f72473084 100644 --- a/homeassistant/components/tractive/translations/he.json +++ b/homeassistant/components/tractive/translations/he.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", diff --git a/homeassistant/components/vacuum/translations/ar.json b/homeassistant/components/vacuum/translations/ar.json index 630b54d4676..f0148624667 100644 --- a/homeassistant/components/vacuum/translations/ar.json +++ b/homeassistant/components/vacuum/translations/ar.json @@ -2,6 +2,7 @@ "state": { "_": { "cleaning": "\u062a\u0646\u0638\u064a\u0641", + "docked": "\u0631\u0633\u062a", "error": "\u062e\u0637\u0623", "idle": "\u062e\u0627\u0645\u0644", "off": "\u0645\u0637\u0641\u0626", diff --git a/homeassistant/components/vacuum/translations/bn.json b/homeassistant/components/vacuum/translations/bn.json new file mode 100644 index 00000000000..de65f0ce3bd --- /dev/null +++ b/homeassistant/components/vacuum/translations/bn.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u09a1\u0995 \u0995\u09b0\u09be" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/bs.json b/homeassistant/components/vacuum/translations/bs.json new file mode 100644 index 00000000000..58d0dbd19c9 --- /dev/null +++ b/homeassistant/components/vacuum/translations/bs.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Docked" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/cy.json b/homeassistant/components/vacuum/translations/cy.json new file mode 100644 index 00000000000..df39549d654 --- /dev/null +++ b/homeassistant/components/vacuum/translations/cy.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Wedi'i docio" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/en_GB.json b/homeassistant/components/vacuum/translations/en_GB.json new file mode 100644 index 00000000000..58d0dbd19c9 --- /dev/null +++ b/homeassistant/components/vacuum/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Docked" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/eo.json b/homeassistant/components/vacuum/translations/eo.json new file mode 100644 index 00000000000..8d1a74e0b10 --- /dev/null +++ b/homeassistant/components/vacuum/translations/eo.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Aldokita" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/fa.json b/homeassistant/components/vacuum/translations/fa.json index 5e8fb2cae55..753e5974bc9 100644 --- a/homeassistant/components/vacuum/translations/fa.json +++ b/homeassistant/components/vacuum/translations/fa.json @@ -2,6 +2,7 @@ "state": { "_": { "cleaning": "\u062a\u0645\u06cc\u0632 \u06a9\u0631\u062f\u0646", + "docked": "\u0645\u062a\u0635\u0644 \u0634\u062f\u0647 \u0627\u0633\u062a", "off": "\u063a\u06cc\u0631 \u0641\u0639\u0627\u0644", "on": "\u0641\u063a\u0627\u0644", "paused": "\u0645\u06a9\u062b" diff --git a/homeassistant/components/vacuum/translations/gl.json b/homeassistant/components/vacuum/translations/gl.json new file mode 100644 index 00000000000..c0552316a48 --- /dev/null +++ b/homeassistant/components/vacuum/translations/gl.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Acoplado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/ja.json b/homeassistant/components/vacuum/translations/ja.json new file mode 100644 index 00000000000..ba421a8767c --- /dev/null +++ b/homeassistant/components/vacuum/translations/ja.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u30c9\u30c3\u30ad\u30f3\u30b0" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/ka.json b/homeassistant/components/vacuum/translations/ka.json new file mode 100644 index 00000000000..617e001ca45 --- /dev/null +++ b/homeassistant/components/vacuum/translations/ka.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u10d3\u10dd\u10d9\u10d8\u10e0\u10d4\u10d1\u10e3\u10da\u10d8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/lt.json b/homeassistant/components/vacuum/translations/lt.json new file mode 100644 index 00000000000..3cfb5717736 --- /dev/null +++ b/homeassistant/components/vacuum/translations/lt.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Prikabinta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/pt-BR.json b/homeassistant/components/vacuum/translations/pt-BR.json index 4e6f8c84748..b516880d5df 100644 --- a/homeassistant/components/vacuum/translations/pt-BR.json +++ b/homeassistant/components/vacuum/translations/pt-BR.json @@ -1,8 +1,13 @@ { + "device_automation": { + "condition_type": { + "is_docked": "{entity_name} est\u00e1 na base" + } + }, "state": { "_": { "cleaning": "Limpando", - "docked": "Baseado", + "docked": "Na base", "error": "Erro", "idle": "Em espera", "off": "Desligado", diff --git a/homeassistant/components/vacuum/translations/sr-Latn.json b/homeassistant/components/vacuum/translations/sr-Latn.json new file mode 100644 index 00000000000..8e42ab6fa76 --- /dev/null +++ b/homeassistant/components/vacuum/translations/sr-Latn.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u0414\u043e\u0446\u043a\u0435\u0434" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/sr.json b/homeassistant/components/vacuum/translations/sr.json new file mode 100644 index 00000000000..8e42ab6fa76 --- /dev/null +++ b/homeassistant/components/vacuum/translations/sr.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u0414\u043e\u0446\u043a\u0435\u0434" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/ta.json b/homeassistant/components/vacuum/translations/ta.json new file mode 100644 index 00000000000..d7db9a6b051 --- /dev/null +++ b/homeassistant/components/vacuum/translations/ta.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u0ba8\u0bb1\u0bc1\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/te.json b/homeassistant/components/vacuum/translations/te.json index 774d37755b4..335e14c1d3b 100644 --- a/homeassistant/components/vacuum/translations/te.json +++ b/homeassistant/components/vacuum/translations/te.json @@ -1,7 +1,8 @@ { "state": { "_": { - "cleaning": "\u0c36\u0c41\u0c2d\u0c4d\u0c30\u0c2a\u0c30\u0c41\u0c1a\u0c41\u0c24\u0c4b\u0c02\u0c26\u0c3f" + "cleaning": "\u0c36\u0c41\u0c2d\u0c4d\u0c30\u0c2a\u0c30\u0c41\u0c1a\u0c41\u0c24\u0c4b\u0c02\u0c26\u0c3f", + "docked": "\u0c21\u0c3e\u0c15\u0c4d \u0c1a\u0c47\u0c2f\u0c2c\u0c21\u0c3f\u0c02\u0c26\u0c3f" } } } \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/ur.json b/homeassistant/components/vacuum/translations/ur.json new file mode 100644 index 00000000000..410be58bdef --- /dev/null +++ b/homeassistant/components/vacuum/translations/ur.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u0688\u0627\u06a9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/ca.json b/homeassistant/components/wled/translations/ca.json index 2255a3cec0d..c4adacf21c2 100644 --- a/homeassistant/components/wled/translations/ca.json +++ b/homeassistant/components/wled/translations/ca.json @@ -13,7 +13,7 @@ "data": { "host": "Amfitri\u00f3" }, - "description": "Configura el teu WLED per integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 de WLED amb Home Assistant." }, "zeroconf_confirm": { "description": "Vols afegir el WLED `{name}` a Home Assistant?", diff --git a/homeassistant/components/yamaha_musiccast/translations/ca.json b/homeassistant/components/yamaha_musiccast/translations/ca.json index 32cd231c963..e1ff37eeb4f 100644 --- a/homeassistant/components/yamaha_musiccast/translations/ca.json +++ b/homeassistant/components/yamaha_musiccast/translations/ca.json @@ -16,7 +16,7 @@ "data": { "host": "Amfitri\u00f3" }, - "description": "Configura MusicCast per a integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 de MusicCast amb Home Assistant." } } } diff --git a/homeassistant/components/yeelight/translations/ca.json b/homeassistant/components/yeelight/translations/ca.json index 9bdbd01bfca..2732806de85 100644 --- a/homeassistant/components/yeelight/translations/ca.json +++ b/homeassistant/components/yeelight/translations/ca.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Ha fallat la connexi\u00f3" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Vols configurar {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/de.json b/homeassistant/components/yeelight/translations/de.json index e0bf573f95e..1547880e5e6 100644 --- a/homeassistant/components/yeelight/translations/de.json +++ b/homeassistant/components/yeelight/translations/de.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "M\u00f6chtest du {model} ({host}) einrichten?" diff --git a/homeassistant/components/yeelight/translations/et.json b/homeassistant/components/yeelight/translations/et.json index 450b85b03cd..859bc5f8856 100644 --- a/homeassistant/components/yeelight/translations/et.json +++ b/homeassistant/components/yeelight/translations/et.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u00dchendamine nurjus" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Kas seadistada {model} ({host})?" diff --git a/homeassistant/components/zha/translations/ca.json b/homeassistant/components/zha/translations/ca.json index 2467db76709..0e4abfe3a57 100644 --- a/homeassistant/components/zha/translations/ca.json +++ b/homeassistant/components/zha/translations/ca.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "Aquest no \u00e9s un dispositiu zha", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Vols configurar {name}?" + }, "pick_radio": { "data": { "radio_type": "Tipus de r\u00e0dio" diff --git a/homeassistant/components/zha/translations/de.json b/homeassistant/components/zha/translations/de.json index 3d66cc63071..2c7c6fed132 100644 --- a/homeassistant/components/zha/translations/de.json +++ b/homeassistant/components/zha/translations/de.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "Dieses Ger\u00e4t ist kein ZHA-Ger\u00e4t", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "M\u00f6chtest du {name} einrichten?" + }, "pick_radio": { "data": { "radio_type": "Funktyp" diff --git a/homeassistant/components/zha/translations/et.json b/homeassistant/components/zha/translations/et.json index 311a6378fcb..4924b3c954f 100644 --- a/homeassistant/components/zha/translations/et.json +++ b/homeassistant/components/zha/translations/et.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "See seade ei ole zha seade", "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Kas soovid seadistada teenust {name} ?" + }, "pick_radio": { "data": { "radio_type": "Seadme raadio t\u00fc\u00fcp" diff --git a/homeassistant/components/zwave_js/translations/ca.json b/homeassistant/components/zwave_js/translations/ca.json index a758d81e553..ac18c44b489 100644 --- a/homeassistant/components/zwave_js/translations/ca.json +++ b/homeassistant/components/zwave_js/translations/ca.json @@ -8,7 +8,9 @@ "addon_start_failed": "No s'ha pogut iniciar el complement Z-Wave JS.", "already_configured": "El dispositiu ja est\u00e0 configurat", "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", - "cannot_connect": "Ha fallat la connexi\u00f3" + "cannot_connect": "Ha fallat la connexi\u00f3", + "discovery_requires_supervisor": "El descobriment requereix el supervisor.", + "not_zwave_device": "El dispositiu descobert no \u00e9s un dispositiu Z-Wave." }, "error": { "addon_start_failed": "No s'ha pogut iniciar el complement Z-Wave JS. Comprova la configuraci\u00f3.", @@ -16,6 +18,7 @@ "invalid_ws_url": "URL del websocket inv\u00e0lid", "unknown": "Error inesperat" }, + "flow_title": "{name}", "progress": { "install_addon": "Espera mentre finalitza la instal\u00b7laci\u00f3 del complement Z-Wave JS. Pot tardar uns quants minuts.", "start_addon": "Espera mentre es completa la inicialitzaci\u00f3 del complement Z-Wave JS. Pot tardar uns segons." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "El complement Z-Wave JS s'est\u00e0 iniciant." + }, + "usb_confirm": { + "description": "Vols configurar {name} amb el complement Z-Wave JS?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Esdeveniment CC b\u00e0sic a {subtype}", "event.value_notification.central_scene": "Acci\u00f3 d'escena central a {subtype}", "event.value_notification.scene_activation": "Activaci\u00f3 d'escena a {subtype}", - "state.node_status": "L'estat del node ha canviat" + "state.node_status": "L'estat del node ha canviat", + "zwave_js.value_updated.config_parameter": "Canvi del valor del par\u00e0metre de configuraci\u00f3 {subtype}", + "zwave_js.value_updated.value": "Canvi del valor en un valor Z-Wave JS" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/de.json b/homeassistant/components/zwave_js/translations/de.json index 9b01865d3be..8d9634c3f46 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -8,7 +8,9 @@ "addon_start_failed": "Starten des Z-Wave JS Add-ons fehlgeschlagen.", "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", - "cannot_connect": "Verbindung fehlgeschlagen" + "cannot_connect": "Verbindung fehlgeschlagen", + "discovery_requires_supervisor": "Discovery erfordert den Supervisor.", + "not_zwave_device": "Das erkannte Ger\u00e4t ist kein Z-Wave-Ger\u00e4t." }, "error": { "addon_start_failed": "Fehler beim Starten des Z-Wave JS Add-Ons. \u00dcberpr\u00fcfe die Konfiguration.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Ung\u00fcltige Websocket-URL", "unknown": "Unerwarteter Fehler" }, + "flow_title": "{name}", "progress": { "install_addon": "Bitte warte, w\u00e4hrend die Installation des Z-Wave JS Add-ons abgeschlossen wird. Dies kann einige Minuten dauern.", "start_addon": "Bitte warte, w\u00e4hrend der Start des Z-Wave JS Add-ons abgeschlossen wird. Dies kann einige Sekunden dauern." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Z-Wave JS Add-on wird gestartet." + }, + "usb_confirm": { + "description": "M\u00f6chtest du {name} mit dem Z-Wave JS Add-on einrichten?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Grundlegendes CC-Ereignis auf {subtype}", "event.value_notification.central_scene": "Zentrale Szenenaktion auf {subtype}", "event.value_notification.scene_activation": "Szenenaktivierung auf {subtype}", - "state.node_status": "Knotenstatus ge\u00e4ndert" + "state.node_status": "Knotenstatus ge\u00e4ndert", + "zwave_js.value_updated.config_parameter": "Wert\u00e4nderung des Konfigurationsparameters {subtype}", + "zwave_js.value_updated.value": "Wert\u00e4nderung bei einem Z-Wave JS Wert" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 6f5d08933db..8ba33702d1d 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -123,5 +123,6 @@ "title": "The Z-Wave JS add-on is starting." } } - } + }, + "title": "Z-Wave JS" } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/et.json b/homeassistant/components/zwave_js/translations/et.json index 522c145d6d5..efed557fe73 100644 --- a/homeassistant/components/zwave_js/translations/et.json +++ b/homeassistant/components/zwave_js/translations/et.json @@ -8,7 +8,9 @@ "addon_start_failed": "Z-Wave JS-i lisandmooduli k\u00e4ivitamine nurjus.", "already_configured": "Seade on juba h\u00e4\u00e4lestatud", "already_in_progress": "Seadistamine on juba k\u00e4imas", - "cannot_connect": "\u00dchendamine nurjus" + "cannot_connect": "\u00dchendamine nurjus", + "discovery_requires_supervisor": "Avastamine n\u00f5uab supervisorit.", + "not_zwave_device": "Avastatud seade ei ole Z-Wave seade." }, "error": { "addon_start_failed": "Z-Wave JS lisandmooduli k\u00e4ivitamine nurjus. Kontrolli seadistusi.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Vale sihtkoha aadress", "unknown": "Ootamatu t\u00f5rge" }, + "flow_title": "{name}", "progress": { "install_addon": "Palun oota kuni Z-Wave JS lisandmoodul on paigaldatud. See v\u00f5ib v\u00f5tta mitu minutit.", "start_addon": "Palun oota kuni Z-Wave JS lisandmooduli ak\u00e4ivitumine l\u00f5ppeb. See v\u00f5ib v\u00f5tta m\u00f5ned sekundid." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Z-Wave JS lisandmoodul k\u00e4ivitub." + }, + "usb_confirm": { + "description": "Kas seadistada Z-Wave JS lisandmoodul {name}?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "CC p\u00f5his\u00fcndmus {subtype}", "event.value_notification.central_scene": "Keskse stseeni tegevus {subtype}", "event.value_notification.scene_activation": "Stseeni aktiveerimine saidil {subtype}", - "state.node_status": "S\u00f5lme olek muutus" + "state.node_status": "S\u00f5lme olek muutus", + "zwave_js.value_updated.config_parameter": "Seadeparameetri {subtype} v\u00e4\u00e4rtuse muutmine", + "zwave_js.value_updated.value": "Z-Wave JS v\u00e4\u00e4rtuse muutus" } }, "options": { From b942454312587fc251a5f294e8a22f566bfbd860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20R=C3=B8rvik?= <60797691+jorgror@users.noreply.github.com> Date: Sun, 22 Aug 2021 03:01:41 +0200 Subject: [PATCH 613/903] Fix manual setup when roomba is on different subnet (#54639) Co-authored-by: J. Nick Koston Co-authored-by: Franck Nijhof --- .../components/roomba/config_flow.py | 13 +- homeassistant/components/roomba/strings.json | 5 +- .../components/roomba/translations/en.json | 3 +- tests/components/roomba/test_config_flow.py | 118 +++++++++--------- 4 files changed, 69 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 4fdcbceab07..f17eb0a07c0 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -175,17 +175,20 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="manual", description_placeholders={AUTH_HELP_URL_KEY: AUTH_HELP_URL_VALUE}, data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=self.host): str, - vol.Required(CONF_BLID, default=self.blid): str, - } + {vol.Required(CONF_HOST, default=self.host): str} ), ) self._async_abort_entries_match({CONF_HOST: user_input["host"]}) self.host = user_input[CONF_HOST] - self.blid = user_input[CONF_BLID].upper() + + devices = await _async_discover_roombas(self.hass, self.host) + if not devices: + return self.async_abort(reason="cannot_connect") + self.blid = devices[0].blid + self.name = devices[0].robot_name + await self.async_set_unique_id(self.blid, raise_on_progress=False) self._abort_if_unique_id_configured() return await self.async_step_link() diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index 1a37745302a..b52d6443213 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -11,10 +11,9 @@ }, "manual": { "title": "Manually connect to the device", - "description": "No Roomba or Braava have been discovered on your network. The BLID is the portion of the device hostname after `iRobot-` or `Roomba-`. Please follow the steps outlined in the documentation at: {auth_help_url}", + "description": "No Roomba or Braava have been discovered on your network.", "data": { - "host": "[%key:common::config_flow::data::host%]", - "blid": "BLID" + "host": "[%key:common::config_flow::data::host%]" } }, "link": { diff --git a/homeassistant/components/roomba/translations/en.json b/homeassistant/components/roomba/translations/en.json index df95782f52f..8dd2f92183b 100644 --- a/homeassistant/components/roomba/translations/en.json +++ b/homeassistant/components/roomba/translations/en.json @@ -31,10 +31,9 @@ }, "manual": { "data": { - "blid": "BLID", "host": "Host" }, - "description": "No Roomba or Braava have been discovered on your network. The BLID is the portion of the device hostname after `iRobot-` or `Roomba-`. Please follow the steps outlined in the documentation at: {auth_help_url}", + "description": "No Roomba or Braava have been discovered on your network.", "title": "Manually connect to the device" }, "user": { diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index bebf1724761..cdb1c681f5c 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -213,7 +213,7 @@ async def test_form_user_no_devices_found_discovery_aborts_already_configured(ha result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, + {CONF_HOST: MOCK_IP}, ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -250,10 +250,14 @@ async def test_form_user_discovery_manual_and_auto_password_fetch(hass): assert result2["errors"] is None assert result2["step_id"] == "manual" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: MOCK_IP}, + ) + await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM assert result3["errors"] is None @@ -275,7 +279,7 @@ async def test_form_user_discovery_manual_and_auto_password_fetch(hass): await hass.async_block_till_done() assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result4["title"] == "myroomba" + assert result4["title"] == "robot_name" assert result4["result"].unique_id == "BLID" assert result4["data"] == { CONF_BLID: "BLID", @@ -309,7 +313,7 @@ async def test_form_user_discover_fails_aborts_already_configured(hass): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, + {CONF_HOST: MOCK_IP}, ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -322,12 +326,6 @@ async def test_form_user_discovery_manual_and_auto_password_fetch_but_cannot_con """Test discovery skipped and we can auto fetch the password then we fail to connect.""" await setup.async_setup_component(hass, "persistent_notification", {}) - mocked_roomba = _create_mocked_roomba( - connect=RoombaConnectionError, - roomba_connected=True, - master_state={"state": {"reported": {"name": "myroomba"}}}, - ) - with patch( "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery ): @@ -349,33 +347,18 @@ async def test_form_user_discovery_manual_and_auto_password_fetch_but_cannot_con assert result2["errors"] is None assert result2["step_id"] == "manual" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) - await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result3["errors"] is None - with patch( - "homeassistant.components.roomba.config_flow.RoombaFactory.create_roomba", - return_value=mocked_roomba, - ), patch( - "homeassistant.components.roomba.config_flow.RoombaPassword", - _mocked_getpassword, - ), patch( - "homeassistant.components.roomba.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - {}, + "homeassistant.components.roomba.config_flow.RoombaDiscovery", + _mocked_no_devices_found_discovery, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: MOCK_IP}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result4["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result4["reason"] == "cannot_connect" - assert len(mock_setup_entry.mock_calls) == 0 + assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result3["reason"] == "cannot_connect" async def test_form_user_discovery_no_devices_found_and_auto_password_fetch(hass): @@ -400,10 +383,13 @@ async def test_form_user_discovery_no_devices_found_and_auto_password_fetch(hass assert result["errors"] is None assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] is None @@ -425,7 +411,7 @@ async def test_form_user_discovery_no_devices_found_and_auto_password_fetch(hass await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["title"] == "myroomba" + assert result3["title"] == "robot_name" assert result3["result"].unique_id == "BLID" assert result3["data"] == { CONF_BLID: "BLID", @@ -459,10 +445,13 @@ async def test_form_user_discovery_no_devices_found_and_password_fetch_fails(has assert result["errors"] is None assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] is None @@ -528,10 +517,13 @@ async def test_form_user_discovery_not_devices_found_and_password_fetch_fails_an assert result["errors"] is None assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] is None @@ -717,10 +709,13 @@ async def test_dhcp_discovery_falls_back_to_manual(hass, discovery_data): assert result2["errors"] is None assert result2["step_id"] == "manual" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: MOCK_IP}, + ) await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM assert result3["errors"] is None @@ -742,7 +737,7 @@ async def test_dhcp_discovery_falls_back_to_manual(hass, discovery_data): await hass.async_block_till_done() assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result4["title"] == "myroomba" + assert result4["title"] == "robot_name" assert result4["result"].unique_id == "BLID" assert result4["data"] == { CONF_BLID: "BLID", @@ -779,10 +774,13 @@ async def test_dhcp_discovery_no_devices_falls_back_to_manual(hass, discovery_da assert result["errors"] is None assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] is None @@ -804,7 +802,7 @@ async def test_dhcp_discovery_no_devices_falls_back_to_manual(hass, discovery_da await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["title"] == "myroomba" + assert result3["title"] == "robot_name" assert result3["result"].unique_id == "BLID" assert result3["data"] == { CONF_BLID: "BLID", From 243c52e210d603f52fcff12652a642ab7ba0ab45 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 22 Aug 2021 04:14:48 +0200 Subject: [PATCH 614/903] Add missing BYN currency (#55001) --- homeassistant/helpers/config_validation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f8d69a6e49a..de9fa2a4169 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1307,6 +1307,7 @@ currency = vol.In( "BSD", "BTN", "BWP", + "BYN", "BYR", "BZD", "CAD", From afc95becd09a5a2c12255cc2f7d6a67ce688a5ce Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 22 Aug 2021 04:17:36 +0200 Subject: [PATCH 615/903] Fix incorrect power device class on energy sensors in Smappee (#54994) --- homeassistant/components/smappee/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 6474c74c185..ec93501a508 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -89,7 +89,7 @@ SOLAR_SENSORS = { None, ENERGY_WATT_HOUR, "solar_today", - DEVICE_CLASS_POWER, + DEVICE_CLASS_ENERGY, False, # cloud only ], "solar_current_hour": [ @@ -97,7 +97,7 @@ SOLAR_SENSORS = { None, ENERGY_WATT_HOUR, "solar_current_hour", - DEVICE_CLASS_POWER, + DEVICE_CLASS_ENERGY, False, # cloud only ], } From 9be5793ed79b7d4e3684e8ce287d10de86c4b43d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 22 Aug 2021 11:00:35 +0200 Subject: [PATCH 616/903] Upgrade apprise to 0.9.4 (#55002) --- homeassistant/components/apprise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 3e87d38ff69..bf24f2fdac5 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -2,7 +2,7 @@ "domain": "apprise", "name": "Apprise", "documentation": "https://www.home-assistant.io/integrations/apprise", - "requirements": ["apprise==0.9.3"], + "requirements": ["apprise==0.9.4"], "codeowners": ["@caronc"], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index ba568abd683..90e7afe0495 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -294,7 +294,7 @@ apcaccess==0.0.13 apns2==0.3.0 # homeassistant.components.apprise -apprise==0.9.3 +apprise==0.9.4 # homeassistant.components.aprs aprslib==0.6.46 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9cf48e548fe..026ff749c54 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -197,7 +197,7 @@ androidtv[async]==0.0.60 apns2==0.3.0 # homeassistant.components.apprise -apprise==0.9.3 +apprise==0.9.4 # homeassistant.components.aprs aprslib==0.6.46 From 0efcd7888d38c82a59b15eeb14d56d9325f284ee Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 22 Aug 2021 14:23:09 +0200 Subject: [PATCH 617/903] Upgrade watchdog to 2.1.4 (#54993) --- homeassistant/components/folder_watcher/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 709e95f476b..9a7967d22cb 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -2,7 +2,7 @@ "domain": "folder_watcher", "name": "Folder Watcher", "documentation": "https://www.home-assistant.io/integrations/folder_watcher", - "requirements": ["watchdog==2.1.3"], + "requirements": ["watchdog==2.1.4"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 90e7afe0495..a2e874786fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2382,7 +2382,7 @@ wallbox==0.4.4 waqiasync==1.0.0 # homeassistant.components.folder_watcher -watchdog==2.1.3 +watchdog==2.1.4 # homeassistant.components.waterfurnace waterfurnace==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 026ff749c54..3ad5767b306 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1326,7 +1326,7 @@ wakeonlan==2.0.1 wallbox==0.4.4 # homeassistant.components.folder_watcher -watchdog==2.1.3 +watchdog==2.1.4 # homeassistant.components.wiffi wiffi==1.0.1 From b6a1153d4245e0b78be40ea01e2254bc71fe4f4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Aug 2021 11:30:35 -0500 Subject: [PATCH 618/903] Skip trying the pyudev observer when using standalone docker for usb (#54987) --- homeassistant/components/usb/__init__.py | 5 ++ tests/components/usb/test_init.py | 78 +++++++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 36a63a36642..3aaccc15a64 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -15,6 +15,7 @@ from homeassistant.components import websocket_api from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import system_info from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_usb @@ -102,6 +103,10 @@ class USBDiscovery: """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") and not info.get("hassio"): + return + from pyudev import ( # pylint: disable=import-outside-toplevel Context, Monitor, diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index a290ef9fa4c..9c480f11fc6 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -12,11 +12,37 @@ from homeassistant.setup import async_setup_component from . import slae_sh_device +@pytest.fixture(name="operating_system") +def mock_operating_system(): + """Mock running Home Assistant Operating system.""" + with patch( + "homeassistant.components.usb.system_info.async_get_system_info", + return_value={ + "hassio": True, + "docker": True, + }, + ): + 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.mark.skipif( not sys.platform.startswith("linux"), reason="Only works on linux", ) -async def test_discovered_by_observer_before_started(hass): +async def test_discovered_by_observer_before_started(hass, operating_system): """Test a device is discovered by the observer before started.""" async def _mock_monitor_observer_callback(callback): @@ -65,7 +91,7 @@ async def test_discovered_by_observer_before_started(hass): not sys.platform.startswith("linux"), reason="Only works on linux", ) -async def test_removal_by_observer_before_started(hass): +async def test_removal_by_observer_before_started(hass, operating_system): """Test a device is removed by the observer before started.""" async def _mock_monitor_observer_callback(callback): @@ -289,6 +315,54 @@ 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_not_discovered_by_observer_before_started_on_docker(hass, docker): + """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() + + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + mock_comports = [ + 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( + "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 + ): + 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 + + def test_get_serial_by_id_no_dir(): """Test serial by id conversion if there's no /dev/serial/by-id.""" p1 = patch("os.path.isdir", MagicMock(return_value=False)) From 6d049c724cf775c97b35121f3e7bb73555f85e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20M=C3=BCndler?= Date: Sun, 22 Aug 2021 18:57:18 +0200 Subject: [PATCH 619/903] Add support for logger info in fronius integration (#54795) Co-authored-by: Franck Nijhof --- .../components/fronius/manifest.json | 2 +- homeassistant/components/fronius/sensor.py | 31 ++++++++++++++++--- requirements_all.txt | 2 +- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index a8e9c44805d..4c21e83e191 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -2,7 +2,7 @@ "domain": "fronius", "name": "Fronius", "documentation": "https://www.home-assistant.io/integrations/fronius", - "requirements": ["pyfronius==0.5.5"], + "requirements": ["pyfronius==0.6.0"], "codeowners": ["@nielstron"], "iot_class": "local_polling" } diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 0fb046e8aa1..076eee9acc8 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import copy from datetime import timedelta import logging +from typing import Any from pyfronius import Fronius import voluptuous as vol @@ -32,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import callback 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.event import async_track_time_interval _LOGGER = logging.getLogger(__name__) @@ -42,6 +44,7 @@ TYPE_INVERTER = "inverter" TYPE_STORAGE = "storage" TYPE_METER = "meter" TYPE_POWER_FLOW = "power_flow" +TYPE_LOGGER_INFO = "logger_info" SCOPE_DEVICE = "device" SCOPE_SYSTEM = "system" @@ -50,7 +53,13 @@ DEFAULT_DEVICE = 0 DEFAULT_INVERTER = 1 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) -SENSOR_TYPES = [TYPE_INVERTER, TYPE_STORAGE, TYPE_METER, TYPE_POWER_FLOW] +SENSOR_TYPES = [ + TYPE_INVERTER, + TYPE_STORAGE, + TYPE_METER, + TYPE_POWER_FLOW, + TYPE_LOGGER_INFO, +] SCOPE_TYPES = [SCOPE_DEVICE, SCOPE_SYSTEM] PREFIX_DEVICE_CLASS_MAPPING = [ @@ -138,6 +147,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= adapter_cls = FroniusMeterDevice elif sensor_type == TYPE_POWER_FLOW: adapter_cls = FroniusPowerFlow + elif sensor_type == TYPE_LOGGER_INFO: + adapter_cls = FroniusLoggerInfo else: adapter_cls = FroniusStorage @@ -161,16 +172,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class FroniusAdapter: """The Fronius sensor fetching component.""" - def __init__(self, bridge, name, device, add_entities): + def __init__( + self, bridge: Fronius, name: str, device: int, add_entities: AddEntitiesCallback + ) -> None: """Initialize the sensor.""" self.bridge = bridge self._name = name self._device = device - self._fetched = {} + self._fetched: dict[str, Any] = {} self._available = True - self.sensors = set() - self._registered_sensors = set() + self.sensors: set[str] = set() + self._registered_sensors: set[SensorEntity] = set() self._add_entities = add_entities @property @@ -289,6 +302,14 @@ class FroniusPowerFlow(FroniusAdapter): return await self.bridge.current_power_flow() +class FroniusLoggerInfo(FroniusAdapter): + """Adapter for the fronius power flow.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_logger_info() + + class FroniusTemplateSensor(SensorEntity): """Sensor for the single values (e.g. pv power, ac power).""" diff --git a/requirements_all.txt b/requirements_all.txt index a2e874786fc..bed06c29016 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1472,7 +1472,7 @@ pyfreedompro==1.1.0 pyfritzhome==0.6.2 # homeassistant.components.fronius -pyfronius==0.5.5 +pyfronius==0.6.0 # homeassistant.components.ifttt pyfttt==0.3 From da20552cd8937ce1d25176ff3a323460a1f74f67 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 22 Aug 2021 19:59:38 +0200 Subject: [PATCH 620/903] Remove base_test from modbus test harness (#55018) * Remove base_test. * Review comments. --- tests/components/modbus/conftest.py | 189 +++++------------- tests/components/modbus/test_binary_sensor.py | 46 +++-- tests/components/modbus/test_climate.py | 45 ++--- tests/components/modbus/test_cover.py | 76 ++++--- tests/components/modbus/test_fan.py | 60 +++--- tests/components/modbus/test_light.py | 60 +++--- tests/components/modbus/test_sensor.py | 69 ++++--- tests/components/modbus/test_switch.py | 60 +++--- 8 files changed, 263 insertions(+), 342 deletions(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 35688d2f608..33ecf909a6f 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -1,4 +1,5 @@ """The tests for the Modbus sensor component.""" +import copy from dataclasses import dataclass from datetime import timedelta import logging @@ -7,19 +8,8 @@ from unittest import mock from pymodbus.exceptions import ModbusException import pytest -from homeassistant.components.modbus.const import ( - DEFAULT_HUB, - MODBUS_DOMAIN as DOMAIN, - TCP, -) -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PLATFORM, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_TYPE, -) +from homeassistant.components.modbus.const import MODBUS_DOMAIN as DOMAIN, TCP +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -59,14 +49,33 @@ def mock_pymodbus(): yield mock_pb -@pytest.fixture( - params=[ - {"testLoad": True}, - ], -) -async def mock_modbus(hass, caplog, request, do_config): - """Load integration modbus using mocked pymodbus.""" +@pytest.fixture +def check_config_loaded(): + """Set default for check_config_loaded.""" + return True + +@pytest.fixture +def register_words(): + """Set default for register_words.""" + return [0x00, 0x00] + + +@pytest.fixture +def config_addon(): + """Add entra configuration items.""" + return None + + +@pytest.fixture +async def mock_modbus( + hass, caplog, register_words, check_config_loaded, config_addon, do_config +): + """Load integration modbus using mocked pymodbus.""" + conf = copy.deepcopy(do_config) + if config_addon: + for key in conf.keys(): + conf[key][0].update(config_addon) caplog.set_level(logging.WARNING) config = { DOMAIN: [ @@ -75,7 +84,7 @@ async def mock_modbus(hass, caplog, request, do_config): CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, - **do_config, + **conf, } ] } @@ -83,19 +92,35 @@ async def mock_modbus(hass, caplog, request, do_config): with mock.patch( "homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb ): - mock_pb.read_coils.return_value = ReadResult([0x00]) - read_result = ReadResult([0x00, 0x00]) - mock_pb.read_discrete_inputs.return_value = read_result - mock_pb.read_input_registers.return_value = read_result - mock_pb.read_holding_registers.return_value = read_result - if request.param["testLoad"]: - assert await async_setup_component(hass, DOMAIN, config) is True + if register_words is None: + exc = ModbusException("fail read_coils") + mock_pb.read_coils.side_effect = exc + mock_pb.read_discrete_inputs.side_effect = exc + mock_pb.read_input_registers.side_effect = exc + mock_pb.read_holding_registers.side_effect = exc else: - await async_setup_component(hass, DOMAIN, config) + read_result = ReadResult(register_words) + mock_pb.read_coils.return_value = read_result + mock_pb.read_discrete_inputs.return_value = read_result + mock_pb.read_input_registers.return_value = read_result + mock_pb.read_holding_registers.return_value = read_result + now = dt_util.utcnow() + with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + result = await async_setup_component(hass, DOMAIN, config) + assert result or not check_config_loaded await hass.async_block_till_done() yield mock_pb +@pytest.fixture +async def mock_do_cycle(hass): + """Trigger update call with time_changed event.""" + now = dt_util.utcnow() + timedelta(seconds=90) + with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + @pytest.fixture async def mock_test_state(hass, request): """Mock restore cache.""" @@ -108,109 +133,3 @@ async def mock_ha(hass): """Load homeassistant to allow service calls.""" assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - - -async def base_test( - hass, - config_device, - device_name, - entity_domain, - array_name_discovery, - array_name_old_config, - register_words, - expected, - method_discovery=False, - config_modbus=None, - scan_interval=None, - expect_init_to_fail=False, - expect_setup_to_fail=False, -): - """Run test on device for given config.""" - - if config_modbus is None: - config_modbus = { - DOMAIN: { - CONF_NAME: DEFAULT_HUB, - CONF_TYPE: TCP, - CONF_HOST: TEST_MODBUS_HOST, - CONF_PORT: TEST_PORT_TCP, - }, - } - - mock_sync = mock.MagicMock() - with mock.patch( - "homeassistant.components.modbus.modbus.ModbusTcpClient", - autospec=True, - return_value=mock_sync, - ): - - # Setup inputs for the sensor - if register_words is None: - mock_sync.read_coils.side_effect = ModbusException("fail read_coils") - mock_sync.read_discrete_inputs.side_effect = ModbusException( - "fail read_coils" - ) - mock_sync.read_input_registers.side_effect = ModbusException( - "fail read_coils" - ) - mock_sync.read_holding_registers.side_effect = ModbusException( - "fail read_coils" - ) - else: - read_result = ReadResult(register_words) - mock_sync.read_coils.return_value = read_result - mock_sync.read_discrete_inputs.return_value = read_result - mock_sync.read_input_registers.return_value = read_result - mock_sync.read_holding_registers.return_value = read_result - - # mock timer and add old/new config - now = dt_util.utcnow() - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - if method_discovery and config_device is not None: - # setup modbus which in turn does setup for the devices - config_modbus[DOMAIN].update( - {array_name_discovery: [{**config_device}]} - ) - config_device = None - assert ( - await async_setup_component(hass, DOMAIN, config_modbus) - is not expect_setup_to_fail - ) - await hass.async_block_till_done() - - # setup platform old style - if config_device is not None: - config_device = { - entity_domain: { - CONF_PLATFORM: DOMAIN, - array_name_old_config: [ - { - **config_device, - } - ], - } - } - if scan_interval is not None: - config_device[entity_domain][CONF_SCAN_INTERVAL] = scan_interval - assert await async_setup_component(hass, entity_domain, config_device) - await hass.async_block_till_done() - - assert (DOMAIN in hass.config.components) is not expect_setup_to_fail - if config_device is not None: - entity_id = f"{entity_domain}.{device_name}" - device = hass.states.get(entity_id) - - if expect_init_to_fail: - assert device is None - elif device is None: - pytest.fail("CONFIG failed, see output") - - # Trigger update call with time_changed event - now = now + timedelta(seconds=scan_interval + 60) - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() - - # Check state - entity_id = f"{entity_domain}.{device_name}" - return hass.states.get(entity_id).state diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index a710a8b0598..a4442eb3609 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -1,4 +1,4 @@ -"""The tests for the Modbus sensor component.""" +"""Thetests for the Modbus sensor component.""" import pytest from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import TEST_ENTITY_NAME, ReadResult, base_test +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" @@ -56,9 +56,31 @@ async def test_config_binary_sensor(hass, mock_modbus): assert SENSOR_DOMAIN in hass.config.components -@pytest.mark.parametrize("do_type", [CALL_TYPE_COIL, CALL_TYPE_DISCRETE]) @pytest.mark.parametrize( - "regs,expected", + "do_config", + [ + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_INPUT_TYPE: CALL_TYPE_COIL, + }, + ], + }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,expected", [ ( [0xFF], @@ -86,21 +108,9 @@ async def test_config_binary_sensor(hass, mock_modbus): ), ], ) -async def test_all_binary_sensor(hass, do_type, regs, expected): +async def test_all_binary_sensor(hass, expected, mock_modbus, mock_do_cycle): """Run test for given config.""" - state = await base_test( - hass, - {CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: do_type}, - TEST_ENTITY_NAME, - SENSOR_DOMAIN, - CONF_BINARY_SENSORS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index d7096d91b44..f3d50317782 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import TEST_ENTITY_NAME, ReadResult, base_test +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}" @@ -62,36 +62,33 @@ async def test_config_climate(hass, mock_modbus): @pytest.mark.parametrize( - "regs,expected", + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_SLAVE: 1, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_COUNT: 2, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,expected", [ ( - [0x00], + [0x00, 0x00], "auto", ), ], ) -async def test_temperature_climate(hass, regs, expected): +async def test_temperature_climate(hass, expected, mock_modbus, mock_do_cycle): """Run test for given config.""" - return - state = await base_test( - hass, - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_SLAVE: 1, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_COUNT: 2, - }, - TEST_ENTITY_NAME, - CLIMATE_DOMAIN, - CONF_CLIMATES, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index 2f502587949..0b639bc0858 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import TEST_ENTITY_NAME, ReadResult, base_test +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{COVER_DOMAIN}.{TEST_ENTITY_NAME}" @@ -67,7 +67,22 @@ async def test_config_cover(hass, mock_modbus): @pytest.mark.parametrize( - "regs,expected", + "do_config", + [ + { + CONF_COVERS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_INPUT_TYPE: CALL_TYPE_COIL, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,expected", [ ( [0x00], @@ -91,30 +106,27 @@ async def test_config_cover(hass, mock_modbus): ), ], ) -async def test_coil_cover(hass, regs, expected): +async def test_coil_cover(hass, expected, mock_modbus, mock_do_cycle): """Run test for given config.""" - state = await base_test( - hass, - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - }, - TEST_ENTITY_NAME, - COVER_DOMAIN, - CONF_COVERS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( - "regs,expected", + "do_config", + [ + { + CONF_COVERS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,expected", [ ( [0x00], @@ -138,25 +150,9 @@ async def test_coil_cover(hass, regs, expected): ), ], ) -async def test_register_cover(hass, regs, expected): +async def test_register_cover(hass, expected, mock_modbus, mock_do_cycle): """Run test for given config.""" - state = await base_test( - hass, - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - }, - TEST_ENTITY_NAME, - COVER_DOMAIN, - CONF_COVERS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index a54b2212fd5..77cb650a184 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -35,13 +35,7 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.setup import async_setup_component -from .conftest import ( - TEST_ENTITY_NAME, - TEST_MODBUS_HOST, - TEST_PORT_TCP, - ReadResult, - base_test, -) +from .conftest import TEST_ENTITY_NAME, TEST_MODBUS_HOST, TEST_PORT_TCP, ReadResult ENTITY_ID = f"{FAN_DOMAIN}.{TEST_ENTITY_NAME}" @@ -137,9 +131,33 @@ async def test_config_fan(hass, mock_modbus): assert FAN_DOMAIN in hass.config.components -@pytest.mark.parametrize("call_type", [CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING]) @pytest.mark.parametrize( - "regs,verify,expected", + "do_config", + [ + { + CONF_FANS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + }, + ], + }, + { + CONF_FANS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,config_addon,expected", [ ( [0x00], @@ -163,32 +181,14 @@ async def test_config_fan(hass, mock_modbus): ), ( None, - {}, + None, STATE_OFF, ), ], ) -async def test_all_fan(hass, call_type, regs, verify, expected): +async def test_all_fan(hass, mock_modbus, mock_do_cycle, expected): """Run test for given config.""" - state = await base_test( - hass, - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: call_type, - **verify, - }, - TEST_ENTITY_NAME, - FAN_DOMAIN, - CONF_FANS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index bb4bb7b08f9..4d277d0267f 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -35,13 +35,7 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.setup import async_setup_component -from .conftest import ( - TEST_ENTITY_NAME, - TEST_MODBUS_HOST, - TEST_PORT_TCP, - ReadResult, - base_test, -) +from .conftest import TEST_ENTITY_NAME, TEST_MODBUS_HOST, TEST_PORT_TCP, ReadResult ENTITY_ID = f"{LIGHT_DOMAIN}.{TEST_ENTITY_NAME}" @@ -137,9 +131,33 @@ async def test_config_light(hass, mock_modbus): assert LIGHT_DOMAIN in hass.config.components -@pytest.mark.parametrize("call_type", [CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING]) @pytest.mark.parametrize( - "regs,verify,expected", + "do_config", + [ + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + }, + ], + }, + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,config_addon,expected", [ ( [0x00], @@ -163,32 +181,14 @@ async def test_config_light(hass, mock_modbus): ), ( None, - {}, + None, STATE_OFF, ), ], ) -async def test_all_light(hass, call_type, regs, verify, expected): +async def test_all_light(hass, mock_modbus, mock_do_cycle, expected): """Run test for given config.""" - state = await base_test( - hass, - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: call_type, - **verify, - }, - TEST_ENTITY_NAME, - LIGHT_DOMAIN, - CONF_LIGHTS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 07fe8ada2d0..8de22f88eb6 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -8,7 +8,6 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_PRECISION, - CONF_REGISTERS, CONF_SCALE, CONF_SWAP, CONF_SWAP_BYTE, @@ -36,7 +35,7 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import TEST_ENTITY_NAME, ReadResult, base_test +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" @@ -132,7 +131,7 @@ async def test_config_sensor(hass, mock_modbus): assert SENSOR_DOMAIN in hass.config.components -@pytest.mark.parametrize("mock_modbus", [{"testLoad": False}], indirect=True) +@pytest.mark.parametrize("check_config_loaded", [False]) @pytest.mark.parametrize( "do_config,error_message", [ @@ -235,7 +234,20 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl @pytest.mark.parametrize( - "cfg,regs,expected", + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "config_addon,register_words,expected", [ ( { @@ -504,26 +516,26 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl ), ], ) -async def test_all_sensor(hass, cfg, regs, expected): +async def test_all_sensor(hass, mock_modbus, mock_do_cycle, expected): """Run test for sensor.""" - - state = await base_test( - hass, - {CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, **cfg}, - TEST_ENTITY_NAME, - SENSOR_DOMAIN, - CONF_SENSORS, - CONF_REGISTERS, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( - "cfg,regs,expected", + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "config_addon,register_words,expected", [ ( { @@ -558,22 +570,9 @@ async def test_all_sensor(hass, cfg, regs, expected): ), ], ) -async def test_struct_sensor(hass, cfg, regs, expected): +async def test_struct_sensor(hass, mock_modbus, mock_do_cycle, expected): """Run test for sensor struct.""" - - state = await base_test( - hass, - {CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, **cfg}, - TEST_ENTITY_NAME, - SENSOR_DOMAIN, - CONF_SENSORS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 064d9cf7965..53907fe18e5 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -41,13 +41,7 @@ from homeassistant.core import State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import ( - TEST_ENTITY_NAME, - TEST_MODBUS_HOST, - TEST_PORT_TCP, - ReadResult, - base_test, -) +from .conftest import TEST_ENTITY_NAME, TEST_MODBUS_HOST, TEST_PORT_TCP, ReadResult from tests.common import async_fire_time_changed @@ -151,9 +145,33 @@ async def test_config_switch(hass, mock_modbus): assert SWITCH_DOMAIN in hass.config.components -@pytest.mark.parametrize("call_type", [CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING]) @pytest.mark.parametrize( - "regs,verify,expected", + "do_config", + [ + { + CONF_SWITCHES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + }, + ], + }, + { + CONF_SWITCHES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,config_addon,expected", [ ( [0x00], @@ -177,32 +195,14 @@ async def test_config_switch(hass, mock_modbus): ), ( None, - {}, + None, STATE_OFF, ), ], ) -async def test_all_switch(hass, call_type, regs, verify, expected): +async def test_all_switch(hass, mock_modbus, mock_do_cycle, expected): """Run test for given config.""" - state = await base_test( - hass, - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: call_type, - **verify, - }, - TEST_ENTITY_NAME, - SWITCH_DOMAIN, - CONF_SWITCHES, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( From 2d34ebc506e2b7d6c99bfde584218e909f0381d7 Mon Sep 17 00:00:00 2001 From: Jonas Pedersen Date: Sun, 22 Aug 2021 19:59:59 +0200 Subject: [PATCH 621/903] Add state_class to relevant sensors in Danfoss Air (#54847) Co-authored-by: Franck Nijhof --- .../components/danfoss_air/sensor.py | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index 25db56a1624..264e69739af 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -3,7 +3,7 @@ import logging from pydanfossair.commands import ReadCommand -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, @@ -27,52 +27,73 @@ def setup_platform(hass, config, add_entities, discovery_info=None): TEMP_CELSIUS, ReadCommand.exhaustTemperature, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ], [ "Danfoss Air Outdoor Temperature", TEMP_CELSIUS, ReadCommand.outdoorTemperature, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ], [ "Danfoss Air Supply Temperature", TEMP_CELSIUS, ReadCommand.supplyTemperature, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ], [ "Danfoss Air Extract Temperature", TEMP_CELSIUS, ReadCommand.extractTemperature, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ], [ "Danfoss Air Remaining Filter", PERCENTAGE, ReadCommand.filterPercent, None, + None, ], [ "Danfoss Air Humidity", PERCENTAGE, ReadCommand.humidity, DEVICE_CLASS_HUMIDITY, + STATE_CLASS_MEASUREMENT, + ], + ["Danfoss Air Fan Step", PERCENTAGE, ReadCommand.fan_step, None, None], + [ + "Danfoss Air Exhaust Fan Speed", + "RPM", + ReadCommand.exhaust_fan_speed, + None, + None, + ], + [ + "Danfoss Air Supply Fan Speed", + "RPM", + ReadCommand.supply_fan_speed, + None, + None, ], - ["Danfoss Air Fan Step", PERCENTAGE, ReadCommand.fan_step, None], - ["Danfoss Air Exhaust Fan Speed", "RPM", ReadCommand.exhaust_fan_speed, None], - ["Danfoss Air Supply Fan Speed", "RPM", ReadCommand.supply_fan_speed, None], [ "Danfoss Air Dial Battery", PERCENTAGE, ReadCommand.battery_percent, DEVICE_CLASS_BATTERY, + None, ], ] dev = [] for sensor in sensors: - dev.append(DanfossAir(data, sensor[0], sensor[1], sensor[2], sensor[3])) + dev.append( + DanfossAir(data, sensor[0], sensor[1], sensor[2], sensor[3], sensor[4]) + ) add_entities(dev, True) @@ -80,7 +101,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DanfossAir(SensorEntity): """Representation of a Sensor.""" - def __init__(self, data, name, sensor_unit, sensor_type, device_class): + def __init__(self, data, name, sensor_unit, sensor_type, device_class, state_class): """Initialize the sensor.""" self._data = data self._name = name @@ -88,6 +109,7 @@ class DanfossAir(SensorEntity): self._type = sensor_type self._unit = sensor_unit self._device_class = device_class + self._attr_state_class = state_class @property def name(self): From e6ba3b41cbbeca419b7875f138ceb98ba36ae63d Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Sun, 22 Aug 2021 11:09:22 -0700 Subject: [PATCH 622/903] Add Binary Sensor for WeMo Insight & Maker (#55000) Co-authored-by: Martin Hjelmare --- homeassistant/components/wemo/__init__.py | 4 +- .../components/wemo/binary_sensor.py | 31 ++- homeassistant/components/wemo/entity.py | 24 ++ homeassistant/components/wemo/sensor.py | 8 +- tests/components/wemo/conftest.py | 15 +- tests/components/wemo/test_binary_sensor.py | 225 +++++++++++++----- tests/components/wemo/test_sensor.py | 36 +-- 7 files changed, 247 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index dd2ae173b51..27d3a0cbf25 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -33,9 +33,9 @@ WEMO_MODEL_DISPATCH = { "CoffeeMaker": [SWITCH_DOMAIN], "Dimmer": [LIGHT_DOMAIN], "Humidifier": [FAN_DOMAIN], - "Insight": [SENSOR_DOMAIN, SWITCH_DOMAIN], + "Insight": [BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN], "LightSwitch": [SWITCH_DOMAIN], - "Maker": [SWITCH_DOMAIN], + "Maker": [BINARY_SENSOR_DOMAIN, SWITCH_DOMAIN], "Motion": [BINARY_SENSOR_DOMAIN], "OutdoorPlug": [SWITCH_DOMAIN], "Sensor": [BINARY_SENSOR_DOMAIN], diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index 1f48a093cd6..a7f1824cf4b 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -2,6 +2,8 @@ import asyncio import logging +from pywemo import Insight, Maker + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -16,7 +18,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _discovered_wemo(coordinator): """Handle a discovered Wemo device.""" - async_add_entities([WemoBinarySensor(coordinator)]) + if isinstance(coordinator.wemo, Insight): + async_add_entities([InsightBinarySensor(coordinator)]) + elif isinstance(coordinator.wemo, Maker): + async_add_entities([MakerBinarySensor(coordinator)]) + else: + async_add_entities([WemoBinarySensor(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.binary_sensor", _discovered_wemo) @@ -35,3 +42,25 @@ class WemoBinarySensor(WemoEntity, BinarySensorEntity): def is_on(self) -> bool: """Return true if the state is on. Standby is on.""" return self.wemo.get_state() + + +class MakerBinarySensor(WemoEntity, BinarySensorEntity): + """Maker device's sensor port.""" + + _name_suffix = "Sensor" + + @property + def is_on(self) -> bool: + """Return true if the Maker's sensor is pulled low.""" + return self.wemo.has_sensor and self.wemo.sensor_state == 0 + + +class InsightBinarySensor(WemoBinarySensor): + """Sensor representing the device connected to the Insight Switch.""" + + _name_suffix = "Device" + + @property + def is_on(self) -> bool: + """Return true device connected to the Insight Switch is on.""" + return super().is_on and self.wemo.insight_params["state"] == "1" diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index 4571d8f5eaa..62b23b78bd7 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -19,6 +19,12 @@ _LOGGER = logging.getLogger(__name__) class WemoEntity(CoordinatorEntity): """Common methods for Wemo entities.""" + # Most pyWeMo devices are associated with a single Home Assistant entity. When + # that is not the case, name_suffix & unique_id_suffix can be used to provide + # names and unique ids for additional Home Assistant entities. + _name_suffix: str | None = None + _unique_id_suffix: str | None = None + def __init__(self, coordinator: DeviceCoordinator) -> None: """Initialize the WeMo device.""" super().__init__(coordinator) @@ -26,9 +32,17 @@ class WemoEntity(CoordinatorEntity): self._device_info = coordinator.device_info self._available = True + @property + def name_suffix(self): + """Suffix to append to the WeMo device name.""" + return self._name_suffix + @property def name(self) -> str: """Return the name of the device if any.""" + suffix = self.name_suffix + if suffix: + return f"{self.wemo.name} {suffix}" return self.wemo.name @property @@ -36,9 +50,19 @@ class WemoEntity(CoordinatorEntity): """Return true if the device is available.""" return super().available and self._available + @property + def unique_id_suffix(self): + """Suffix to append to the WeMo device's unique ID.""" + if self._unique_id_suffix is None and self.name_suffix is not None: + return self._name_suffix.lower() + return self._unique_id_suffix + @property def unique_id(self) -> str: """Return the id of this WeMo device.""" + suffix = self.unique_id_suffix + if suffix: + return f"{self.wemo.serialnumber}_{suffix}" return self.wemo.serialnumber @property diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 5249ff8a4b9..d1a15ecec3a 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -45,14 +45,14 @@ class InsightSensor(WemoEntity, SensorEntity): """Common base for WeMo Insight power sensors.""" @property - def name(self) -> str: + def name_suffix(self) -> str: """Return the name of the entity if any.""" - return f"{super().name} {self.entity_description.name}" + return self.entity_description.name @property - def unique_id(self) -> str: + def unique_id_suffix(self) -> str: """Return the id of this entity.""" - return f"{super().unique_id}_{self.entity_description.key}" + return self.entity_description.key @property def available(self) -> str: diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 6c597d51df4..08abd140dac 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -68,8 +68,14 @@ def pywemo_device_fixture(pywemo_registry, pywemo_model): yield device +@pytest.fixture(name="wemo_entity_suffix") +def wemo_entity_suffix_fixture(): + """Fixture to select a specific entity for wemo_entity.""" + return "" + + @pytest.fixture(name="wemo_entity") -async def async_wemo_entity_fixture(hass, pywemo_device): +async def async_wemo_entity_fixture(hass, pywemo_device, wemo_entity_suffix): """Fixture for a Wemo entity in hass.""" assert await async_setup_component( hass, @@ -84,7 +90,8 @@ async def async_wemo_entity_fixture(hass, pywemo_device): await hass.async_block_till_done() entity_registry = er.async_get(hass) - entity_entries = list(entity_registry.entities.values()) - assert len(entity_entries) == 1 + for entry in entity_registry.entities.values(): + if entry.entity_id.endswith(wemo_entity_suffix): + return entry - yield entity_entries[0] + return None diff --git a/tests/components/wemo/test_binary_sensor.py b/tests/components/wemo/test_binary_sensor.py index 64e67162829..26e4981203d 100644 --- a/tests/components/wemo/test_binary_sensor.py +++ b/tests/components/wemo/test_binary_sensor.py @@ -6,71 +6,190 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.wemo.binary_sensor import ( + InsightBinarySensor, + MakerBinarySensor, +) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component from . import entity_test_helpers -@pytest.fixture -def pywemo_model(): - """Pywemo Motion models use the binary_sensor platform.""" - return "Motion" +class EntityTestHelpers: + """Common state update helpers.""" + + async def test_async_update_locked_multiple_updates( + self, hass, pywemo_device, wemo_entity + ): + """Test that two hass async_update state updates do not proceed at the same time.""" + await entity_test_helpers.test_async_update_locked_multiple_updates( + hass, pywemo_device, wemo_entity + ) + + async def test_async_update_locked_multiple_callbacks( + self, hass, pywemo_device, wemo_entity + ): + """Test that two device callback state updates do not proceed at the same time.""" + await entity_test_helpers.test_async_update_locked_multiple_callbacks( + hass, pywemo_device, wemo_entity + ) + + async def test_async_update_locked_callback_and_update( + self, hass, pywemo_device, wemo_entity + ): + """Test that a callback and a state update request can't both happen at the same time. + + When a state update is received via a callback from the device at the same time + as hass is calling `async_update`, verify that only one of the updates proceeds. + """ + await entity_test_helpers.test_async_update_locked_callback_and_update( + hass, pywemo_device, wemo_entity + ) -# Tests that are in common among wemo platforms. These test methods will be run -# in the scope of this test module. They will run using the pywemo_model from -# this test module (Motion). -test_async_update_locked_multiple_updates = ( - entity_test_helpers.test_async_update_locked_multiple_updates -) -test_async_update_locked_multiple_callbacks = ( - entity_test_helpers.test_async_update_locked_multiple_callbacks -) -test_async_update_locked_callback_and_update = ( - entity_test_helpers.test_async_update_locked_callback_and_update -) +class TestMotion(EntityTestHelpers): + """Test for the pyWeMo Motion device.""" + + @pytest.fixture + def pywemo_model(self): + """Pywemo Motion models use the binary_sensor platform.""" + return "Motion" + + async def test_binary_sensor_registry_state_callback( + self, hass, pywemo_registry, pywemo_device, wemo_entity + ): + """Verify that the binary_sensor receives state updates from the registry.""" + # On state. + pywemo_device.get_state.return_value = 1 + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + + # Off state. + pywemo_device.get_state.return_value = 0 + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + + async def test_binary_sensor_update_entity( + self, hass, pywemo_registry, pywemo_device, wemo_entity + ): + """Verify that the binary_sensor performs state updates.""" + await async_setup_component(hass, HA_DOMAIN, {}) + + # On state. + pywemo_device.get_state.return_value = 1 + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, + blocking=True, + ) + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + + # Off state. + pywemo_device.get_state.return_value = 0 + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, + blocking=True, + ) + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF -async def test_binary_sensor_registry_state_callback( - hass, pywemo_registry, pywemo_device, wemo_entity -): - """Verify that the binary_sensor receives state updates from the registry.""" - # On state. - pywemo_device.get_state.return_value = 1 - pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") - await hass.async_block_till_done() - assert hass.states.get(wemo_entity.entity_id).state == STATE_ON +class TestMaker(EntityTestHelpers): + """Test for the pyWeMo Maker device.""" - # Off state. - pywemo_device.get_state.return_value = 0 - pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") - await hass.async_block_till_done() - assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + @pytest.fixture + def pywemo_model(self): + """Pywemo Motion models use the binary_sensor platform.""" + return "Maker" + + @pytest.fixture + def wemo_entity_suffix(self): + """Select the MakerBinarySensor entity.""" + return MakerBinarySensor._name_suffix.lower() + + @pytest.fixture(name="pywemo_device") + def pywemo_device_fixture(self, pywemo_device): + """Fixture for WeMoDevice instances.""" + pywemo_device.maker_params = { + "hassensor": 1, + "sensorstate": 1, + "switchmode": 1, + "switchstate": 0, + } + pywemo_device.has_sensor = pywemo_device.maker_params["hassensor"] + pywemo_device.sensor_state = pywemo_device.maker_params["sensorstate"] + yield pywemo_device + + async def test_registry_state_callback( + self, hass, pywemo_registry, pywemo_device, wemo_entity + ): + """Verify that the binary_sensor receives state updates from the registry.""" + # On state. + pywemo_device.sensor_state = 0 + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + + # Off state. + pywemo_device.sensor_state = 1 + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF -async def test_binary_sensor_update_entity( - hass, pywemo_registry, pywemo_device, wemo_entity -): - """Verify that the binary_sensor performs state updates.""" - await async_setup_component(hass, HA_DOMAIN, {}) +class TestInsight(EntityTestHelpers): + """Test for the pyWeMo Insight device.""" - # On state. - pywemo_device.get_state.return_value = 1 - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) - assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + @pytest.fixture + def pywemo_model(self): + """Pywemo Motion models use the binary_sensor platform.""" + return "Insight" - # Off state. - pywemo_device.get_state.return_value = 0 - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) - assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + @pytest.fixture + def wemo_entity_suffix(self): + """Select the InsightBinarySensor entity.""" + return InsightBinarySensor._name_suffix.lower() + + @pytest.fixture(name="pywemo_device") + def pywemo_device_fixture(self, pywemo_device): + """Fixture for WeMoDevice instances.""" + pywemo_device.insight_params = { + "currentpower": 1.0, + "todaymw": 200000000.0, + "state": "0", + "onfor": 0, + "ontoday": 0, + "ontotal": 0, + "powerthreshold": 0, + } + yield pywemo_device + + async def test_registry_state_callback( + self, hass, pywemo_registry, pywemo_device, wemo_entity + ): + """Verify that the binary_sensor receives state updates from the registry.""" + # On state. + pywemo_device.get_state.return_value = 1 + pywemo_device.insight_params["state"] = "1" + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + + # Standby (Off) state. + pywemo_device.get_state.return_value = 1 + pywemo_device.insight_params["state"] = "8" + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + + # Off state. + pywemo_device.get_state.return_value = 0 + pywemo_device.insight_params["state"] = "1" + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF diff --git a/tests/components/wemo/test_sensor.py b/tests/components/wemo/test_sensor.py index a7f68429994..eb322d469cd 100644 --- a/tests/components/wemo/test_sensor.py +++ b/tests/components/wemo/test_sensor.py @@ -6,14 +6,10 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC -from homeassistant.components.wemo.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE -from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from . import entity_test_helpers -from .conftest import MOCK_HOST, MOCK_PORT @pytest.fixture @@ -44,35 +40,11 @@ class InsightTestTemplate: EXPECTED_STATE_VALUE: str INSIGHT_PARAM_NAME: str - @pytest.fixture(name="wemo_entity") + @pytest.fixture(name="wemo_entity_suffix") @classmethod - async def async_wemo_entity_fixture(cls, hass, pywemo_device): - """Fixture for a Wemo entity in hass.""" - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_DISCOVERY: False, - CONF_STATIC: [f"{MOCK_HOST}:{MOCK_PORT}"], - }, - }, - ) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - correct_entity = None - to_remove = [] - for entry in entity_registry.entities.values(): - if entry.entity_id.endswith(cls.ENTITY_ID_SUFFIX): - correct_entity = entry - else: - to_remove.append(entry.entity_id) - - for removal in to_remove: - entity_registry.async_remove(removal) - assert len(entity_registry.entities) == 1 - return correct_entity + def wemo_entity_suffix_fixture(cls): + """Select the appropriate entity for the test.""" + return cls.ENTITY_ID_SUFFIX # Tests that are in common among wemo platforms. These test methods will be run # in the scope of this test module. They will run using the pywemo_model from From bba6a75934b47fa6fbf5d1a9a1f1f88b7184e0f9 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 22 Aug 2021 14:13:44 -0400 Subject: [PATCH 623/903] Add silver quality scale to goalzero (#53299) * Add platinum quality scale to goalzero * adjust for quality scale * Update manifest.json --- homeassistant/components/goalzero/__init__.py | 3 +-- homeassistant/components/goalzero/manifest.json | 1 + homeassistant/components/goalzero/sensor.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 379a56512c6..04a9d6aaa86 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -53,8 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await api.init_connect() except exceptions.ConnectError as ex: - _LOGGER.warning("Failed to connect to device %s", ex) - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady(f"Failed to connect to device: {ex}") from ex async def async_update_data(): """Fetch data from API endpoint.""" diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json index 52d3a024955..b4a9415d01d 100644 --- a/homeassistant/components/goalzero/manifest.json +++ b/homeassistant/components/goalzero/manifest.json @@ -8,5 +8,6 @@ {"hostname": "yeti*"} ], "codeowners": ["@tkdrob"], + "quality_scale": "silver", "iot_class": "local_polling" } diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index b422b317601..957891e67ed 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -110,6 +110,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Wifi Strength", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_registry_enabled_default=False, ), SensorEntityDescription( key="timestamp", From bfb6eaf6f3769ff723aca8f370d9dd2e8b98fd43 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 22 Aug 2021 20:30:50 +0200 Subject: [PATCH 624/903] Use EntityDescription - openuv (#55022) --- homeassistant/components/openuv/sensor.py | 107 +++++++++++++--------- 1 file changed, 64 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index e115f9294a5..9769245c48f 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -1,7 +1,7 @@ """Support for OpenUV sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import TIME_MINUTES, UV_INDEX from homeassistant.core import HomeAssistant, callback @@ -42,42 +42,68 @@ UV_LEVEL_HIGH = "High" UV_LEVEL_MODERATE = "Moderate" UV_LEVEL_LOW = "Low" -SENSORS = { - TYPE_CURRENT_OZONE_LEVEL: ("Current Ozone Level", "mdi:vector-triangle", "du"), - TYPE_CURRENT_UV_INDEX: ("Current UV Index", "mdi:weather-sunny", UV_INDEX), - TYPE_CURRENT_UV_LEVEL: ("Current UV Level", "mdi:weather-sunny", None), - TYPE_MAX_UV_INDEX: ("Max UV Index", "mdi:weather-sunny", UV_INDEX), - TYPE_SAFE_EXPOSURE_TIME_1: ( - "Skin Type 1 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=TYPE_CURRENT_OZONE_LEVEL, + name="Current Ozone Level", + icon="mdi:vector-triangle", + native_unit_of_measurement="du", ), - TYPE_SAFE_EXPOSURE_TIME_2: ( - "Skin Type 2 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, + SensorEntityDescription( + key=TYPE_CURRENT_UV_INDEX, + name="Current UV Index", + icon="mdi:weather-sunny", + native_unit_of_measurement=UV_INDEX, ), - TYPE_SAFE_EXPOSURE_TIME_3: ( - "Skin Type 3 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, + SensorEntityDescription( + key=TYPE_CURRENT_UV_LEVEL, + name="Current UV Level", + icon="mdi:weather-sunny", + native_unit_of_measurement=None, ), - TYPE_SAFE_EXPOSURE_TIME_4: ( - "Skin Type 4 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, + SensorEntityDescription( + key=TYPE_MAX_UV_INDEX, + name="Max UV Index", + icon="mdi:weather-sunny", + native_unit_of_measurement=UV_INDEX, ), - TYPE_SAFE_EXPOSURE_TIME_5: ( - "Skin Type 5 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_1, + name="Skin Type 1 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, ), - TYPE_SAFE_EXPOSURE_TIME_6: ( - "Skin Type 6 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_2, + name="Skin Type 2 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, ), -} + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_3, + name="Skin Type 3 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_4, + name="Skin Type 4 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_5, + name="Skin Type 5 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_6, + name="Skin Type 6 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, + ), +) async def async_setup_entry( @@ -86,26 +112,21 @@ async def async_setup_entry( """Set up a OpenUV sensor based on a config entry.""" openuv = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - sensors = [] - for kind, attrs in SENSORS.items(): - name, icon, unit = attrs - sensors.append(OpenUvSensor(openuv, kind, name, icon, unit)) - - async_add_entities(sensors, True) + entities = [OpenUvSensor(openuv, description) for description in SENSOR_TYPES] + async_add_entities(entities, True) class OpenUvSensor(OpenUvEntity, SensorEntity): """Define a binary sensor for OpenUV.""" def __init__( - self, openuv: OpenUV, sensor_type: str, name: str, icon: str, unit: str | None + self, + openuv: OpenUV, + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(openuv, sensor_type) - - self._attr_icon = icon - self._attr_name = name - self._attr_native_unit_of_measurement = unit + super().__init__(openuv, description.key) + self.entity_description = description @callback def update_from_latest_data(self) -> None: From 0095c6baebfe2c75308f9db43127370710cb556f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 22 Aug 2021 21:32:50 +0300 Subject: [PATCH 625/903] Improve device trigger type hinting (#54907) --- .../components/alarm_control_panel/device_trigger.py | 4 ++-- homeassistant/components/arcam_fmj/device_trigger.py | 6 +++++- homeassistant/components/climate/device_trigger.py | 12 +++++++++--- homeassistant/components/cover/device_trigger.py | 10 ++++++++-- .../components/device_automation/toggle_entity.py | 8 ++++++-- .../components/device_tracker/device_trigger.py | 6 ++++-- homeassistant/components/fan/device_trigger.py | 10 ++++++++-- .../components/homekit_controller/device_trigger.py | 6 +++++- .../components/humidifier/device_trigger.py | 12 ++++++++++-- homeassistant/components/kodi/device_trigger.py | 6 +++++- homeassistant/components/light/device_trigger.py | 10 ++++++++-- homeassistant/components/lock/device_trigger.py | 10 ++++++++-- .../components/lutron_caseta/device_trigger.py | 6 +++++- .../components/media_player/device_trigger.py | 10 ++++++++-- homeassistant/components/mqtt/device_trigger.py | 6 ++++-- homeassistant/components/nest/device_trigger.py | 6 +++++- homeassistant/components/netatmo/device_trigger.py | 6 +++++- .../components/philips_js/device_trigger.py | 6 +++++- homeassistant/components/remote/device_trigger.py | 10 ++++++++-- homeassistant/components/select/device_trigger.py | 6 ++++-- homeassistant/components/shelly/device_trigger.py | 2 +- homeassistant/components/switch/device_trigger.py | 10 ++++++++-- homeassistant/components/tasmota/device_trigger.py | 6 ++++-- homeassistant/components/vacuum/device_trigger.py | 10 ++++++++-- homeassistant/components/zwave_js/device_trigger.py | 6 +++++- .../device_trigger/integration/device_trigger.py | 6 +++++- 26 files changed, 153 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 9ab6e466b6c..695ec0ebb4a 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -1,7 +1,7 @@ """Provides device automations for Alarm control panel.""" from __future__ import annotations -from typing import Final +from typing import Any, Final import voluptuous as vol @@ -55,7 +55,7 @@ TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( async def async_get_triggers( hass: HomeAssistant, device_id: str -) -> list[dict[str, str]]: +) -> list[dict[str, Any]]: """List device triggers for Alarm control panel devices.""" registry = await entity_registry.async_get_registry(hass) triggers: list[dict[str, str]] = [] diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index 383f28d7a20..7bf7a06d851 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Arcam FMJ Receiver control.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -28,7 +30,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Arcam FMJ Receiver control devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 1b5127d7d4a..4ff2e8fe477 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Climate.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -58,7 +60,9 @@ CURRENT_TRIGGER_SCHEMA = vol.All( TRIGGER_SCHEMA = vol.Any(HVAC_MODE_TRIGGER_SCHEMA, CURRENT_TRIGGER_SCHEMA) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Climate devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -158,12 +162,14 @@ async def async_attach_trigger( ) -async def async_get_trigger_capabilities(hass: HomeAssistant, config): +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" trigger_type = config[CONF_TYPE] if trigger_type == "hvac_action_changed": - return None + return {} if trigger_type == "hvac_mode_changed": return { diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index acfd276d1fb..e7048032cba 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Cover.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -67,7 +69,9 @@ STATE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( TRIGGER_SCHEMA = vol.Any(POSITION_TRIGGER_SCHEMA, STATE_TRIGGER_SCHEMA) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Cover devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -114,7 +118,9 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: return triggers -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" if config[CONF_TYPE] not in POSITION_TRIGGER_TYPES: return { diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index 794b6643ae8..2e9576ee74a 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -1,6 +1,8 @@ """Device automation helpers for toggle entity.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -212,7 +214,7 @@ async def async_get_conditions( async def async_get_triggers( hass: HomeAssistant, device_id: str, domain: str -) -> list[dict]: +) -> list[dict[str, Any]]: """List device triggers.""" return await _async_get_automations(hass, device_id, ENTITY_TRIGGERS, domain) @@ -228,7 +230,9 @@ async def async_get_condition_capabilities( } -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index e73b5a70075..0b8fd6da7f4 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -1,7 +1,7 @@ """Provides device automations for Device Tracker.""" from __future__ import annotations -from typing import Final +from typing import Any, Final import voluptuous as vol @@ -34,7 +34,9 @@ TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Device Tracker devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] diff --git a/homeassistant/components/fan/device_trigger.py b/homeassistant/components/fan/device_trigger.py index 15f8f4be45e..38cfb33b42d 100644 --- a/homeassistant/components/fan/device_trigger.py +++ b/homeassistant/components/fan/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Fan.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -16,12 +18,16 @@ TRIGGER_SCHEMA = toggle_entity.TRIGGER_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Fan devices.""" return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 818b75e47d3..1972aadfeca 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for homekit devices.""" from __future__ import annotations +from typing import Any + from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.characteristics.const import InputEventValues from aiohomekit.model.services import ServicesTypes @@ -232,7 +234,9 @@ def async_fire_triggers(conn, events): source.fire(iid, ev) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for homekit devices.""" if device_id not in hass.data.get(TRIGGERS, {}): diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 98bbe192a1f..5c761e798ea 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Climate.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -28,6 +30,8 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN +# mypy: disallow-any-generics + TARGET_TRIGGER_SCHEMA = vol.All( DEVICE_TRIGGER_BASE_SCHEMA.extend( { @@ -48,7 +52,9 @@ TOGGLE_TRIGGER_SCHEMA = toggle_entity.TRIGGER_SCHEMA.extend( TRIGGER_SCHEMA = vol.Any(TARGET_TRIGGER_SCHEMA, TOGGLE_TRIGGER_SCHEMA) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Humidifier devices.""" registry = await entity_registry.async_get_registry(hass) triggers = await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) @@ -105,7 +111,9 @@ async def async_attach_trigger( ) -async def async_get_trigger_capabilities(hass: HomeAssistant, config): +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" trigger_type = config[CONF_TYPE] diff --git a/homeassistant/components/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py index 584e465b3a6..ac474413b54 100644 --- a/homeassistant/components/kodi/device_trigger.py +++ b/homeassistant/components/kodi/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Kodi.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -29,7 +31,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Kodi devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] diff --git a/homeassistant/components/light/device_trigger.py b/homeassistant/components/light/device_trigger.py index e1b14124831..6cb6e8a34c1 100644 --- a/homeassistant/components/light/device_trigger.py +++ b/homeassistant/components/light/device_trigger.py @@ -1,6 +1,8 @@ """Provides device trigger for lights.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -28,11 +30,15 @@ async def async_attach_trigger( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers.""" return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 641030e9f23..393fd968437 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Lock.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -36,7 +38,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Lock devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -61,7 +65,9 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: return triggers -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 7d9728a79a1..8a7f321e158 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for lutron caseta.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -225,7 +227,9 @@ async def async_validate_trigger_config(hass: HomeAssistant, config: ConfigType) return schema(config) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for lutron caseta devices.""" triggers = [] diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index de0ff6b8e90..532519616d2 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Media player.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -36,7 +38,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Media player entities.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -61,7 +65,9 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: return triggers -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 89246406de3..b4b586e14d2 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Callable +from typing import Any, Callable import attr import voluptuous as vol @@ -287,7 +287,9 @@ async def async_device_removed(hass: HomeAssistant, device_id: str): ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for MQTT devices.""" triggers: list[dict] = [] diff --git a/homeassistant/components/nest/device_trigger.py b/homeassistant/components/nest/device_trigger.py index 980d9726467..619f6a3fe56 100644 --- a/homeassistant/components/nest/device_trigger.py +++ b/homeassistant/components/nest/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Nest.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -62,7 +64,9 @@ async def async_get_device_trigger_types( return trigger_types -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for a Nest device.""" nest_device_id = await async_get_nest_device_id(hass, device_id) if not nest_device_id: diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index 1bfc736d581..777b905f5d7 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Netatmo.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -84,7 +86,9 @@ async def async_validate_trigger_config( return config -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Netatmo devices.""" registry = await entity_registry.async_get_registry(hass) device_registry = await hass.helpers.device_registry.async_get_registry() diff --git a/homeassistant/components/philips_js/device_trigger.py b/homeassistant/components/philips_js/device_trigger.py index 51efa643310..85b1a012860 100644 --- a/homeassistant/components/philips_js/device_trigger.py +++ b/homeassistant/components/philips_js/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for control of device.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -23,7 +25,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for device.""" triggers = [] triggers.append( diff --git a/homeassistant/components/remote/device_trigger.py b/homeassistant/components/remote/device_trigger.py index d8437604f6d..40182cc0114 100644 --- a/homeassistant/components/remote/device_trigger.py +++ b/homeassistant/components/remote/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for remotes.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -28,11 +30,15 @@ async def async_attach_trigger( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers.""" return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/select/device_trigger.py b/homeassistant/components/select/device_trigger.py index 84f61dfaec9..ded3ff4bc24 100644 --- a/homeassistant/components/select/device_trigger.py +++ b/homeassistant/components/select/device_trigger.py @@ -42,7 +42,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Select devices.""" registry = await entity_registry.async_get_registry(hass) return [ @@ -87,7 +89,7 @@ async def async_attach_trigger( async def async_get_trigger_capabilities( hass: HomeAssistant, config: ConfigType -) -> dict[str, Any]: +) -> dict[str, vol.Schema]: """List trigger capabilities.""" try: options = get_capability(hass, config[CONF_ENTITY_ID], ATTR_OPTIONS) or [] diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index bcb909555a9..c44dd279230 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -69,7 +69,7 @@ async def async_validate_trigger_config( async def async_get_triggers( hass: HomeAssistant, device_id: str -) -> list[dict[str, str]]: +) -> list[dict[str, Any]]: """List device triggers for Shelly devices.""" triggers = [] diff --git a/homeassistant/components/switch/device_trigger.py b/homeassistant/components/switch/device_trigger.py index 15b700d9eb5..b796a31134f 100644 --- a/homeassistant/components/switch/device_trigger.py +++ b/homeassistant/components/switch/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for switches.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -28,11 +30,15 @@ async def async_attach_trigger( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers.""" return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/tasmota/device_trigger.py b/homeassistant/components/tasmota/device_trigger.py index eb95ca2bf64..b3be1fbd2cc 100644 --- a/homeassistant/components/tasmota/device_trigger.py +++ b/homeassistant/components/tasmota/device_trigger.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Callable +from typing import Any, Callable import attr from hatasmota.models import DiscoveryHashType @@ -259,7 +259,9 @@ async def async_remove_triggers(hass: HomeAssistant, device_id: str) -> None: device_trigger.remove_update_signal() -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for a Tasmota device.""" triggers: list[dict[str, str]] = [] diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index 4c1d6e93820..9189568d2f4 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Vacuum.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -31,7 +33,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Vacuum devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -55,7 +59,9 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: return triggers -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index e0588e0ea4e..6fab91c867d 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for Z-Wave JS.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from zwave_js_server.const import CommandClass, ConfigurationValueType @@ -184,7 +186,9 @@ TRIGGER_SCHEMA = vol.Any( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Z-Wave JS devices.""" dev_reg = device_registry.async_get(hass) node = async_get_node_from_device_id(hass, device_id, dev_reg) diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index e070bc43f57..16dc43f8d59 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for NEW_NAME.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -32,7 +34,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for NEW_NAME devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] From 0680e9f8334f293200610c9d498e6c45e48fd408 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 22 Aug 2021 20:33:09 +0200 Subject: [PATCH 626/903] Fix P1 Monitor requirement in manifest (#55027) --- homeassistant/components/p1_monitor/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json index 9e61bca3089..1a4beb36f5d 100644 --- a/homeassistant/components/p1_monitor/manifest.json +++ b/homeassistant/components/p1_monitor/manifest.json @@ -3,8 +3,8 @@ "name": "P1 Monitor", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/p1_monitor", - "requirements": ["p1monitor == 0.2.0"], + "requirements": ["p1monitor==0.2.0"], "codeowners": ["@klaasnicolaas"], "quality_scale": "platinum", "iot_class": "local_polling" -} \ No newline at end of file +} diff --git a/requirements_all.txt b/requirements_all.txt index bed06c29016..81b94ac5f43 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1136,7 +1136,7 @@ orvibo==1.1.1 ovoenergy==1.1.12 # homeassistant.components.p1_monitor -p1monitor == 0.2.0 +p1monitor==0.2.0 # homeassistant.components.mqtt # homeassistant.components.shiftr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ad5767b306..86c2921598b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -638,7 +638,7 @@ openerz-api==0.1.0 ovoenergy==1.1.12 # homeassistant.components.p1_monitor -p1monitor == 0.2.0 +p1monitor==0.2.0 # homeassistant.components.mqtt # homeassistant.components.shiftr From 562212bb5e7f50f2ba56d457b7da087c69f1dbfe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Aug 2021 14:20:40 -0500 Subject: [PATCH 627/903] Add support for bridge accessory unavailability (#52207) Co-authored-by: Jc2k --- .../components/homekit_controller/__init__.py | 2 +- .../homekit_controller/test_sensor.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index c14cfbb8a7e..f7c98c66708 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -142,7 +142,7 @@ class HomeKitEntity(Entity): @property def available(self) -> bool: """Return True if entity is available.""" - return self._accessory.available + return self._accessory.available and self.service.available @property def device_info(self): diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 604c83e54f7..f96569551d8 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -1,6 +1,7 @@ """Basic checks for HomeKit sensor.""" from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from aiohomekit.protocol.statuscodes import HapStatusCode from homeassistant.const import ( DEVICE_CLASS_BATTERY, @@ -236,3 +237,30 @@ async def test_switch_with_sensor(hass, utcnow): realtime_energy.value = 50 state = await energy_helper.poll_and_get_state() assert state.state == "50" + + +async def test_sensor_unavailable(hass, utcnow): + """Test a sensor becoming unavailable.""" + helper = await setup_test_component(hass, create_switch_with_sensor) + + # Find the energy sensor and mark it as offline + outlet = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + realtime_energy = outlet[CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY] + realtime_energy.status = HapStatusCode.UNABLE_TO_COMMUNICATE + + # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. + energy_helper = Helper( + hass, + "sensor.testdevice_real_time_energy", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + # Outlet has non-responsive characteristics so should be unavailable + state = await helper.poll_and_get_state() + assert state.state == "unavailable" + + # Energy sensor has non-responsive characteristics so should be unavailable + state = await energy_helper.poll_and_get_state() + assert state.state == "unavailable" From e5338c3f89db5863bc5d099e4bb9f106b3de7478 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 22 Aug 2021 22:26:24 +0200 Subject: [PATCH 628/903] Use EntityDescription - ring (#55023) Co-authored-by: Franck Nijhof --- .../components/ring/binary_sensor.py | 96 ++++---- homeassistant/components/ring/sensor.py | 233 +++++++++--------- 2 files changed, 167 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index d2c412a691d..de854022301 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -1,25 +1,49 @@ """This component provides HA sensor support for Ring Door Bell/Chimes.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import datetime from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_OCCUPANCY, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import callback from . import DOMAIN from .entity import RingEntityMixin -# Sensor types: Name, category, device_class -SENSOR_TYPES = { - "ding": ["Ding", ["doorbots", "authorized_doorbots"], DEVICE_CLASS_OCCUPANCY], - "motion": [ - "Motion", - ["doorbots", "authorized_doorbots", "stickup_cams"], - DEVICE_CLASS_MOTION, - ], -} + +@dataclass +class RingRequiredKeysMixin: + """Mixin for required keys.""" + + category: list[str] + + +@dataclass +class RingBinarySensorEntityDescription( + BinarySensorEntityDescription, RingRequiredKeysMixin +): + """Describes Ring binary sensor entity.""" + + +BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( + RingBinarySensorEntityDescription( + key="ding", + name="Ding", + category=["doorbots", "authorized_doorbots"], + device_class=DEVICE_CLASS_OCCUPANCY, + ), + RingBinarySensorEntityDescription( + key="motion", + name="Motion", + category=["doorbots", "authorized_doorbots", "stickup_cams"], + device_class=DEVICE_CLASS_MOTION, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -27,35 +51,36 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ring = hass.data[DOMAIN][config_entry.entry_id]["api"] devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] - sensors = [] + entities = [ + RingBinarySensor(config_entry.entry_id, ring, device, description) + for device_type in ("doorbots", "authorized_doorbots", "stickup_cams") + for description in BINARY_SENSOR_TYPES + if device_type in description.category + for device in devices[device_type] + ] - for device_type in ("doorbots", "authorized_doorbots", "stickup_cams"): - for sensor_type, sensor in SENSOR_TYPES.items(): - if device_type not in sensor[1]: - continue - - for device in devices[device_type]: - sensors.append( - RingBinarySensor(config_entry.entry_id, ring, device, sensor_type) - ) - - async_add_entities(sensors) + async_add_entities(entities) class RingBinarySensor(RingEntityMixin, BinarySensorEntity): """A binary sensor implementation for Ring device.""" _active_alert = None + entity_description: RingBinarySensorEntityDescription - def __init__(self, config_entry_id, ring, device, sensor_type): + def __init__( + self, + config_entry_id, + ring, + device, + description: RingBinarySensorEntityDescription, + ): """Initialize a sensor for Ring device.""" super().__init__(config_entry_id, device) + self.entity_description = description self._ring = ring - self._sensor_type = sensor_type - self._name = f"{self._device.name} {SENSOR_TYPES.get(sensor_type)[0]}" - self._device_class = SENSOR_TYPES.get(sensor_type)[2] - self._state = None - self._unique_id = f"{device.id}-{sensor_type}" + self._attr_name = f"{device.name} {description.name}" + self._attr_unique_id = f"{device.id}-{description.key}" self._update_alert() async def async_added_to_hass(self): @@ -84,32 +109,17 @@ class RingBinarySensor(RingEntityMixin, BinarySensorEntity): ( alert for alert in self._ring.active_alerts() - if alert["kind"] == self._sensor_type + if alert["kind"] == self.entity_description.key and alert["doorbot_id"] == self._device.id ), None, ) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def is_on(self): """Return True if the binary sensor is on.""" return self._active_alert is not None - @property - def device_class(self): - """Return the class of the binary sensor.""" - return self._device_class - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 192ba03c010..c36b44f5ee5 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -1,5 +1,9 @@ """This component provides HA sensor support for Ring Door Bell/Chimes.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_TIMESTAMP, PERCENTAGE, @@ -16,77 +20,58 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up a sensor for a Ring device.""" devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] - sensors = [] + entities = [ + description.cls(config_entry.entry_id, device, description) + for device_type in ("chimes", "doorbots", "authorized_doorbots", "stickup_cams") + for description in SENSOR_TYPES + if device_type in description.category + for device in devices[device_type] + if not (device_type == "battery" and device.battery_life is None) + ] - for device_type in ("chimes", "doorbots", "authorized_doorbots", "stickup_cams"): - for sensor_type, sensor in SENSOR_TYPES.items(): - if device_type not in sensor[1]: - continue - - for device in devices[device_type]: - if device_type == "battery" and device.battery_life is None: - continue - - sensors.append(sensor[6](config_entry.entry_id, device, sensor_type)) - - async_add_entities(sensors) + async_add_entities(entities) class RingSensor(RingEntityMixin, SensorEntity): """A sensor implementation for Ring device.""" - def __init__(self, config_entry_id, device, sensor_type): + entity_description: RingSensorEntityDescription + _attr_should_poll = False # updates are controlled via the hub + + def __init__( + self, + config_entry_id, + device, + description: RingSensorEntityDescription, + ): """Initialize a sensor for Ring device.""" super().__init__(config_entry_id, device) - self._sensor_type = sensor_type + self.entity_description = description self._extra = None - self._icon = f"mdi:{SENSOR_TYPES.get(sensor_type)[3]}" - self._kind = SENSOR_TYPES.get(sensor_type)[4] - self._name = f"{self._device.name} {SENSOR_TYPES.get(sensor_type)[0]}" - self._unique_id = f"{device.id}-{sensor_type}" - - @property - def should_poll(self): - """Return False, updates are controlled via the hub.""" - return False - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = f"{device.name} {description.name}" + self._attr_unique_id = f"{device.id}-{description.key}" @property def native_value(self): """Return the state of the sensor.""" - if self._sensor_type == "volume": + sensor_type = self.entity_description.key + if sensor_type == "volume": return self._device.volume - if self._sensor_type == "battery": + if sensor_type == "battery": return self._device.battery_life - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def device_class(self): - """Return sensor device class.""" - return SENSOR_TYPES[self._sensor_type][5] - @property def icon(self): """Icon to use in the frontend, if any.""" - if self._sensor_type == "battery" and self._device.battery_life is not None: + if ( + self.entity_description.key == "battery" + and self._device.battery_life is not None + ): return icon_for_battery_level( battery_level=self._device.battery_life, charging=False ) - return self._icon - - @property - def native_unit_of_measurement(self): - """Return the units of measurement.""" - return SENSOR_TYPES.get(self._sensor_type)[2] + return self.entity_description.icon class HealthDataRingSensor(RingSensor): @@ -122,10 +107,11 @@ class HealthDataRingSensor(RingSensor): @property def native_value(self): """Return the state of the sensor.""" - if self._sensor_type == "wifi_signal_category": + sensor_type = self.entity_description.key + if sensor_type == "wifi_signal_category": return self._device.wifi_signal_category - if self._sensor_type == "wifi_signal_strength": + if sensor_type == "wifi_signal_strength": return self._device.wifi_signal_strength @@ -156,12 +142,13 @@ class HistoryRingSensor(RingSensor): if not history_data: return + kind = self.entity_description.kind found = None - if self._kind is None: + if kind is None: found = history_data[0] else: for entry in history_data: - if entry["kind"] == self._kind: + if entry["kind"] == kind: found = entry break @@ -193,69 +180,77 @@ class HistoryRingSensor(RingSensor): return attrs -# Sensor types: Name, category, units, icon, kind, device_class, class -SENSOR_TYPES = { - "battery": [ - "Battery", - ["doorbots", "authorized_doorbots", "stickup_cams"], - PERCENTAGE, - None, - None, - "battery", - RingSensor, - ], - "last_activity": [ - "Last Activity", - ["doorbots", "authorized_doorbots", "stickup_cams"], - None, - "history", - None, - DEVICE_CLASS_TIMESTAMP, - HistoryRingSensor, - ], - "last_ding": [ - "Last Ding", - ["doorbots", "authorized_doorbots"], - None, - "history", - "ding", - DEVICE_CLASS_TIMESTAMP, - HistoryRingSensor, - ], - "last_motion": [ - "Last Motion", - ["doorbots", "authorized_doorbots", "stickup_cams"], - None, - "history", - "motion", - DEVICE_CLASS_TIMESTAMP, - HistoryRingSensor, - ], - "volume": [ - "Volume", - ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], - None, - "bell-ring", - None, - None, - RingSensor, - ], - "wifi_signal_category": [ - "WiFi Signal Category", - ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], - None, - "wifi", - None, - None, - HealthDataRingSensor, - ], - "wifi_signal_strength": [ - "WiFi Signal Strength", - ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - "wifi", - None, - "signal_strength", - HealthDataRingSensor, - ], -} +@dataclass +class RingRequiredKeysMixin: + """Mixin for required keys.""" + + category: list[str] + cls: type[RingSensor] + + +@dataclass +class RingSensorEntityDescription(SensorEntityDescription, RingRequiredKeysMixin): + """Describes Ring sensor entity.""" + + kind: str | None = None + + +SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( + RingSensorEntityDescription( + key="battery", + name="Battery", + category=["doorbots", "authorized_doorbots", "stickup_cams"], + native_unit_of_measurement=PERCENTAGE, + device_class="battery", + cls=RingSensor, + ), + RingSensorEntityDescription( + key="last_activity", + name="Last Activity", + category=["doorbots", "authorized_doorbots", "stickup_cams"], + icon="mdi:history", + device_class=DEVICE_CLASS_TIMESTAMP, + cls=HistoryRingSensor, + ), + RingSensorEntityDescription( + key="last_ding", + name="Last Ding", + category=["doorbots", "authorized_doorbots"], + icon="mdi:history", + kind="ding", + device_class=DEVICE_CLASS_TIMESTAMP, + cls=HistoryRingSensor, + ), + RingSensorEntityDescription( + key="last_motion", + name="Last Motion", + category=["doorbots", "authorized_doorbots", "stickup_cams"], + icon="mdi:history", + kind="motion", + device_class=DEVICE_CLASS_TIMESTAMP, + cls=HistoryRingSensor, + ), + RingSensorEntityDescription( + key="volume", + name="Volume", + category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], + icon="mdi:bell-ring", + cls=RingSensor, + ), + RingSensorEntityDescription( + key="wifi_signal_category", + name="WiFi Signal Category", + category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], + icon="mdi:wifi", + cls=HealthDataRingSensor, + ), + RingSensorEntityDescription( + key="wifi_signal_strength", + name="WiFi Signal Strength", + category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + icon="mdi:wifi", + device_class="signal_strength", + cls=HealthDataRingSensor, + ), +) From 2113368b850feda575e6cb1c7e48b2f54d1d0b15 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Sun, 22 Aug 2021 14:12:02 -0700 Subject: [PATCH 629/903] Add zigbee connection for wemo bridge lights (#53813) --- homeassistant/components/wemo/light.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index ecb64296171..13f375ad726 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -16,6 +16,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util @@ -105,6 +106,7 @@ class WemoLight(WemoEntity, LightEntity): """Return the device info.""" return { "name": self.name, + "connections": {(CONNECTION_ZIGBEE, self._unique_id)}, "identifiers": {(WEMO_DOMAIN, self._unique_id)}, "model": self._model_name, "manufacturer": "Belkin", From 305475a635a95145c3e432a0553655ae3d6190f8 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 23 Aug 2021 00:12:20 +0000 Subject: [PATCH 630/903] [ci skip] Translation update --- .../components/accuweather/translations/he.json | 10 ++++++++-- .../accuweather/translations/sensor.he.json | 9 +++++++++ .../components/airtouch4/translations/he.json | 3 +++ .../components/arcam_fmj/translations/he.json | 6 ------ .../components/directv/translations/he.json | 8 -------- .../components/fjaraskupan/translations/pl.json | 13 +++++++++++++ .../components/fjaraskupan/translations/ru.json | 13 +++++++++++++ .../fjaraskupan/translations/zh-Hant.json | 13 +++++++++++++ .../components/hangouts/translations/he.json | 2 -- .../components/konnected/translations/he.json | 6 ------ .../components/kraken/translations/he.json | 12 ------------ .../components/netatmo/translations/he.json | 7 +++++++ .../components/p1_monitor/translations/pl.json | 17 +++++++++++++++++ .../components/p1_monitor/translations/ru.json | 17 +++++++++++++++++ .../p1_monitor/translations/zh-Hant.json | 17 +++++++++++++++++ .../rainforest_eagle/translations/pl.json | 1 + .../rainforest_eagle/translations/ru.json | 1 + .../rainforest_eagle/translations/zh-Hant.json | 1 + .../components/roku/translations/he.json | 8 -------- .../components/roomba/translations/ca.json | 2 +- .../components/roomba/translations/de.json | 2 +- .../components/roomba/translations/en.json | 1 + .../components/roomba/translations/et.json | 2 +- .../components/roomba/translations/pl.json | 2 +- .../components/roomba/translations/ru.json | 2 +- .../components/roomba/translations/zh-Hant.json | 2 +- .../components/tellduslive/translations/he.json | 1 - .../components/unifi/translations/he.json | 8 -------- .../components/upnp/translations/he.json | 12 ------------ .../components/xiaomi_miio/translations/he.json | 2 +- .../components/yeelight/translations/he.json | 2 +- .../components/yeelight/translations/pl.json | 2 +- .../components/yeelight/translations/ru.json | 2 +- .../yeelight/translations/zh-Hant.json | 2 +- .../components/zha/translations/pl.json | 4 ++++ .../components/zha/translations/ru.json | 4 ++++ .../components/zha/translations/zh-Hant.json | 4 ++++ .../components/zwave_js/translations/he.json | 1 + .../components/zwave_js/translations/pl.json | 12 ++++++++++-- .../components/zwave_js/translations/ru.json | 12 ++++++++++-- .../zwave_js/translations/zh-Hant.json | 12 ++++++++++-- 41 files changed, 175 insertions(+), 82 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/sensor.he.json create mode 100644 homeassistant/components/fjaraskupan/translations/pl.json create mode 100644 homeassistant/components/fjaraskupan/translations/ru.json create mode 100644 homeassistant/components/fjaraskupan/translations/zh-Hant.json create mode 100644 homeassistant/components/p1_monitor/translations/pl.json create mode 100644 homeassistant/components/p1_monitor/translations/ru.json create mode 100644 homeassistant/components/p1_monitor/translations/zh-Hant.json diff --git a/homeassistant/components/accuweather/translations/he.json b/homeassistant/components/accuweather/translations/he.json index 219ce00872f..77c1e54f3e5 100644 --- a/homeassistant/components/accuweather/translations/he.json +++ b/homeassistant/components/accuweather/translations/he.json @@ -5,7 +5,8 @@ }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", - "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "requests_exceeded": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05d7\u05e8\u05d9\u05d2\u05d4 \u05de\u05de\u05e1\u05e4\u05e8 \u05d4\u05d1\u05e7\u05e9\u05d5\u05ea \u05d4\u05de\u05d5\u05ea\u05e8 \u05dc-API \u05e9\u05dc Accuweather. \u05e2\u05dc\u05d9\u05da \u05dc\u05d4\u05de\u05ea\u05d9\u05df \u05d0\u05d5 \u05dc\u05e9\u05e0\u05d5\u05ea \u05d0\u05ea \u05de\u05e4\u05ea\u05d7 \u05d4-API." }, "step": { "user": { @@ -15,6 +16,7 @@ "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", "name": "\u05e9\u05dd" }, + "description": "\u05d0\u05dd \u05d4\u05d9\u05e0\u05da \u05d6\u05e7\u05d5\u05e7 \u05dc\u05e2\u05d6\u05e8\u05d4 \u05e2\u05dd \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4, \u05d9\u05e9 \u05dc\u05e2\u05d9\u05d9\u05df \u05db\u05d0\u05df: https://www.home-assistant.io/integrations/accuweather/\n\n\u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05de\u05e1\u05d5\u05d9\u05de\u05d9\u05dd \u05d0\u05d9\u05e0\u05dd \u05d6\u05de\u05d9\u05e0\u05d9\u05dd \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc. \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea\u05da \u05dc\u05d4\u05e4\u05d5\u05da \u05d0\u05d5\u05ea\u05dd \u05dc\u05d6\u05de\u05d9\u05e0\u05d9\u05dd \u05d1\u05e8\u05d9\u05e9\u05d5\u05dd \u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea \u05dc\u05d0\u05d7\u05e8 \u05e7\u05d1\u05d9\u05e2\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1.\n\u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d4\u05d0\u05d5\u05d5\u05d9\u05e8 \u05d0\u05d9\u05e0\u05d4 \u05d6\u05de\u05d9\u05e0\u05d4 \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc. \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea\u05da \u05dc\u05d4\u05e4\u05d5\u05da \u05d0\u05d5\u05ea\u05d5 \u05dc\u05d6\u05de\u05d9\u05df \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1.", "title": "AccuWeather" } } @@ -22,6 +24,9 @@ "options": { "step": { "user": { + "data": { + "forecast": "\u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d4\u05d0\u05d5\u05d5\u05d9\u05e8" + }, "description": "\u05d1\u05e9\u05dc \u05de\u05d2\u05d1\u05dc\u05d5\u05ea \u05d4\u05d2\u05d9\u05e8\u05e1\u05d4 \u05d4\u05d7\u05d9\u05e0\u05de\u05d9\u05ea \u05e9\u05dc \u05de\u05e4\u05ea\u05d7 \u05d4-API \u05e9\u05dc AccuWeather, \u05db\u05d0\u05e9\u05e8 \u05ea\u05e4\u05e2\u05d9\u05dc \u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d0\u05d5\u05d5\u05d9\u05e8, \u05e2\u05d3\u05db\u05d5\u05e0\u05d9 \u05e0\u05ea\u05d5\u05e0\u05d9\u05dd \u05d9\u05d1\u05d5\u05e6\u05e2\u05d5 \u05db\u05dc 80 \u05d3\u05e7\u05d5\u05ea \u05d1\u05de\u05e7\u05d5\u05dd \u05db\u05dc 40 \u05d3\u05e7\u05d5\u05ea.", "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea AccuWeather" } @@ -29,7 +34,8 @@ }, "system_health": { "info": { - "can_reach_server": "\u05d4\u05e9\u05d2\u05ea \u05e9\u05e8\u05ea AccuWeather" + "can_reach_server": "\u05d4\u05e9\u05d2\u05ea \u05e9\u05e8\u05ea AccuWeather", + "remaining_requests": "\u05d4\u05d1\u05e7\u05e9\u05d5\u05ea \u05d4\u05e0\u05d5\u05ea\u05e8\u05d5\u05ea \u05de\u05d5\u05ea\u05e8\u05d5\u05ea" } } } \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.he.json b/homeassistant/components/accuweather/translations/sensor.he.json new file mode 100644 index 00000000000..08c637f1ce1 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.he.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "\u05d9\u05d5\u05e8\u05d3", + "rising": "\u05e2\u05d5\u05dc\u05d4", + "steady": "\u05d9\u05e6\u05d9\u05d1" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/he.json b/homeassistant/components/airtouch4/translations/he.json index 887a102c99a..25fe66938d7 100644 --- a/homeassistant/components/airtouch4/translations/he.json +++ b/homeassistant/components/airtouch4/translations/he.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/arcam_fmj/translations/he.json b/homeassistant/components/arcam_fmj/translations/he.json index c07b9af0c67..447d79eed28 100644 --- a/homeassistant/components/arcam_fmj/translations/he.json +++ b/homeassistant/components/arcam_fmj/translations/he.json @@ -5,12 +5,6 @@ "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, - "error": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "flow_title": "{host}", "step": { "confirm": { diff --git a/homeassistant/components/directv/translations/he.json b/homeassistant/components/directv/translations/he.json index bc28ff4eba5..f057c4e4629 100644 --- a/homeassistant/components/directv/translations/he.json +++ b/homeassistant/components/directv/translations/he.json @@ -9,14 +9,6 @@ }, "flow_title": "{name}", "step": { - "ssdp_confirm": { - "data": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - } - }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/fjaraskupan/translations/pl.json b/homeassistant/components/fjaraskupan/translations/pl.json new file mode 100644 index 00000000000..65fcb66af6d --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/pl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/ru.json b/homeassistant/components/fjaraskupan/translations/ru.json new file mode 100644 index 00000000000..5d165713eb1 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/ru.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + }, + "step": { + "confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/zh-Hant.json b/homeassistant/components/fjaraskupan/translations/zh-Hant.json new file mode 100644 index 00000000000..3312cea3576 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/zh-Hant.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Fj\u00e4r\u00e5skupan\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/translations/he.json b/homeassistant/components/hangouts/translations/he.json index fa756c49ac6..9f0e3b48a62 100644 --- a/homeassistant/components/hangouts/translations/he.json +++ b/homeassistant/components/hangouts/translations/he.json @@ -14,7 +14,6 @@ "data": { "2fa": "\u05e7\u05d5\u05d3 \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9" }, - "description": "\u05e8\u05d9\u05e7", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9" }, "user": { @@ -22,7 +21,6 @@ "email": "\u05d3\u05d5\u05d0\"\u05dc", "password": "\u05e1\u05d9\u05e1\u05de\u05d4" }, - "description": "\u05e8\u05d9\u05e7", "title": "\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05dc- Google Hangouts" } } diff --git a/homeassistant/components/konnected/translations/he.json b/homeassistant/components/konnected/translations/he.json index 0a436bc2d3c..5bfc5453409 100644 --- a/homeassistant/components/konnected/translations/he.json +++ b/homeassistant/components/konnected/translations/he.json @@ -22,12 +22,6 @@ } }, "options": { - "error": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "step": { "options_binary": { "data": { diff --git a/homeassistant/components/kraken/translations/he.json b/homeassistant/components/kraken/translations/he.json index 2be0837c966..460ab83938c 100644 --- a/homeassistant/components/kraken/translations/he.json +++ b/homeassistant/components/kraken/translations/he.json @@ -3,20 +3,8 @@ "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, - "error": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "step": { "user": { - "data": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" } } diff --git a/homeassistant/components/netatmo/translations/he.json b/homeassistant/components/netatmo/translations/he.json index 54571f698fe..32d7ecac5f0 100644 --- a/homeassistant/components/netatmo/translations/he.json +++ b/homeassistant/components/netatmo/translations/he.json @@ -31,8 +31,15 @@ "step": { "public_weather": { "data": { + "mode": "\u05d7\u05d9\u05e9\u05d5\u05d1", "show_on_map": "\u05d4\u05e6\u05d2 \u05d1\u05de\u05e4\u05d4" } + }, + "public_weather_areas": { + "data": { + "new_area": "\u05e9\u05dd \u05d0\u05d6\u05d5\u05e8", + "weather_areas": "\u05d0\u05d6\u05d5\u05e8\u05d9 \u05de\u05d6\u05d2 \u05d0\u05d5\u05d5\u05d9\u05e8" + } } } } diff --git a/homeassistant/components/p1_monitor/translations/pl.json b/homeassistant/components/p1_monitor/translations/pl.json new file mode 100644 index 00000000000..5aacfb63336 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/pl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "name": "Nazwa" + }, + "description": "Skonfiguruj P1 Monitor, aby zintegrowa\u0107 go z Home Assistantem." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/ru.json b/homeassistant/components/p1_monitor/translations/ru.json new file mode 100644 index 00000000000..661cc1c8968 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/ru.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 P1 Monitor." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/zh-Hant.json b/homeassistant/components/p1_monitor/translations/zh-Hant.json new file mode 100644 index 00000000000..fafb7f9b7c2 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31" + }, + "description": "\u8a2d\u5b9a P1 Monitor \u4ee5\u6574\u5408\u81f3 Home Assistant\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/pl.json b/homeassistant/components/rainforest_eagle/translations/pl.json index acbbaf044d3..8d12ee56c27 100644 --- a/homeassistant/components/rainforest_eagle/translations/pl.json +++ b/homeassistant/components/rainforest_eagle/translations/pl.json @@ -12,6 +12,7 @@ "user": { "data": { "cloud_id": "Identyfikator chmury", + "host": "Nazwa hosta lub adres IP", "install_code": "Kod instalacji" } } diff --git a/homeassistant/components/rainforest_eagle/translations/ru.json b/homeassistant/components/rainforest_eagle/translations/ru.json index e731e89fa82..fa9310eb0a8 100644 --- a/homeassistant/components/rainforest_eagle/translations/ru.json +++ b/homeassistant/components/rainforest_eagle/translations/ru.json @@ -12,6 +12,7 @@ "user": { "data": { "cloud_id": "Cloud ID", + "host": "\u0425\u043e\u0441\u0442", "install_code": "\u041a\u043e\u0434 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438" } } diff --git a/homeassistant/components/rainforest_eagle/translations/zh-Hant.json b/homeassistant/components/rainforest_eagle/translations/zh-Hant.json index f306750fa29..408792dd291 100644 --- a/homeassistant/components/rainforest_eagle/translations/zh-Hant.json +++ b/homeassistant/components/rainforest_eagle/translations/zh-Hant.json @@ -12,6 +12,7 @@ "user": { "data": { "cloud_id": "Cloud ID", + "host": "\u4e3b\u6a5f\u7aef", "install_code": "\u5b89\u88dd\u78bc" } } diff --git a/homeassistant/components/roku/translations/he.json b/homeassistant/components/roku/translations/he.json index 12dc4bb482b..41d59c29fd8 100644 --- a/homeassistant/components/roku/translations/he.json +++ b/homeassistant/components/roku/translations/he.json @@ -10,14 +10,6 @@ }, "flow_title": "{name}", "step": { - "ssdp_confirm": { - "data": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - } - }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/roomba/translations/ca.json b/homeassistant/components/roomba/translations/ca.json index f237967b8a4..ba23e30e3f0 100644 --- a/homeassistant/components/roomba/translations/ca.json +++ b/homeassistant/components/roomba/translations/ca.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Amfitri\u00f3" }, - "description": "No s'ha descobert cap Roomba ni cap Braava a la teva xarxa. El BLID \u00e9s la part del nom d'amfitri\u00f3 del dispositiu despr\u00e9s de `iRobot-` o `Roomba-`. Segueix els passos de la documentaci\u00f3 seg\u00fcent: {auth_help_url}", + "description": "No s'ha descobert cap Roomba o Braava a la teva xarxa.", "title": "Connecta't al dispositiu manualment" }, "user": { diff --git a/homeassistant/components/roomba/translations/de.json b/homeassistant/components/roomba/translations/de.json index bfc98069881..ae4ef7d9dc7 100644 --- a/homeassistant/components/roomba/translations/de.json +++ b/homeassistant/components/roomba/translations/de.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Host" }, - "description": "Es wurde kein Roomba oder Braava in deinem Netzwerk entdeckt. Die BLID ist der Teil des Ger\u00e4te-Hostnamens nach `iRobot-` oder `Roomba-`. Bitte folge den Schritten in der Dokumentation unter: {auth_help_url}", + "description": "Es wurde kein Roomba oder Braava in deinem Netzwerk entdeckt.", "title": "Manuell mit dem Ger\u00e4t verbinden" }, "user": { diff --git a/homeassistant/components/roomba/translations/en.json b/homeassistant/components/roomba/translations/en.json index 8dd2f92183b..facd127985a 100644 --- a/homeassistant/components/roomba/translations/en.json +++ b/homeassistant/components/roomba/translations/en.json @@ -31,6 +31,7 @@ }, "manual": { "data": { + "blid": "BLID", "host": "Host" }, "description": "No Roomba or Braava have been discovered on your network.", diff --git a/homeassistant/components/roomba/translations/et.json b/homeassistant/components/roomba/translations/et.json index 7a8f33ebf57..43715399ef1 100644 --- a/homeassistant/components/roomba/translations/et.json +++ b/homeassistant/components/roomba/translations/et.json @@ -34,7 +34,7 @@ "blid": "", "host": "Host" }, - "description": "V\u00f5rgus ei tuvastatud \u00fchtegi Roomba ega Braava seadet. BLID on seadme hostinime osa p\u00e4rast 'iRobot-` v\u00f5i 'Roomba-'. J\u00e4rgi dokumentatsioonis toodud juhiseid: {auth_help_url}", + "description": "V\u00f5rgus ei tuvastatud \u00fchtegi Roomba ega Braava seadet.", "title": "\u00dchenda seadmega k\u00e4sitsi" }, "user": { diff --git a/homeassistant/components/roomba/translations/pl.json b/homeassistant/components/roomba/translations/pl.json index 118d5a8ece7..5f6a3a23333 100644 --- a/homeassistant/components/roomba/translations/pl.json +++ b/homeassistant/components/roomba/translations/pl.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Nazwa hosta lub adres IP" }, - "description": "W Twojej sieci nie wykryto urz\u0105dzenia Roomba ani Braava. BLID to cz\u0119\u015b\u0107 nazwy hosta urz\u0105dzenia po `iRobot-` lub 'Roomba-'. Post\u0119puj zgodnie z instrukcjami podanymi w dokumentacji pod adresem: {auth_help_url}", + "description": "W Twojej sieci nie wykryto urz\u0105dzenia Roomba ani Braava.", "title": "R\u0119czne po\u0142\u0105czenie z urz\u0105dzeniem" }, "user": { diff --git a/homeassistant/components/roomba/translations/ru.json b/homeassistant/components/roomba/translations/ru.json index f61ecde08ec..a8b31297f04 100644 --- a/homeassistant/components/roomba/translations/ru.json +++ b/homeassistant/components/roomba/translations/ru.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "\u0425\u043e\u0441\u0442" }, - "description": "\u0412 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 \u0441\u0435\u0442\u0438 \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u043f\u044b\u043b\u0435\u0441\u043e\u0441\u043e\u0432 Roomba \u0438\u043b\u0438 Braava. BLID - \u044d\u0442\u043e \u0447\u0430\u0441\u0442\u044c \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0433\u043e \u0438\u043c\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0430\u044f \u043f\u043e\u0441\u043b\u0435 `iRobot-` \u0438\u043b\u0438 `Roomba-`. \u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439: {auth_help_url}.", + "description": "\u0412 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 \u0441\u0435\u0442\u0438 \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 Roomba \u0438\u043b\u0438 Braava.", "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 \u0432\u0440\u0443\u0447\u043d\u0443\u044e" }, "user": { diff --git a/homeassistant/components/roomba/translations/zh-Hant.json b/homeassistant/components/roomba/translations/zh-Hant.json index 4a5891d896e..c17607e8be4 100644 --- a/homeassistant/components/roomba/translations/zh-Hant.json +++ b/homeassistant/components/roomba/translations/zh-Hant.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "\u4e3b\u6a5f\u7aef" }, - "description": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Roomba \u6216 Braava\u3002BLID \u88dd\u7f6e\u65bc\u4e3b\u6a5f\u7aef\u7684\u90e8\u5206\u540d\u7a31\u70ba `iRobot-` \u6216 `Roomba-` \u958b\u982d\u3002\u8acb\u53c3\u95b1\u4ee5\u4e0b\u6587\u4ef6\u7684\u6b65\u9a5f\u9032\u884c\u8a2d\u5b9a\uff1a{auth_help_url}", + "description": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Roomba \u6216 Braava\u3002", "title": "\u624b\u52d5\u9023\u7dda\u81f3\u88dd\u7f6e" }, "user": { diff --git a/homeassistant/components/tellduslive/translations/he.json b/homeassistant/components/tellduslive/translations/he.json index db5a0aad8d9..d19fa6d3d31 100644 --- a/homeassistant/components/tellduslive/translations/he.json +++ b/homeassistant/components/tellduslive/translations/he.json @@ -13,7 +13,6 @@ "data": { "host": "\u05de\u05d0\u05e8\u05d7" }, - "description": "\u05e8\u05d9\u05e7", "title": "\u05d1\u05d7\u05e8 \u05e0\u05e7\u05d5\u05d3\u05ea \u05e7\u05e6\u05d4." } } diff --git a/homeassistant/components/unifi/translations/he.json b/homeassistant/components/unifi/translations/he.json index 4fe52a3cf8b..83c34cb9c77 100644 --- a/homeassistant/components/unifi/translations/he.json +++ b/homeassistant/components/unifi/translations/he.json @@ -33,14 +33,6 @@ "track_devices": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9 \u05e8\u05e9\u05ea (\u05d4\u05ea\u05e7\u05e0\u05d9 Ubiquiti)" } }, - "init": { - "data": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - } - }, "simple_options": { "data": { "block_client": "\u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05de\u05d1\u05d5\u05e7\u05e8\u05d9\u05dd \u05e9\u05dc \u05d2\u05d9\u05e9\u05d4 \u05dc\u05e8\u05e9\u05ea", diff --git a/homeassistant/components/upnp/translations/he.json b/homeassistant/components/upnp/translations/he.json index e9aba0a7a58..6395b5f029f 100644 --- a/homeassistant/components/upnp/translations/he.json +++ b/homeassistant/components/upnp/translations/he.json @@ -4,20 +4,8 @@ "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea" }, - "error": { - "many": "", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "flow_title": "{name}", "step": { - "init": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "user": { "data": { "unique_id": "\u05d4\u05ea\u05e7\u05df", diff --git a/homeassistant/components/xiaomi_miio/translations/he.json b/homeassistant/components/xiaomi_miio/translations/he.json index e3bf59f9459..69d47597c5f 100644 --- a/homeassistant/components/xiaomi_miio/translations/he.json +++ b/homeassistant/components/xiaomi_miio/translations/he.json @@ -41,7 +41,7 @@ "name": "\u05e9\u05dd \u05d4\u05d4\u05ea\u05e7\u05df", "token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df API" }, - "description": "\u05d0\u05ea\u05d4 \u05d6\u05e7\u05d5\u05e7 \u05dc-32 \u05ea\u05d5\u05d5\u05d9 \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05df API , \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token \u05dc\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea. \u05e9\u05d9\u05dd \u05dc\u05d1, \u05db\u05d9 \u05d0\u05e1\u05d9\u05de\u05d5\u05df API \u05e9\u05d5\u05e0\u05d4 \u05de\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05e9\u05de\u05e9 \u05d0\u05ea \u05e9\u05d9\u05dc\u05d5\u05d1 Xiaomi Aqara.", + "description": "\u05d0\u05ea\u05d4 \u05d6\u05e7\u05d5\u05e7 \u05dc-32 \u05ea\u05d5\u05d5\u05d9 \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05df API , \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u05dc\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea. \u05e9\u05d9\u05dd \u05dc\u05d1, \u05db\u05d9 \u05d0\u05e1\u05d9\u05de\u05d5\u05df API \u05e9\u05d5\u05e0\u05d4 \u05de\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05e9\u05de\u05e9 \u05d0\u05ea \u05e9\u05d9\u05dc\u05d5\u05d1 Xiaomi Aqara.", "title": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9" }, "gateway": { diff --git a/homeassistant/components/yeelight/translations/he.json b/homeassistant/components/yeelight/translations/he.json index adfd4d904ce..535aa833a7f 100644 --- a/homeassistant/components/yeelight/translations/he.json +++ b/homeassistant/components/yeelight/translations/he.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/pl.json b/homeassistant/components/yeelight/translations/pl.json index 9ea693fffcc..a11706dfd64 100644 --- a/homeassistant/components/yeelight/translations/pl.json +++ b/homeassistant/components/yeelight/translations/pl.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Czy chcesz skonfigurowa\u0107 {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/ru.json b/homeassistant/components/yeelight/translations/ru.json index cbeaad534b4..34e3c4d2c8a 100644 --- a/homeassistant/components/yeelight/translations/ru.json +++ b/homeassistant/components/yeelight/translations/ru.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/zh-Hant.json b/homeassistant/components/yeelight/translations/zh-Hant.json index b5df81beafd..c0c83c213b0 100644 --- a/homeassistant/components/yeelight/translations/zh-Hant.json +++ b/homeassistant/components/yeelight/translations/zh-Hant.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "\u662f\u5426\u8981\u8a2d\u5b9a {model} ({host})\uff1f" diff --git a/homeassistant/components/zha/translations/pl.json b/homeassistant/components/zha/translations/pl.json index 8c726fc349f..40a5257335f 100644 --- a/homeassistant/components/zha/translations/pl.json +++ b/homeassistant/components/zha/translations/pl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "To urz\u0105dzenie nie jest urz\u0105dzeniem zha", "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name}?" + }, "pick_radio": { "data": { "radio_type": "Typ radia" diff --git a/homeassistant/components/zha/translations/ru.json b/homeassistant/components/zha/translations/ru.json index 8644cdbc03b..17d95dbd7e8 100644 --- a/homeassistant/components/zha/translations/ru.json +++ b/homeassistant/components/zha/translations/ru.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 ZHA.", "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name}?" + }, "pick_radio": { "data": { "radio_type": "\u0422\u0438\u043f \u0440\u0430\u0434\u0438\u043e\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" diff --git a/homeassistant/components/zha/translations/zh-Hant.json b/homeassistant/components/zha/translations/zh-Hant.json index d219e311791..e08adf98527 100644 --- a/homeassistant/components/zha/translations/zh-Hant.json +++ b/homeassistant/components/zha/translations/zh-Hant.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e ZHA \u88dd\u7f6e", "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f" + }, "pick_radio": { "data": { "radio_type": "\u7121\u7dda\u96fb\u985e\u578b" diff --git a/homeassistant/components/zwave_js/translations/he.json b/homeassistant/components/zwave_js/translations/he.json index 7c1cab98854..fae03188b81 100644 --- a/homeassistant/components/zwave_js/translations/he.json +++ b/homeassistant/components/zwave_js/translations/he.json @@ -9,6 +9,7 @@ "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, + "flow_title": "{name}", "step": { "configure_addon": { "data": { diff --git a/homeassistant/components/zwave_js/translations/pl.json b/homeassistant/components/zwave_js/translations/pl.json index cc000691d25..bd842cb1359 100644 --- a/homeassistant/components/zwave_js/translations/pl.json +++ b/homeassistant/components/zwave_js/translations/pl.json @@ -8,7 +8,9 @@ "addon_start_failed": "Nie uda\u0142o si\u0119 uruchomi\u0107 dodatku Z-Wave JS.", "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", "already_in_progress": "Konfiguracja jest ju\u017c w toku", - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "discovery_requires_supervisor": "Wykrywanie wymaga Supervisora.", + "not_zwave_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Z-Wave." }, "error": { "addon_start_failed": "Nie uda\u0142o si\u0119 uruchomi\u0107 dodatku Z-Wave JS. Sprawd\u017a konfiguracj\u0119", @@ -16,6 +18,7 @@ "invalid_ws_url": "Nieprawid\u0142owy URL websocket", "unknown": "Nieoczekiwany b\u0142\u0105d" }, + "flow_title": "{name}", "progress": { "install_addon": "Poczekaj, a\u017c zako\u0144czy si\u0119 instalacja dodatku Z-Wave JS. Mo\u017ce to zaj\u0105\u0107 kilka minut.", "start_addon": "Poczekaj, a\u017c zako\u0144czy si\u0119 uruchamianie dodatku Z-Wave JS. Mo\u017ce to zaj\u0105\u0107 chwil\u0119." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Dodatek Z-Wave JS uruchamia si\u0119..." + }, + "usb_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name} z dodatkiem Z-Wave JS?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Podstawowe wydarzenie CC na {subtype}", "event.value_notification.central_scene": "Akcja sceny centralnej na {subtype}", "event.value_notification.scene_activation": "Aktywacja sceny na {subtype}", - "state.node_status": "Zmieni\u0142 si\u0119 stan w\u0119z\u0142a" + "state.node_status": "Zmieni\u0142 si\u0119 stan w\u0119z\u0142a", + "zwave_js.value_updated.config_parameter": "zmieni si\u0119 warto\u015b\u0107 parametru konfiguracji {subtype}", + "zwave_js.value_updated.value": "zmieni si\u0119 warto\u015b\u0107 na Z-Wave JS" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/ru.json b/homeassistant/components/zwave_js/translations/ru.json index 03529769828..994bfb54cfc 100644 --- a/homeassistant/components/zwave_js/translations/ru.json +++ b/homeassistant/components/zwave_js/translations/ru.json @@ -8,7 +8,9 @@ "addon_start_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS.", "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "discovery_requires_supervisor": "\u0414\u043b\u044f \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f Supervisor.", + "not_zwave_device": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Z-Wave." }, "error": { "addon_start_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e.", @@ -16,6 +18,7 @@ "invalid_ws_url": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, + "flow_title": "{name}", "progress": { "install_addon": "\u041f\u043e\u0434\u043e\u0436\u0434\u0438\u0442\u0435, \u043f\u043e\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0441\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430 \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043d\u044f\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043c\u0438\u043d\u0443\u0442.", "start_addon": "\u041f\u043e\u0434\u043e\u0436\u0434\u0438\u0442\u0435, \u043f\u043e\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0441\u044f \u0437\u0430\u043f\u0443\u0441\u043a \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043d\u044f\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0441\u0435\u043a\u0443\u043d\u0434." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "\u0414\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS \u0437\u0430\u043f\u0443\u0441\u043a\u0430\u0435\u0442\u0441\u044f" + }, + "usb_confirm": { + "description": "\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name} \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "\u0411\u0430\u0437\u043e\u0432\u043e\u0435 \u0441\u043e\u0431\u044b\u0442\u0438\u0435 CC \u043d\u0430 {subtype}", "event.value_notification.central_scene": "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u0435 \"\u0426\u0435\u043d\u0442\u0440\u0430\u043b\u044c\u043d\u0430\u044f \u0441\u0446\u0435\u043d\u0430\" \u043d\u0430 {subtype}", "event.value_notification.scene_activation": "\u0410\u043a\u0442\u0438\u0432\u0430\u0446\u0438\u044f \u0441\u0446\u0435\u043d\u044b \u043d\u0430 {subtype}", - "state.node_status": "\u0421\u0442\u0430\u0442\u0443\u0441 \u0443\u0437\u043b\u0430 \u0438\u0437\u043c\u0435\u043d\u0435\u043d" + "state.node_status": "\u0421\u0442\u0430\u0442\u0443\u0441 \u0443\u0437\u043b\u0430 \u0438\u0437\u043c\u0435\u043d\u0435\u043d", + "zwave_js.value_updated.config_parameter": "\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 {subtype}", + "zwave_js.value_updated.value": "\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f Z-Wave JS Value" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/zh-Hant.json b/homeassistant/components/zwave_js/translations/zh-Hant.json index 8b1c4caff1f..e9038ed9a00 100644 --- a/homeassistant/components/zwave_js/translations/zh-Hant.json +++ b/homeassistant/components/zwave_js/translations/zh-Hant.json @@ -8,7 +8,9 @@ "addon_start_failed": "Z-Wave JS \u9644\u52a0\u5143\u4ef6\u555f\u59cb\u5931\u6557\u3002", "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", - "cannot_connect": "\u9023\u7dda\u5931\u6557" + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "discovery_requires_supervisor": "\u63a2\u7d22\u529f\u80fd\u9700\u8981 Supervisor \u6b0a\u9650\u3002", + "not_zwave_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Z-Wave \u88dd\u7f6e" }, "error": { "addon_start_failed": "Z-Wave JS \u9644\u52a0\u5143\u4ef6\u555f\u52d5\u5931\u6557\uff0c\u8acb\u6aa2\u67e5\u8a2d\u5b9a\u3002", @@ -16,6 +18,7 @@ "invalid_ws_url": "Websocket URL \u7121\u6548", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, + "flow_title": "{name}", "progress": { "install_addon": "\u8acb\u7a0d\u7b49 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u5b89\u88dd\u5b8c\u6210\uff0c\u53ef\u80fd\u6703\u9700\u8981\u5e7e\u5206\u9418\u3002", "start_addon": "\u8acb\u7a0d\u7b49 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u555f\u59cb\u5b8c\u6210\uff0c\u53ef\u80fd\u6703\u9700\u8981\u5e7e\u5206\u9418\u3002" @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Z-Wave JS \u9644\u52a0\u5143\u4ef6\u555f\u59cb\u4e2d\u3002" + }, + "usb_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u540d\u70ba {name} \u7684 Z-Wave JS \u9644\u52a0\u5143\u4ef6\uff1f" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "{subtype} \u4e0a\u57fa\u672c CC \u4e8b\u4ef6", "event.value_notification.central_scene": "{subtype} \u4e0a\u6838\u5fc3\u5834\u666f\u52d5\u4f5c", "event.value_notification.scene_activation": "{subtype} \u4e0a\u5834\u666f\u5df2\u555f\u52d5", - "state.node_status": "\u7bc0\u9ede\u72c0\u614b\u5df2\u6539\u8b8a" + "state.node_status": "\u7bc0\u9ede\u72c0\u614b\u5df2\u6539\u8b8a", + "zwave_js.value_updated.config_parameter": "\u8a2d\u5b9a\u53c3\u6578 {subtype} \u6578\u503c\u8b8a\u66f4", + "zwave_js.value_updated.value": "Z-Wave JS \u503c\u4e0a\u7684\u6578\u503c\u8b8a\u66f4" } }, "options": { From 5f5c8ade4150981e909db9461cbe620805d5b750 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 22 Aug 2021 20:43:59 -0400 Subject: [PATCH 631/903] Fix validation for zwave_js device trigger and condition (#54974) --- .../components/zwave_js/device_condition.py | 62 +++++--- .../components/zwave_js/device_trigger.py | 147 ++++++++++-------- homeassistant/components/zwave_js/helpers.py | 79 +++++++++- .../zwave_js/test_device_condition.py | 28 ++++ .../zwave_js/test_device_trigger.py | 58 +++---- 5 files changed, 260 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index 2eac4b7d7b0..f17654f184a 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -4,9 +4,12 @@ from __future__ import annotations from typing import cast import voluptuous as vol -from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const import CommandClass from zwave_js_server.model.value import ConfigurationValue +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.const import CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -22,7 +25,14 @@ from .const import ( ATTR_PROPERTY_KEY, ATTR_VALUE, ) -from .helpers import async_get_node_from_device_id, get_zwave_value_from_config +from .helpers import ( + async_get_node_from_device_id, + async_is_device_config_entry_not_loaded, + check_type_schema_map, + get_value_state_schema, + get_zwave_value_from_config, + remove_keys_with_empty_values, +) CONF_SUBTYPE = "subtype" CONF_VALUE_ID = "value_id" @@ -67,10 +77,21 @@ VALUE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( } ) -CONDITION_SCHEMA = vol.Any( - NODE_STATUS_CONDITION_SCHEMA, - CONFIG_PARAMETER_CONDITION_SCHEMA, - VALUE_CONDITION_SCHEMA, +TYPE_SCHEMA_MAP = { + NODE_STATUS_TYPE: NODE_STATUS_CONDITION_SCHEMA, + CONFIG_PARAMETER_TYPE: CONFIG_PARAMETER_CONDITION_SCHEMA, + VALUE_TYPE: VALUE_CONDITION_SCHEMA, +} + + +CONDITION_TYPE_SCHEMA = vol.Schema( + {vol.Required(CONF_TYPE): vol.In(TYPE_SCHEMA_MAP)}, extra=vol.ALLOW_EXTRA +) + +CONDITION_SCHEMA = vol.All( + remove_keys_with_empty_values, + CONDITION_TYPE_SCHEMA, + check_type_schema_map(TYPE_SCHEMA_MAP), ) @@ -79,9 +100,18 @@ async def async_validate_condition_config( ) -> ConfigType: """Validate config.""" config = CONDITION_SCHEMA(config) + + # We return early if the config entry for this device is not ready because we can't + # validate the value without knowing the state of the device + if async_is_device_config_entry_not_loaded(hass, config[CONF_DEVICE_ID]): + return config + if config[CONF_TYPE] == VALUE_TYPE: - node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) - get_zwave_value_from_config(node, config) + try: + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + get_zwave_value_from_config(node, config) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig(err.msg) from err return config @@ -174,20 +204,8 @@ async def async_get_condition_capabilities( # Add additional fields to the automation trigger UI if config[CONF_TYPE] == CONFIG_PARAMETER_TYPE: value_id = config[CONF_VALUE_ID] - config_value = cast(ConfigurationValue, node.values[value_id]) - min_ = config_value.metadata.min - max_ = config_value.metadata.max - - if config_value.configuration_value_type in ( - ConfigurationValueType.RANGE, - ConfigurationValueType.MANUAL_ENTRY, - ): - value_schema = vol.Range(min=min_, max=max_) - elif config_value.configuration_value_type == ConfigurationValueType.ENUMERATED: - value_schema = vol.In( - {int(k): v for k, v in config_value.metadata.states.items()} - ) - else: + value_schema = get_value_state_schema(node.values[value_id]) + if not value_schema: return {} return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 6fab91c867d..7ed13ce2b98 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -4,10 +4,13 @@ from __future__ import annotations from typing import Any import voluptuous as vol -from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const import CommandClass from homeassistant.components.automation import AutomationActionType from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.homeassistant.triggers import event, state from homeassistant.const import ( CONF_DEVICE_ID, @@ -46,12 +49,20 @@ from .const import ( from .helpers import ( async_get_node_from_device_id, async_get_node_status_sensor_entity_id, + async_is_device_config_entry_not_loaded, + check_type_schema_map, + copy_available_params, + get_value_state_schema, get_zwave_value_from_config, + remove_keys_with_empty_values, +) +from .triggers.value_updated import ( + ATTR_FROM, + ATTR_TO, + PLATFORM_TYPE as VALUE_UPDATED_PLATFORM_TYPE, ) -from .triggers.value_updated import ATTR_FROM, ATTR_TO CONF_SUBTYPE = "subtype" -CONF_VALUE_ID = "value_id" # Trigger types ENTRY_CONTROL_NOTIFICATION = "event.notification.entry_control" @@ -59,8 +70,8 @@ NOTIFICATION_NOTIFICATION = "event.notification.notification" BASIC_VALUE_NOTIFICATION = "event.value_notification.basic" CENTRAL_SCENE_VALUE_NOTIFICATION = "event.value_notification.central_scene" SCENE_ACTIVATION_VALUE_NOTIFICATION = "event.value_notification.scene_activation" -CONFIG_PARAMETER_VALUE_UPDATED = f"{DOMAIN}.value_updated.config_parameter" -VALUE_VALUE_UPDATED = f"{DOMAIN}.value_updated.value" +CONFIG_PARAMETER_VALUE_UPDATED = f"{VALUE_UPDATED_PLATFORM_TYPE}.config_parameter" +VALUE_VALUE_UPDATED = f"{VALUE_UPDATED_PLATFORM_TYPE}.value" NODE_STATUS = "state.node_status" VALUE_SCHEMA = vol.Any( @@ -71,6 +82,7 @@ VALUE_SCHEMA = vol.Any( cv.string, ) + NOTIFICATION_EVENT_CC_MAPPINGS = ( (ENTRY_CONTROL_NOTIFICATION, CommandClass.ENTRY_CONTROL), (NOTIFICATION_NOTIFICATION, CommandClass.NOTIFICATION), @@ -104,7 +116,7 @@ ENTRY_CONTROL_NOTIFICATION_SCHEMA = BASE_EVENT_SCHEMA.extend( BASE_VALUE_NOTIFICATION_EVENT_SCHEMA = BASE_EVENT_SCHEMA.extend( { vol.Required(ATTR_PROPERTY): vol.Any(int, str), - vol.Required(ATTR_PROPERTY_KEY): vol.Any(None, int, str), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(int, str), vol.Required(ATTR_ENDPOINT): vol.Coerce(int), vol.Optional(ATTR_VALUE): vol.Coerce(int), vol.Required(CONF_SUBTYPE): cv.string, @@ -174,17 +186,61 @@ VALUE_VALUE_UPDATED_SCHEMA = BASE_VALUE_UPDATED_SCHEMA.extend( } ) -TRIGGER_SCHEMA = vol.Any( - ENTRY_CONTROL_NOTIFICATION_SCHEMA, - NOTIFICATION_NOTIFICATION_SCHEMA, - BASIC_VALUE_NOTIFICATION_SCHEMA, - CENTRAL_SCENE_VALUE_NOTIFICATION_SCHEMA, - SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA, - CONFIG_PARAMETER_VALUE_UPDATED_SCHEMA, - VALUE_VALUE_UPDATED_SCHEMA, - NODE_STATUS_SCHEMA, +TYPE_SCHEMA_MAP = { + ENTRY_CONTROL_NOTIFICATION: ENTRY_CONTROL_NOTIFICATION_SCHEMA, + NOTIFICATION_NOTIFICATION: NOTIFICATION_NOTIFICATION_SCHEMA, + BASIC_VALUE_NOTIFICATION: BASIC_VALUE_NOTIFICATION_SCHEMA, + CENTRAL_SCENE_VALUE_NOTIFICATION: CENTRAL_SCENE_VALUE_NOTIFICATION_SCHEMA, + SCENE_ACTIVATION_VALUE_NOTIFICATION: SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA, + CONFIG_PARAMETER_VALUE_UPDATED: CONFIG_PARAMETER_VALUE_UPDATED_SCHEMA, + VALUE_VALUE_UPDATED: VALUE_VALUE_UPDATED_SCHEMA, + NODE_STATUS: NODE_STATUS_SCHEMA, +} + + +TRIGGER_TYPE_SCHEMA = vol.Schema( + {vol.Required(CONF_TYPE): vol.In(TYPE_SCHEMA_MAP)}, extra=vol.ALLOW_EXTRA ) +TRIGGER_SCHEMA = vol.All( + remove_keys_with_empty_values, + TRIGGER_TYPE_SCHEMA, + check_type_schema_map(TYPE_SCHEMA_MAP), +) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + # We return early if the config entry for this device is not ready because we can't + # validate the value without knowing the state of the device + if async_is_device_config_entry_not_loaded(hass, config[CONF_DEVICE_ID]): + return config + + trigger_type = config[CONF_TYPE] + if get_trigger_platform_from_type(trigger_type) == VALUE_UPDATED_PLATFORM_TYPE: + try: + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + get_zwave_value_from_config(node, config) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig(err.msg) from err + + return config + + +def get_trigger_platform_from_type(trigger_type: str) -> str: + """Get trigger platform from Z-Wave JS trigger type.""" + trigger_split = trigger_type.split(".") + # Our convention for trigger types is to have the trigger type at the beginning + # delimited by a `.`. For zwave_js triggers, there is a `.` in the name + trigger_platform = trigger_split[0] + if trigger_platform == DOMAIN: + return ".".join(trigger_split[:2]) + return trigger_platform + async def async_get_triggers( hass: HomeAssistant, device_id: str @@ -298,15 +354,6 @@ async def async_get_triggers( return triggers -def copy_available_params( - input_dict: dict, output_dict: dict, params: list[str] -) -> None: - """Copy available params from input into output.""" - for param in params: - if (val := input_dict.get(param)) not in ("", None): - output_dict[param] = val - - async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, @@ -315,12 +362,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] - trigger_split = trigger_type.split(".") - # Our convention for trigger types is to have the trigger type at the beginning - # delimited by a `.`. For zwave_js triggers, there is a `.` in the name - trigger_platform = trigger_split[0] - if trigger_platform == DOMAIN: - trigger_platform = ".".join(trigger_split[:2]) + trigger_platform = get_trigger_platform_from_type(trigger_type) # Take input data from automation trigger UI and add it to the trigger we are # attaching to @@ -379,14 +421,7 @@ async def async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) - if trigger_platform == f"{DOMAIN}.value_updated": - # Try to get the value to make sure the value ID is valid - try: - node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) - get_zwave_value_from_config(node, config) - except (ValueError, vol.Invalid) as err: - raise HomeAssistantError("Invalid value specified") from err - + if trigger_platform == VALUE_UPDATED_PLATFORM_TYPE: zwave_js_config = { state.CONF_PLATFORM: trigger_platform, CONF_DEVICE_ID: config[CONF_DEVICE_ID], @@ -420,9 +455,7 @@ async def async_get_trigger_capabilities( trigger_type = config[CONF_TYPE] node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) - value = ( - get_zwave_value_from_config(node, config) if ATTR_PROPERTY in config else None - ) + # Add additional fields to the automation trigger UI if trigger_type == NOTIFICATION_NOTIFICATION: return { @@ -462,33 +495,23 @@ async def async_get_trigger_capabilities( CENTRAL_SCENE_VALUE_NOTIFICATION, SCENE_ACTIVATION_VALUE_NOTIFICATION, ): - if value.metadata.states: - value_schema = vol.In({int(k): v for k, v in value.metadata.states.items()}) - else: - value_schema = vol.All( - vol.Coerce(int), - vol.Range(min=value.metadata.min, max=value.metadata.max), - ) + value_schema = get_value_state_schema(get_zwave_value_from_config(node, config)) + + # We should never get here, but just in case we should add a guard + if not value_schema: + return {} return {"extra_fields": vol.Schema({vol.Optional(ATTR_VALUE): value_schema})} if trigger_type == CONFIG_PARAMETER_VALUE_UPDATED: - # We can be more deliberate about the config parameter schema here because - # there are a limited number of types - if value.configuration_value_type == ConfigurationValueType.UNDEFINED: + value_schema = get_value_state_schema(get_zwave_value_from_config(node, config)) + if not value_schema: return {} - if value.configuration_value_type == ConfigurationValueType.ENUMERATED: - value_schema = vol.In({int(k): v for k, v in value.metadata.states.items()}) - else: - value_schema = vol.All( - vol.Coerce(int), - vol.Range(min=value.metadata.min, max=value.metadata.max), - ) return { "extra_fields": vol.Schema( { - vol.Optional(state.CONF_FROM): value_schema, - vol.Optional(state.CONF_TO): value_schema, + vol.Optional(ATTR_FROM): value_schema, + vol.Optional(ATTR_TO): value_schema, } ) } @@ -509,8 +532,8 @@ async def async_get_trigger_capabilities( vol.Required(ATTR_PROPERTY): cv.string, vol.Optional(ATTR_PROPERTY_KEY): cv.string, vol.Optional(ATTR_ENDPOINT): cv.string, - vol.Optional(state.CONF_FROM): cv.string, - vol.Optional(state.CONF_TO): cv.string, + vol.Optional(ATTR_FROM): cv.string, + vol.Optional(ATTR_TO): cv.string, } ) } diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 667d7a9de24..4744c7f9fc1 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -1,16 +1,21 @@ """Helper functions for Z-Wave JS integration.""" from __future__ import annotations -from typing import Any, cast +from typing import Any, Callable, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import ConfigurationValueType from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import Value as ZwaveValue, get_value_id +from zwave_js_server.model.value import ( + ConfigurationValue, + Value as ZwaveValue, + get_value_id, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import __version__ as HA_VERSION +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_TYPE, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -242,3 +247,69 @@ def async_get_node_status_sensor_entity_id( ) return entity_id + + +def remove_keys_with_empty_values(config: ConfigType) -> ConfigType: + """Remove keys from config where the value is an empty string or None.""" + return {key: value for key, value in config.items() if value not in ("", None)} + + +def check_type_schema_map(schema_map: dict[str, vol.Schema]) -> Callable: + """Check type specific schema against config.""" + + def _check_type_schema(config: ConfigType) -> ConfigType: + """Check type specific schema against config.""" + return cast(ConfigType, schema_map[str(config[CONF_TYPE])](config)) + + return _check_type_schema + + +def copy_available_params( + input_dict: dict[str, Any], output_dict: dict[str, Any], params: list[str] +) -> None: + """Copy available params from input into output.""" + output_dict.update( + {param: input_dict[param] for param in params if param in input_dict} + ) + + +@callback +def async_is_device_config_entry_not_loaded( + hass: HomeAssistant, device_id: str +) -> bool: + """Return whether device's config entries are not loaded.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get(device_id) + assert device + return any( + (entry := hass.config_entries.async_get_entry(entry_id)) + and entry.state != ConfigEntryState.LOADED + for entry_id in device.config_entries + ) + + +def get_value_state_schema( + value: ZwaveValue, +) -> vol.Schema | None: + """Return device automation schema for a config entry.""" + if isinstance(value, ConfigurationValue): + min_ = value.metadata.min + max_ = value.metadata.max + if value.configuration_value_type in ( + ConfigurationValueType.RANGE, + ConfigurationValueType.MANUAL_ENTRY, + ): + return vol.All(vol.Coerce(int), vol.Range(min=min_, max=max_)) + + if value.configuration_value_type == ConfigurationValueType.ENUMERATED: + return vol.In({int(k): v for k, v in value.metadata.states.items()}) + + return None + + if value.metadata.states: + return vol.In({int(k): v for k, v in value.metadata.states.items()}) + + return vol.All( + vol.Coerce(int), + vol.Range(min=value.metadata.min, max=value.metadata.max), + ) diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 73ac9957071..dfdbb16c8e8 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -10,6 +10,9 @@ from zwave_js_server.const import CommandClass from zwave_js_server.event import Event from homeassistant.components import automation +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.zwave_js import DOMAIN, device_condition from homeassistant.components.zwave_js.helpers import get_zwave_value_from_config from homeassistant.exceptions import HomeAssistantError @@ -519,6 +522,7 @@ async def test_get_condition_capabilities_config_parameter( { "name": "value", "required": True, + "type": "integer", "valueMin": 0, "valueMax": 124, } @@ -565,6 +569,30 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): == {} ) + INVALID_CONFIG = { + "condition": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": 9999, + "property_key": 9999, + "endpoint": 9999, + "value": 9999, + } + + # Test that invalid config raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_condition.async_validate_condition_config(hass, INVALID_CONFIG) + + # Unload entry so we can verify that validation will pass on an invalid config + # since we return early + await hass.config_entries.async_unload(integration.entry_id) + assert ( + await device_condition.async_validate_condition_config(hass, INVALID_CONFIG) + == INVALID_CONFIG + ) + async def test_get_value_from_config_failure( hass, client, hank_binary_switch, integration diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index c7cd8e23943..22496d3deed 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -8,11 +8,10 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components import automation -from homeassistant.components.zwave_js import DOMAIN, device_trigger -from homeassistant.components.zwave_js.device_trigger import ( - async_attach_trigger, - async_get_trigger_capabilities, +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, ) +from homeassistant.components.zwave_js import DOMAIN, device_trigger from homeassistant.components.zwave_js.helpers import ( async_get_node_status_sensor_entity_id, ) @@ -1281,12 +1280,12 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_enumerate async def test_failure_scenarios(hass, client, hank_binary_switch, integration): """Test failure scenarios.""" with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "failed.test", "device_id": "invalid_device_id"}, None, {} ) with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "event.failed_type", "device_id": "invalid_device_id"}, None, @@ -1297,12 +1296,12 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "failed.test", "device_id": device.id}, None, {} ) with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "event.failed_type", "device_id": device.id}, None, @@ -1310,29 +1309,13 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): ) with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "state.failed_type", "device_id": device.id}, None, {}, ) - with pytest.raises(HomeAssistantError): - await async_attach_trigger( - hass, - { - "device_id": device.id, - "type": "zwave_js.value_updated.value", - "command_class": CommandClass.DOOR_LOCK.value, - "property": -1234, - "property_key": None, - "endpoint": None, - "from": "open", - }, - None, - {}, - ) - with patch( "homeassistant.components.zwave_js.device_trigger.async_get_node_from_device_id", return_value=None, @@ -1341,7 +1324,7 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): return_value=None, ): assert ( - await async_get_trigger_capabilities( + await device_trigger.async_get_trigger_capabilities( hass, {"type": "failed.test", "device_id": "invalid_device_id"} ) == {} @@ -1349,3 +1332,26 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): with pytest.raises(HomeAssistantError): async_get_node_status_sensor_entity_id(hass, "invalid_device_id") + + INVALID_CONFIG = { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": 9999, + "property_key": 9999, + "endpoint": 9999, + } + + # Test that invalid config raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config(hass, INVALID_CONFIG) + + # Unload entry so we can verify that validation will pass on an invalid config + # since we return early + await hass.config_entries.async_unload(integration.entry_id) + assert ( + await device_trigger.async_validate_trigger_config(hass, INVALID_CONFIG) + == INVALID_CONFIG + ) From 87d52cd2d1925e092927cb8d5a747ce8c7f236c7 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 22 Aug 2021 20:44:23 -0400 Subject: [PATCH 632/903] Clean up unused ipp logger (#55039) --- homeassistant/components/ipp/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 65d326f8f3a..242390b55b8 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -1,8 +1,6 @@ """The Internet Printing Protocol (IPP) integration.""" from __future__ import annotations -import logging - from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL @@ -13,8 +11,6 @@ from .coordinator import IPPDataUpdateCoordinator PLATFORMS = [SENSOR_DOMAIN] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IPP from a config entry.""" From e5d6e18e30ba8602bdeec02d665a58dfc6798aed Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Mon, 23 Aug 2021 01:58:42 -0400 Subject: [PATCH 633/903] Complete config flow tests for sense (#55040) --- .coveragerc | 4 +++- tests/components/sense/test_config_flow.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index de0c947eecb..85b36a2d7d4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -905,7 +905,9 @@ omit = homeassistant/components/screenlogic/switch.py homeassistant/components/scsgate/* homeassistant/components/sendgrid/notify.py - homeassistant/components/sense/* + homeassistant/components/sense/__init__.py + homeassistant/components/sense/binary_sensor.py + homeassistant/components/sense/sensor.py homeassistant/components/sensehat/light.py homeassistant/components/sensehat/sensor.py homeassistant/components/sensibo/climate.py diff --git a/tests/components/sense/test_config_flow.py b/tests/components/sense/test_config_flow.py index 55348cca838..a56422dcb84 100644 --- a/tests/components/sense/test_config_flow.py +++ b/tests/components/sense/test_config_flow.py @@ -72,3 +72,22 @@ async def test_form_cannot_connect(hass): assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass): + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "sense_energy.ASyncSenseable.authenticate", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"timeout": "6", "email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} From 03bda6ed1559d9c98d8bc98c3f561194beeedfc8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Aug 2021 09:35:03 +0200 Subject: [PATCH 634/903] Enable basic type checking for almond (#54927) * Enable basic type checking for almond * Tweak * Address review comments --- homeassistant/components/almond/__init__.py | 5 +++-- homeassistant/components/almond/config_flow.py | 8 ++++++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - tests/components/almond/test_config_flow.py | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/almond/__init__.py b/homeassistant/components/almond/__init__.py index 5d3b5a86942..6a5449e3d51 100644 --- a/homeassistant/components/almond/__init__.py +++ b/homeassistant/components/almond/__init__.py @@ -5,6 +5,7 @@ import asyncio from datetime import timedelta import logging import time +from typing import Optional, cast from aiohttp import ClientError, ClientSession import async_timeout @@ -166,7 +167,7 @@ async def _configure_almond_for_ha( _LOGGER.debug("Configuring Almond to connect to Home Assistant at %s", hass_url) store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) - data = await store.async_load() + data = cast(Optional[dict], await store.async_load()) if data is None: data = {} @@ -204,7 +205,7 @@ async def _configure_almond_for_ha( ) except (asyncio.TimeoutError, ClientError) as err: if isinstance(err, asyncio.TimeoutError): - msg = "Request timeout" + msg: str | ClientError = "Request timeout" else: msg = err _LOGGER.warning("Unable to configure Almond: %s", msg) diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py index d6084569ff7..b7b56f93864 100644 --- a/homeassistant/components/almond/config_flow.py +++ b/homeassistant/components/almond/config_flow.py @@ -1,6 +1,9 @@ """Config flow to connect with Home Assistant.""" +from __future__ import annotations + import asyncio import logging +from typing import Any from aiohttp import ClientError import async_timeout @@ -9,6 +12,7 @@ import voluptuous as vol from yarl import URL from homeassistant import config_entries, core, data_entry_flow +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .const import DOMAIN as ALMOND_DOMAIN, TYPE_LOCAL, TYPE_OAUTH2 @@ -64,7 +68,7 @@ class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): return result - async def async_oauth_create_entry(self, data: dict) -> dict: + async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow. Ok to override if you want to fetch extra info or even add another step. @@ -73,7 +77,7 @@ class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): data["host"] = self.host return self.async_create_entry(title=self.flow_impl.name, data=data) - async def async_step_import(self, user_input: dict = None) -> dict: + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import data.""" # Only allow 1 instance. if self._async_current_entries(): diff --git a/mypy.ini b/mypy.ini index 37007884be2..69b27cd1093 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1265,9 +1265,6 @@ no_implicit_optional = false warn_return_any = false warn_unreachable = false -[mypy-homeassistant.components.almond.*] -ignore_errors = true - [mypy-homeassistant.components.awair.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 2015b9983c7..b3968bc58c5 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -14,7 +14,6 @@ from .model import Config, Integration # remove your component from this list to enable type checks. # Do your best to not add anything new here. IGNORED_MODULES: Final[list[str]] = [ - "homeassistant.components.almond.*", "homeassistant.components.awair.*", "homeassistant.components.azure_event_hub.*", "homeassistant.components.blueprint.*", diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py index 892abaa9650..b5a3d90fbdb 100644 --- a/tests/components/almond/test_config_flow.py +++ b/tests/components/almond/test_config_flow.py @@ -82,7 +82,7 @@ async def test_abort_if_existing_entry(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "single_instance_allowed" - result = await flow.async_step_import() + result = await flow.async_step_import({}) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "single_instance_allowed" From f72bf2145b7e310f34f23ad34f8294d8fe1360ce Mon Sep 17 00:00:00 2001 From: Pawel Date: Mon, 23 Aug 2021 09:53:23 +0200 Subject: [PATCH 635/903] Remove deprecated YAML configuration from Epson (#55045) --- homeassistant/components/epson/config_flow.py | 24 ------- homeassistant/components/epson/const.py | 1 - .../components/epson/media_player.py | 28 +------- tests/components/epson/test_config_flow.py | 69 ------------------- 4 files changed, 3 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/epson/config_flow.py b/homeassistant/components/epson/config_flow.py index 5203cdbe9e0..b1ac34b1099 100644 --- a/homeassistant/components/epson/config_flow.py +++ b/homeassistant/components/epson/config_flow.py @@ -25,30 +25,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - for entry in self._async_current_entries(include_ignore=True): - if import_config[CONF_HOST] == entry.data[CONF_HOST]: - return self.async_abort(reason="already_configured") - try: - projector = await validate_projector( - hass=self.hass, - host=import_config[CONF_HOST], - check_power=True, - check_powered_on=False, - ) - except CannotConnect: - _LOGGER.warning("Cannot connect to projector") - return self.async_abort(reason="cannot_connect") - - serial_no = await projector.get_serial_number() - await self.async_set_unique_id(serial_no) - self._abort_if_unique_id_configured() - import_config.pop(CONF_PORT, None) - return self.async_create_entry( - title=import_config.pop(CONF_NAME), data=import_config - ) - async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} diff --git a/homeassistant/components/epson/const.py b/homeassistant/components/epson/const.py index 9b1ad0a8f5f..06ef9f25e35 100644 --- a/homeassistant/components/epson/const.py +++ b/homeassistant/components/epson/const.py @@ -4,5 +4,4 @@ DOMAIN = "epson" SERVICE_SELECT_CMODE = "select_cmode" ATTR_CMODE = "cmode" -DEFAULT_NAME = "EPSON Projector" HTTP = "http" diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 92a43330d69..1fd0b7f6e70 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -26,7 +26,7 @@ from epson_projector.const import ( ) import voluptuous as vol -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, @@ -36,13 +36,12 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry -from .const import ATTR_CMODE, DEFAULT_NAME, DOMAIN, SERVICE_SELECT_CMODE +from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE _LOGGER = logging.getLogger(__name__) @@ -56,14 +55,6 @@ SUPPORT_EPSON = ( | SUPPORT_PREVIOUS_TRACK ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=80): cv.port, - } -) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Epson projector from a config entry.""" @@ -85,19 +76,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Epson projector.""" - _LOGGER.warning( - "Loading Espon projector via platform setup is deprecated; " - "Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - class EpsonProjectorMediaPlayer(MediaPlayerEntity): """Representation of Epson Projector Device.""" diff --git a/tests/components/epson/test_config_flow.py b/tests/components/epson/test_config_flow.py index 3ff7753d3eb..9c02feadc1a 100644 --- a/tests/components/epson/test_config_flow.py +++ b/tests/components/epson/test_config_flow.py @@ -7,8 +7,6 @@ from homeassistant import config_entries, setup from homeassistant.components.epson.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, STATE_UNAVAILABLE -from tests.common import MockConfigEntry - async def test_form(hass): """Test we get the form.""" @@ -75,70 +73,3 @@ async def test_form_powered_off(hass): assert result2["type"] == "form" assert result2["errors"] == {"base": "powered_off"} - - -async def test_import(hass): - """Test config.yaml import.""" - with patch( - "homeassistant.components.epson.Projector.get_power", - return_value="01", - ), patch( - "homeassistant.components.epson.Projector.get_property", - return_value="04", - ), patch( - "homeassistant.components.epson.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.1", CONF_NAME: "test-epson"}, - ) - assert result["type"] == "create_entry" - assert result["title"] == "test-epson" - assert result["data"] == {CONF_HOST: "1.1.1.1"} - - -async def test_already_imported(hass): - """Test config.yaml imported twice.""" - MockConfigEntry( - domain=DOMAIN, - source=config_entries.SOURCE_IMPORT, - unique_id="bla", - title="test-epson", - data={CONF_HOST: "1.1.1.1"}, - ).add_to_hass(hass) - - with patch( - "homeassistant.components.epson.Projector.get_power", - return_value="01", - ), patch( - "homeassistant.components.epson.Projector.get_property", - return_value="04", - ), patch( - "homeassistant.components.epson.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.1", CONF_NAME: "test-epson"}, - ) - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - -async def test_import_cannot_connect(hass): - """Test we handle cannot connect error.""" - with patch( - "homeassistant.components.epson.Projector.get_power", - return_value=STATE_UNAVAILABLE, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.1", CONF_NAME: "test-epson"}, - ) - - assert result["type"] == "abort" - assert result["reason"] == "cannot_connect" From 5a7b894ba8940d923ce1f97ad60d21bfced2e7b0 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 23 Aug 2021 10:05:10 +0200 Subject: [PATCH 636/903] Alpine 3.14 (#55049) --- .github/workflows/wheels.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index cce57401ea8..95f7f1fda4d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -66,6 +66,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} tag: - "3.9-alpine3.13" + - "3.9-alpine3.14" steps: - name: Checkout the repository uses: actions/checkout@v2.3.4 @@ -106,6 +107,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} tag: - "3.9-alpine3.13" + - "3.9-alpine3.14" steps: - name: Checkout the repository uses: actions/checkout@v2.3.4 From 4a2eeed1e87e33b1822c00700752e885d172b756 Mon Sep 17 00:00:00 2001 From: JasperPlant <78851352+JasperPlant@users.noreply.github.com> Date: Mon, 23 Aug 2021 10:55:38 +0200 Subject: [PATCH 637/903] Growatt server integration add TLX support (#54844) --- CODEOWNERS | 2 +- .../components/growatt_server/manifest.json | 2 +- .../components/growatt_server/sensor.py | 165 +++++++++++++++++- 3 files changed, 166 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 5a2828711c0..1a81b981dc5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -199,7 +199,7 @@ homeassistant/components/gpsd/* @fabaff homeassistant/components/gree/* @cmroche homeassistant/components/greeneye_monitor/* @jkeljo homeassistant/components/group/* @home-assistant/core -homeassistant/components/growatt_server/* @indykoning @muppet3000 +homeassistant/components/growatt_server/* @indykoning @muppet3000 @JasperPlant homeassistant/components/guardian/* @bachya homeassistant/components/habitica/* @ASMfreaK @leikoilja homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 8b4a82d7b99..ab2d07c147b 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server/", "requirements": ["growattServer==1.0.1"], - "codeowners": ["@indykoning", "@muppet3000"], + "codeowners": ["@indykoning", "@muppet3000", "@JasperPlant"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index fb271afb68a..03da4fe4b57 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -258,6 +258,163 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), ) +TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="tlx_energy_today", + name="Energy today", + api_key="eacToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_total", + name="Lifetime energy output", + api_key="eacTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_total_input_1", + name="Lifetime total energy input 1", + api_key="epv1Total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_voltage_input_1", + name="Input 1 voltage", + api_key="vpv1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_amperage_input_1", + name="Input 1 Amperage", + api_key="ipv1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_wattage_input_1", + name="Input 1 Wattage", + api_key="ppv1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_total_input_2", + name="Lifetime total energy input 2", + api_key="epv2Total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_voltage_input_2", + name="Input 2 voltage", + api_key="vpv2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_amperage_input_2", + name="Input 2 Amperage", + api_key="ipv2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_wattage_input_2", + name="Input 2 Wattage", + api_key="ppv2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_internal_wattage", + name="Internal wattage", + api_key="ppv", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_reactive_voltage", + name="Reactive voltage", + api_key="vacrs", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_frequency", + name="AC frequency", + api_key="fac", + native_unit_of_measurement=FREQUENCY_HERTZ, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_current_wattage", + name="Output power", + api_key="pac", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_1", + name="Temperature 1", + api_key="temp1", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_2", + name="Temperature 2", + api_key="temp2", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_3", + name="Temperature 3", + api_key="temp3", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_4", + name="Temperature 4", + api_key="temp4", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_5", + name="Temperature 5", + api_key="temp5", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), +) + STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( GrowattSensorEntityDescription( key="storage_storage_production_today", @@ -746,6 +903,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensor_descriptions = () if device["deviceType"] == "inverter": sensor_descriptions = INVERTER_SENSOR_TYPES + elif device["deviceType"] == "tlx": + probe.plant_id = plant_id + sensor_descriptions = TLX_SENSOR_TYPES elif device["deviceType"] == "storage": probe.plant_id = plant_id sensor_descriptions = STORAGE_SENSOR_TYPES @@ -820,7 +980,7 @@ class GrowattData: def update(self): """Update probe data.""" self.api.login(self.username, self.password) - _LOGGER.debug("Updating data for %s", self.device_id) + _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.growatt_type) try: if self.growatt_type == "total": total_info = self.api.plant_info(self.device_id) @@ -833,6 +993,9 @@ class GrowattData: elif self.growatt_type == "inverter": inverter_info = self.api.inverter_detail(self.device_id) self.data = inverter_info + elif self.growatt_type == "tlx": + tlx_info = self.api.tlx_detail(self.device_id) + self.data = tlx_info["data"] elif self.growatt_type == "storage": storage_info_detail = self.api.storage_params(self.device_id)[ "storageDetailBean" From 2dd4de060b4b1ec56ae2f59fc22531035dc52558 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 23 Aug 2021 10:14:43 +0100 Subject: [PATCH 638/903] Add device class for volatile organic compounds (#55050) --- homeassistant/components/sensor/__init__.py | 2 ++ homeassistant/components/sensor/device_condition.py | 6 ++++++ homeassistant/components/sensor/device_trigger.py | 6 ++++++ homeassistant/components/sensor/strings.json | 2 ++ homeassistant/const.py | 1 + tests/components/sensor/test_device_trigger.py | 2 +- tests/testing_config/custom_components/test/sensor.py | 1 + 7 files changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 8063129d7be..73e6f1fa93a 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -37,6 +37,7 @@ from homeassistant.const import ( DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT, @@ -85,6 +86,7 @@ DEVICE_CLASSES: Final[list[str]] = [ DEVICE_CLASS_POWER, # power (W/kW) DEVICE_CLASS_POWER_FACTOR, # power factor (%) DEVICE_CLASS_VOLTAGE, # voltage (V) + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, # Amount of VOC (µg/m³) DEVICE_CLASS_GAS, # gas (m³ or ft³) ] diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index dee20405e07..ffa59271d79 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -32,6 +32,7 @@ from homeassistant.const import ( DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLTAGE, ) from homeassistant.core import HomeAssistant, HomeAssistantError, callback @@ -70,6 +71,7 @@ CONF_IS_PRESSURE = "is_pressure" CONF_IS_SIGNAL_STRENGTH = "is_signal_strength" CONF_IS_SULPHUR_DIOXIDE = "is_sulphur_dioxide" CONF_IS_TEMPERATURE = "is_temperature" +CONF_IS_VOLATILE_ORGANIC_COMPOUNDS = "is_volatile_organic_compounds" CONF_IS_VOLTAGE = "is_voltage" CONF_IS_VALUE = "is_value" @@ -95,6 +97,9 @@ ENTITY_CONDITIONS = { DEVICE_CLASS_SIGNAL_STRENGTH: [{CONF_TYPE: CONF_IS_SIGNAL_STRENGTH}], DEVICE_CLASS_SULPHUR_DIOXIDE: [{CONF_TYPE: CONF_IS_SULPHUR_DIOXIDE}], DEVICE_CLASS_TEMPERATURE: [{CONF_TYPE: CONF_IS_TEMPERATURE}], + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: [ + {CONF_TYPE: CONF_IS_VOLATILE_ORGANIC_COMPOUNDS} + ], DEVICE_CLASS_VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_VALUE}], } @@ -126,6 +131,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_SIGNAL_STRENGTH, CONF_IS_SULPHUR_DIOXIDE, CONF_IS_TEMPERATURE, + CONF_IS_VOLATILE_ORGANIC_COMPOUNDS, CONF_IS_VOLTAGE, CONF_IS_VALUE, ] diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 2de09c01bc1..189b098bea0 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -35,6 +35,7 @@ from homeassistant.const import ( DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLTAGE, ) from homeassistant.core import HomeAssistantError @@ -69,6 +70,7 @@ CONF_PRESSURE = "pressure" CONF_SIGNAL_STRENGTH = "signal_strength" CONF_SULPHUR_DIOXIDE = "sulphur_dioxide" CONF_TEMPERATURE = "temperature" +CONF_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" CONF_VOLTAGE = "voltage" CONF_VALUE = "value" @@ -94,6 +96,9 @@ ENTITY_TRIGGERS = { DEVICE_CLASS_SIGNAL_STRENGTH: [{CONF_TYPE: CONF_SIGNAL_STRENGTH}], DEVICE_CLASS_SULPHUR_DIOXIDE: [{CONF_TYPE: CONF_SULPHUR_DIOXIDE}], DEVICE_CLASS_TEMPERATURE: [{CONF_TYPE: CONF_TEMPERATURE}], + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: [ + {CONF_TYPE: CONF_VOLATILE_ORGANIC_COMPOUNDS} + ], DEVICE_CLASS_VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_VALUE}], } @@ -126,6 +131,7 @@ TRIGGER_SCHEMA = vol.All( CONF_SIGNAL_STRENGTH, CONF_SULPHUR_DIOXIDE, CONF_TEMPERATURE, + CONF_VOLATILE_ORGANIC_COMPOUNDS, CONF_VOLTAGE, CONF_VALUE, ] diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 431e8a4789a..1dec2b60e20 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -23,6 +23,7 @@ "is_current": "Current {entity_name} current", "is_energy": "Current {entity_name} energy", "is_power_factor": "Current {entity_name} power factor", + "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", "is_voltage": "Current {entity_name} voltage", "is_value": "Current {entity_name} value" }, @@ -48,6 +49,7 @@ "current": "{entity_name} current changes", "energy": "{entity_name} energy changes", "power_factor": "{entity_name} power factor changes", + "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", "voltage": "{entity_name} voltage changes", "value": "{entity_name} value changes" } diff --git a/homeassistant/const.py b/homeassistant/const.py index ae1f50d0087..7f5dba5b17d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -256,6 +256,7 @@ DEVICE_CLASS_SULPHUR_DIOXIDE = "sulphur_dioxide" DEVICE_CLASS_TEMPERATURE: Final = "temperature" DEVICE_CLASS_TIMESTAMP: Final = "timestamp" DEVICE_CLASS_VOLTAGE: Final = "voltage" +DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" DEVICE_CLASS_GAS: Final = "gas" # #### STATES #### diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 8e60714a9e2..5ef99b6c669 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -86,7 +86,7 @@ async def test_get_triggers(hass, device_reg, entity_reg, enable_custom_integrat if device_class != "none" ] triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert len(triggers) == 22 + assert len(triggers) == 23 assert triggers == expected_triggers diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 010b82dc3a2..fd35d1006a0 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -39,6 +39,7 @@ UNITS_OF_MEASUREMENT = { sensor.DEVICE_CLASS_CURRENT: "A", # current (A) sensor.DEVICE_CLASS_ENERGY: "kWh", # energy (Wh/kWh) sensor.DEVICE_CLASS_POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of vocs sensor.DEVICE_CLASS_VOLTAGE: "V", # voltage (V) sensor.DEVICE_CLASS_GAS: VOLUME_CUBIC_METERS, # gas (m³) } From ee009cc332a83cc29a07ba17bfa90bf4b582472b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Aug 2021 12:32:57 +0200 Subject: [PATCH 639/903] Fix DSMR startup logic (#55051) --- homeassistant/components/dsmr/sensor.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index f19f04072cb..d3dfb68d425 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, VOLUME_CUBIC_METERS, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, StateType @@ -139,7 +139,7 @@ async def async_setup_entry( transport = None protocol = None - while hass.is_running: + while hass.state == CoreState.not_running or hass.is_running: # Start DSMR asyncio.Protocol reader try: transport, protocol = await hass.loop.create_task(reader_factory()) @@ -154,7 +154,7 @@ async def async_setup_entry( await protocol.wait_closed() # Unexpected disconnect - if hass.is_running: + if hass.state == CoreState.not_running or hass.is_running: stop_listener() transport = None @@ -181,7 +181,9 @@ async def async_setup_entry( entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) ) except CancelledError: - if stop_listener and hass.is_running: + if stop_listener and ( + hass.state == CoreState.not_running or hass.is_running + ): stop_listener() # pylint: disable=not-callable if transport: From 716abaa9b1fc1b713b6d9fc8a1e8f50422ca1a54 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 23 Aug 2021 13:50:08 +0200 Subject: [PATCH 640/903] Prefer discovered usb device over add-on config in zwave_js (#55056) --- .../components/zwave_js/config_flow.py | 2 +- tests/components/zwave_js/test_config_flow.py | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 6b0fc7b692e..55266d02389 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -485,7 +485,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_start_addon() - usb_path = addon_config.get(CONF_ADDON_DEVICE) or self.usb_path or "" + usb_path = self.usb_path or addon_config.get(CONF_ADDON_DEVICE) or "" network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, self.network_key or "") data_schema = vol.Schema( diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 393de228d87..5e994a2ac7a 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -499,6 +499,74 @@ async def test_usb_discovery( assert len(mock_setup_entry.mock_calls) == 1 +async def test_usb_discovery_addon_not_running( + hass, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, +): + """Test usb discovery when add-on is installed but not running.""" + addon_options["device"] = "/dev/incorrect_device" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "form" + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + # Make sure the discovered usb device is preferred. + data_schema = result["data_schema"] + assert data_schema({}) == { + "usb_path": USB_DISCOVERY_INFO["device"], + "network_key": "", + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"usb_path": USB_DISCOVERY_INFO["device"], "network_key": "abc123"}, + ) + + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + {"options": {"device": USB_DISCOVERY_INFO["device"], "network_key": "abc123"}}, + ) + + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call(hass, "core_zwave_js") + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"]["usb_path"] == USB_DISCOVERY_INFO["device"] + assert result["data"]["integration_created_addon"] is False + assert result["data"]["use_addon"] is True + assert result["data"]["network_key"] == "abc123" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_discovery_addon_not_running( hass, supervisor, addon_installed, addon_options, set_addon_options, start_addon ): From b76e8c5722522af26a446492d87fdb2d02d20eda Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Aug 2021 16:02:31 +0200 Subject: [PATCH 641/903] Please mypy. (#55069) --- homeassistant/components/smarty/sensor.py | 3 ++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index a76e4b0f567..44a8392991a 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -1,4 +1,5 @@ """Support for Salda Smarty XP/XV Ventilation Unit Sensors.""" +from __future__ import annotations import datetime as dt import logging @@ -43,7 +44,7 @@ class SmartySensor(SensorEntity): ): """Initialize the entity.""" self._name = name - self._state = None + self._state: dt.datetime | None = None self._sensor_type = device_class self._unit_of_measurement = unit_of_measurement self._smarty = smarty diff --git a/mypy.ini b/mypy.ini index 69b27cd1093..5c2c8678999 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1586,9 +1586,6 @@ ignore_errors = true [mypy-homeassistant.components.smarttub.*] ignore_errors = true -[mypy-homeassistant.components.smarty.*] -ignore_errors = true - [mypy-homeassistant.components.solaredge.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index b3968bc58c5..b22919ce5fa 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -121,7 +121,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.sma.*", "homeassistant.components.smartthings.*", "homeassistant.components.smarttub.*", - "homeassistant.components.smarty.*", "homeassistant.components.solaredge.*", "homeassistant.components.somfy.*", "homeassistant.components.somfy_mylink.*", From 68f1c190490786577ef4fc9d87146a7cbc262b50 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Aug 2021 17:04:42 +0200 Subject: [PATCH 642/903] Enable basic type checking for azure_event_hub (#55047) * Enable basic type checking for azure_event_hub * Update homeassistant/components/azure_event_hub/__init__.py Co-authored-by: Martin Hjelmare * Disable false pylint positive Co-authored-by: Martin Hjelmare --- .../components/azure_event_hub/__init__.py | 16 +++++++++------- .../components/azure_event_hub/const.py | 6 +++++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 1c9add1bd8b..9bae21ec43b 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -5,9 +5,9 @@ import asyncio import json import logging import time -from typing import Any +from typing import Any, Callable -from azure.eventhub import EventData +from azure.eventhub import EventData, EventDataBatch from azure.eventhub.aio import EventHubProducerClient, EventHubSharedKeyCredential from azure.eventhub.exceptions import EventHubError import voluptuous as vol @@ -109,14 +109,16 @@ class AzureEventHub: ) -> None: """Initialize the listener.""" self.hass = hass - self.queue = asyncio.PriorityQueue() + self.queue: asyncio.PriorityQueue[ # pylint: disable=unsubscriptable-object + tuple[int, tuple[float, Event | None]] + ] = asyncio.PriorityQueue() self._client_args = client_args self._conn_str_client = conn_str_client self._entities_filter = entities_filter self._send_interval = send_interval self._max_delay = max_delay + send_interval - self._listener_remover = None - self._next_send_remover = None + self._listener_remover: Callable[[], None] | None = None + self._next_send_remover: Callable[[], None] | None = None self.shutdown = False async def async_start(self) -> None: @@ -169,7 +171,7 @@ class AzureEventHub: self.hass, self._send_interval, self.async_send ) - async def fill_batch(self, client) -> None: + async def fill_batch(self, client) -> tuple[EventDataBatch, int]: """Return a batch of events formatted for writing. Uses get_nowait instead of await get, because the functions batches and doesn't wait for each single event, the send function is called. @@ -207,7 +209,7 @@ class AzureEventHub: return event_batch, dequeue_count - def _event_to_filtered_event_data(self, event: Event) -> None: + def _event_to_filtered_event_data(self, event: Event) -> EventData | None: """Filter event states and create EventData object.""" state = event.data.get("new_state") if ( diff --git a/homeassistant/components/azure_event_hub/const.py b/homeassistant/components/azure_event_hub/const.py index 1786bb5cbf2..fdb5180fe4e 100644 --- a/homeassistant/components/azure_event_hub/const.py +++ b/homeassistant/components/azure_event_hub/const.py @@ -1,4 +1,8 @@ """Constants and shared schema for the Azure Event Hub integration.""" +from __future__ import annotations + +from typing import Any + DOMAIN = "azure_event_hub" CONF_EVENT_HUB_NAMESPACE = "event_hub_namespace" @@ -10,4 +14,4 @@ CONF_SEND_INTERVAL = "send_interval" CONF_MAX_DELAY = "max_delay" CONF_FILTER = "filter" -ADDITIONAL_ARGS = {"logging_enable": False} +ADDITIONAL_ARGS: dict[str, Any] = {"logging_enable": False} diff --git a/mypy.ini b/mypy.ini index 5c2c8678999..2d94878ee2a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1268,9 +1268,6 @@ warn_unreachable = false [mypy-homeassistant.components.awair.*] ignore_errors = true -[mypy-homeassistant.components.azure_event_hub.*] -ignore_errors = true - [mypy-homeassistant.components.blueprint.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index b22919ce5fa..b761bdee919 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -15,7 +15,6 @@ from .model import Config, Integration # Do your best to not add anything new here. IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.awair.*", - "homeassistant.components.azure_event_hub.*", "homeassistant.components.blueprint.*", "homeassistant.components.bmw_connected_drive.*", "homeassistant.components.cert_expiry.*", From 21806115ee7512c8de6eca11f23bfef8159f51a7 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 23 Aug 2021 17:18:56 +0200 Subject: [PATCH 643/903] Pass session to forecast constructor (#55075) --- homeassistant/components/forecast_solar/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 4d996736ecf..adbe040bfbd 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -11,6 +11,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -32,8 +33,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not api_key: api_key = None + session = async_get_clientsession(hass) forecast = ForecastSolar( api_key=api_key, + session=session, latitude=entry.data[CONF_LATITUDE], longitude=entry.data[CONF_LONGITUDE], declination=entry.options[CONF_DECLINATION], From ee3e27c82a750c29a9ca340ee54a07af31a2eab0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Aug 2021 18:29:44 +0200 Subject: [PATCH 644/903] Add support for white to light groups (#55082) --- homeassistant/components/group/light.py | 2 + tests/components/group/test_light.py | 58 +++++++++++++++++++ .../custom_components/test/light.py | 2 + 3 files changed, 62 insertions(+) diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 671a471318a..96cad7a4914 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -24,6 +24,7 @@ from homeassistant.components.light import ( ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_WHITE, ATTR_WHITE_VALUE, ATTR_XY_COLOR, COLOR_MODE_BRIGHTNESS, @@ -93,6 +94,7 @@ FORWARDED_ATTRIBUTES = frozenset( ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_TRANSITION, + ATTR_WHITE, ATTR_WHITE_VALUE, ATTR_XY_COLOR, } diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 74275cf0bd2..e7a862ee0ad 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -21,6 +21,7 @@ from homeassistant.components.light import ( ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_WHITE, ATTR_WHITE_VALUE, ATTR_XY_COLOR, COLOR_MODE_BRIGHTNESS, @@ -29,6 +30,7 @@ from homeassistant.components.light import ( COLOR_MODE_ONOFF, COLOR_MODE_RGBW, COLOR_MODE_RGBWW, + COLOR_MODE_WHITE, DOMAIN as LIGHT_DOMAIN, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -442,6 +444,62 @@ async def test_white_value(hass): assert state.attributes[ATTR_WHITE_VALUE] == 100 +async def test_white(hass, enable_custom_integrations): + """Test white reporting.""" + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + platform.ENTITIES.append(platform.MockLight("test1", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("test2", STATE_ON)) + + entity0 = platform.ENTITIES[0] + entity0.supported_color_modes = {COLOR_MODE_HS, COLOR_MODE_WHITE} + entity0.color_mode = COLOR_MODE_WHITE + entity0.brightness = 255 + + entity1 = platform.ENTITIES[1] + entity1.supported_color_modes = {COLOR_MODE_HS, COLOR_MODE_WHITE} + entity1.color_mode = COLOR_MODE_WHITE + entity1.brightness = 128 + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2"], + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes[ATTR_COLOR_MODE] == "white" + assert state.attributes[ATTR_BRIGHTNESS] == 191 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs", "white"] + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": ["light.light_group"], ATTR_WHITE: 128}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes[ATTR_COLOR_MODE] == "white" + assert state.attributes[ATTR_BRIGHTNESS] == 128 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs", "white"] + + async def test_color_temp(hass, enable_custom_integrations): """Test color temp reporting.""" platform = getattr(hass.components, "test.light") diff --git a/tests/testing_config/custom_components/test/light.py b/tests/testing_config/custom_components/test/light.py index 88ce04bdc92..1b33b1cafbf 100644 --- a/tests/testing_config/custom_components/test/light.py +++ b/tests/testing_config/custom_components/test/light.py @@ -66,3 +66,5 @@ class MockLight(MockToggleEntity, LightEntity): "white_value", ]: setattr(self, key, value) + if key == "white": + setattr(self, "brightness", value) From 8522538d8f7d50d5dde4058c85a79c8bddc36286 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 19:23:35 +0200 Subject: [PATCH 645/903] Use EntityDescription - rainmachine (#55021) --- .../components/rainmachine/sensor.py | 128 +++++++++--------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 2316b27acbf..269bd6bcd4b 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -1,9 +1,12 @@ """This platform provides support for sensor data from RainMachine.""" +from __future__ import annotations + +from dataclasses import dataclass from functools import partial from regenmaschine.controller import Controller -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, @@ -29,48 +32,64 @@ TYPE_FLOW_SENSOR_START_INDEX = "flow_sensor_start_index" TYPE_FLOW_SENSOR_WATERING_CLICKS = "flow_sensor_watering_clicks" TYPE_FREEZE_TEMP = "freeze_protect_temp" -SENSORS = { - TYPE_FLOW_SENSOR_CLICK_M3: ( - "Flow Sensor Clicks", - "mdi:water-pump", - f"clicks/{VOLUME_CUBIC_METERS}", - None, - False, - DATA_PROVISION_SETTINGS, + +@dataclass +class RainmachineRequiredKeysMixin: + """Mixin for required keys.""" + + api_category: str + + +@dataclass +class RainmachineSensorEntityDescription( + SensorEntityDescription, RainmachineRequiredKeysMixin +): + """Describes Rainmachine sensor entity.""" + + +SENSOR_TYPES: tuple[RainmachineSensorEntityDescription, ...] = ( + RainmachineSensorEntityDescription( + key=TYPE_FLOW_SENSOR_CLICK_M3, + name="Flow Sensor Clicks", + icon="mdi:water-pump", + native_unit_of_measurement=f"clicks/{VOLUME_CUBIC_METERS}", + entity_registry_enabled_default=False, + api_category=DATA_PROVISION_SETTINGS, ), - TYPE_FLOW_SENSOR_CONSUMED_LITERS: ( - "Flow Sensor Consumed Liters", - "mdi:water-pump", - "liter", - None, - False, - DATA_PROVISION_SETTINGS, + RainmachineSensorEntityDescription( + key=TYPE_FLOW_SENSOR_CONSUMED_LITERS, + name="Flow Sensor Consumed Liters", + icon="mdi:water-pump", + native_unit_of_measurement="liter", + entity_registry_enabled_default=False, + api_category=DATA_PROVISION_SETTINGS, ), - TYPE_FLOW_SENSOR_START_INDEX: ( - "Flow Sensor Start Index", - "mdi:water-pump", - "index", - None, - False, - DATA_PROVISION_SETTINGS, + RainmachineSensorEntityDescription( + key=TYPE_FLOW_SENSOR_START_INDEX, + name="Flow Sensor Start Index", + icon="mdi:water-pump", + native_unit_of_measurement="index", + entity_registry_enabled_default=False, + api_category=DATA_PROVISION_SETTINGS, ), - TYPE_FLOW_SENSOR_WATERING_CLICKS: ( - "Flow Sensor Clicks", - "mdi:water-pump", - "clicks", - None, - False, - DATA_PROVISION_SETTINGS, + RainmachineSensorEntityDescription( + key=TYPE_FLOW_SENSOR_WATERING_CLICKS, + name="Flow Sensor Clicks", + icon="mdi:water-pump", + native_unit_of_measurement="clicks", + entity_registry_enabled_default=False, + api_category=DATA_PROVISION_SETTINGS, ), - TYPE_FREEZE_TEMP: ( - "Freeze Protect Temperature", - "mdi:thermometer", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - True, - DATA_RESTRICTIONS_UNIVERSAL, + RainmachineSensorEntityDescription( + key=TYPE_FREEZE_TEMP, + name="Freeze Protect Temperature", + icon="mdi:thermometer", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_registry_enabled_default=True, + api_category=DATA_RESTRICTIONS_UNIVERSAL, ), -} +) async def async_setup_entry( @@ -96,19 +115,8 @@ async def async_setup_entry( async_add_entities( [ - async_get_sensor(api_category)( - controller, - sensor_type, - name, - icon, - unit, - device_class, - enabled_by_default, - ) - for ( - sensor_type, - (name, icon, unit, device_class, enabled_by_default, api_category), - ) in SENSORS.items() + async_get_sensor(description.api_category)(controller, description) + for description in SENSOR_TYPES ] ) @@ -116,25 +124,17 @@ async def async_setup_entry( class RainMachineSensor(RainMachineEntity, SensorEntity): """Define a general RainMachine sensor.""" + entity_description: RainmachineSensorEntityDescription + def __init__( self, coordinator: DataUpdateCoordinator, controller: Controller, - sensor_type: str, - name: str, - icon: str, - unit: str, - device_class: str, - enabled_by_default: bool, + description: RainmachineSensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator, controller, sensor_type) - - self._attr_device_class = device_class - self._attr_entity_registry_enabled_default = enabled_by_default - self._attr_icon = icon - self._attr_name = name - self._attr_native_unit_of_measurement = unit + super().__init__(coordinator, controller, description.key) + self.entity_description = description class ProvisionSettingsSensor(RainMachineSensor): From a3ff05f367c183403b0f83da6ecf2ff70eb0e362 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 19:24:19 +0200 Subject: [PATCH 646/903] Update pylint to 2.10.2 (#55089) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 7aee2dfb332..73b34913f89 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ jsonpickle==1.4.1 mock-open==1.4.0 mypy==0.910 pre-commit==2.14.0 -pylint==2.10.1 +pylint==2.10.2 pipdeptree==1.0.0 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 From 2cdaf632a49d6b7ee2dbeaad9cfe88abdabadfbb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 23 Aug 2021 20:05:29 +0200 Subject: [PATCH 647/903] Restore last_triggered state in scripts (#55071) --- homeassistant/components/script/__init__.py | 10 ++++- tests/components/script/test_init.py | 50 ++++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 483b4065be2..f3f34a0ad53 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -32,6 +32,7 @@ import homeassistant.helpers.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 +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import ( ATTR_CUR, ATTR_MAX, @@ -42,6 +43,7 @@ from homeassistant.helpers.script import ( from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.trace import trace_get, trace_path from homeassistant.loader import bind_hass +from homeassistant.util.dt import parse_datetime from .config import ScriptConfig, async_validate_config_item from .const import ( @@ -296,7 +298,7 @@ async def _async_process_config(hass, config, component) -> bool: return blueprints_used -class ScriptEntity(ToggleEntity): +class ScriptEntity(ToggleEntity, RestoreEntity): """Representation of a script entity.""" icon = None @@ -415,6 +417,12 @@ class ScriptEntity(ToggleEntity): """ await self.script.async_stop() + async def async_added_to_hass(self) -> None: + """Restore last triggered on startup.""" + if state := await self.async_get_last_state(): + if last_triggered := state.attributes.get("last_triggered"): + self.script.last_triggered = parse_datetime(last_triggered) + async def async_will_remove_from_hass(self): """Stop script and remove service when it will be removed from Home Assistant.""" await self.script.async_stop() diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 6070daeb8af..9190b033f44 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -15,16 +15,25 @@ from homeassistant.const import ( SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, +) +from homeassistant.core import ( + Context, + CoreState, + HomeAssistant, + State, + callback, + split_entity_id, ) -from homeassistant.core import Context, callback, split_entity_id from homeassistant.exceptions import ServiceNotFound from homeassistant.helpers import template from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.loader import bind_hass from homeassistant.setup import async_setup_component, setup_component +import homeassistant.util.dt as dt_util -from tests.common import async_mock_service, get_test_home_assistant +from tests.common import async_mock_service, get_test_home_assistant, mock_restore_cache from tests.components.logbook.test_init import MockLazyEventPartialState ENTITY_ID = "script.test" @@ -84,6 +93,7 @@ class TestScriptComponent(unittest.TestCase): def test_passing_variables(self): """Test different ways of passing in variables.""" + mock_restore_cache(self.hass, ()) calls = [] context = Context() @@ -796,3 +806,39 @@ async def test_script_this_var_always(hass, caplog): # Verify this available to all templates assert mock_calls[0].data.get("this_template") == "script.script1" assert "Error rendering variables" not in caplog.text + + +async def test_script_restore_last_triggered(hass: HomeAssistant) -> None: + """Test if last triggered is restored on start.""" + time = dt_util.utcnow() + mock_restore_cache( + hass, + ( + State("script.no_last_triggered", STATE_OFF), + State("script.last_triggered", STATE_OFF, {"last_triggered": time}), + ), + ) + hass.state = CoreState.starting + + assert await async_setup_component( + hass, + "script", + { + "script": { + "no_last_triggered": { + "sequence": [{"delay": {"seconds": 5}}], + }, + "last_triggered": { + "sequence": [{"delay": {"seconds": 5}}], + }, + }, + }, + ) + + state = hass.states.get("script.no_last_triggered") + assert state + assert state.attributes["last_triggered"] is None + + state = hass.states.get("script.last_triggered") + assert state + assert state.attributes["last_triggered"] == time From ce5c76869d5d6ca2b373e299427423389c59e352 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 20:19:19 +0200 Subject: [PATCH 648/903] Use EntityDescription - ecobee (#55088) --- homeassistant/components/ecobee/sensor.py | 70 +++++++++++------------ 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index eb72f667b5f..dfa6cf4cb0a 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -1,7 +1,9 @@ """Support for Ecobee sensors.""" +from __future__ import annotations + from pyecobee.const import ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -11,45 +13,51 @@ from homeassistant.const import ( from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER -SENSOR_TYPES = { - "temperature": ["Temperature", TEMP_FAHRENHEIT, DEVICE_CLASS_TEMPERATURE], - "humidity": ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up ecobee (temperature and humidity) sensors.""" data = hass.data[DOMAIN] - dev = [] - for index in range(len(data.ecobee.thermostats)): - for sensor in data.ecobee.get_remote_sensors(index): - for item in sensor["capability"]: - if item["type"] not in ("temperature", "humidity"): - continue + entities = [ + EcobeeSensor(data, sensor["name"], index, description) + for index in range(len(data.ecobee.thermostats)) + for sensor in data.ecobee.get_remote_sensors(index) + for item in sensor["capability"] + for description in SENSOR_TYPES + if description.key == item["type"] + ] - dev.append(EcobeeSensor(data, sensor["name"], item["type"], index)) - - async_add_entities(dev, True) + async_add_entities(entities, True) class EcobeeSensor(SensorEntity): """Representation of an Ecobee sensor.""" - def __init__(self, data, sensor_name, sensor_type, sensor_index): + def __init__( + self, data, sensor_name, sensor_index, description: SensorEntityDescription + ): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._name = f"{sensor_name} {SENSOR_TYPES[sensor_type][0]}" self.sensor_name = sensor_name - self.type = sensor_type self.index = sensor_index self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] - @property - def name(self): - """Return the name of the Ecobee sensor.""" - return self._name + self._attr_name = f"{sensor_name} {description.name}" @property def unique_id(self): @@ -99,13 +107,6 @@ class EcobeeSensor(SensorEntity): thermostat = self.data.ecobee.get_thermostat(self.index) return thermostat["runtime"]["connected"] - @property - def device_class(self): - """Return the device class of the sensor.""" - if self.type in (DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE): - return self.type - return None - @property def native_value(self): """Return the state of the sensor.""" @@ -116,16 +117,11 @@ class EcobeeSensor(SensorEntity): ): return None - if self.type == "temperature": + if self.entity_description.key == "temperature": return float(self._state) / 10 return self._state - @property - def native_unit_of_measurement(self): - """Return the unit of measurement this sensor expresses itself in.""" - return self._unit_of_measurement - async def async_update(self): """Get the latest state of the sensor.""" await self.data.update() @@ -133,7 +129,7 @@ class EcobeeSensor(SensorEntity): if sensor["name"] != self.sensor_name: continue for item in sensor["capability"]: - if item["type"] != self.type: + if item["type"] != self.entity_description.key: continue self._state = item["value"] break From 45a32362aff8df1dc5d620fd7de763843beeecc0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 20:21:00 +0200 Subject: [PATCH 649/903] Use EntityDescription - wirelesstag (#55065) --- .../components/wirelesstag/switch.py | 70 ++++++++++++------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index 5866893888f..ca5391fdf96 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -1,31 +1,47 @@ """Switch implementation for Wireless Sensor Tags (wirelesstag.net).""" +from __future__ import annotations + import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.components.switch import ( + PLATFORM_SCHEMA, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.const import CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv from . import DOMAIN as WIRELESSTAG_DOMAIN, WirelessTagBaseSensor -ARM_TEMPERATURE = "temperature" -ARM_HUMIDITY = "humidity" -ARM_MOTION = "motion" -ARM_LIGHT = "light" -ARM_MOISTURE = "moisture" +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="temperature", + name="Arm Temperature", + ), + SwitchEntityDescription( + key="humidity", + name="Arm Humidity", + ), + SwitchEntityDescription( + key="motion", + name="Arm Motion", + ), + SwitchEntityDescription( + key="light", + name="Arm Light", + ), + SwitchEntityDescription( + key="moisture", + name="Arm Moisture", + ), +) -# Switch types: Name, tag sensor type -SWITCH_TYPES = { - ARM_TEMPERATURE: ["Arm Temperature", "temperature"], - ARM_HUMIDITY: ["Arm Humidity", "humidity"], - ARM_MOTION: ["Arm Motion", "motion"], - ARM_LIGHT: ["Arm Light", "light"], - ARM_MOISTURE: ["Arm Moisture", "moisture"], -} +SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SWITCH_TYPES)] + cv.ensure_list, [vol.In(SWITCH_KEYS)] ) } ) @@ -35,25 +51,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up switches for a Wireless Sensor Tags.""" platform = hass.data.get(WIRELESSTAG_DOMAIN) - switches = [] tags = platform.load_tags() - for switch_type in config.get(CONF_MONITORED_CONDITIONS): - for tag in tags.values(): - if switch_type in tag.allowed_monitoring_types: - switches.append(WirelessTagSwitch(platform, tag, switch_type)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + WirelessTagSwitch(platform, tag, description) + for tag in tags.values() + for description in SWITCH_TYPES + if description.key in monitored_conditions + and description.key in tag.allowed_monitoring_types + ] - add_entities(switches, True) + add_entities(entities, True) class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): """A switch implementation for Wireless Sensor Tags.""" - def __init__(self, api, tag, switch_type): + def __init__(self, api, tag, description: SwitchEntityDescription): """Initialize a switch for Wireless Sensor Tag.""" super().__init__(api, tag) - self._switch_type = switch_type - self.sensor_type = SWITCH_TYPES[self._switch_type][1] - self._name = f"{self._tag.name} {SWITCH_TYPES[self._switch_type][0]}" + self.entity_description = description + self._name = f"{self._tag.name} {description.name}" def turn_on(self, **kwargs): """Turn on the switch.""" @@ -75,5 +93,5 @@ class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): @property def principal_value(self): """Provide actual value of switch.""" - attr_name = f"is_{self.sensor_type}_sensor_armed" + attr_name = f"is_{self.entity_description.key}_sensor_armed" return getattr(self._tag, attr_name, False) From 90788245569fc3cc03b7becc9093709769a99814 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Aug 2021 20:22:55 +0200 Subject: [PATCH 650/903] Activate mypy for timer (#55058) --- homeassistant/components/timer/__init__.py | 3 ++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 2e92ddf6fd8..31b9b14c9da 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import Callable import voluptuous as vol @@ -196,7 +197,7 @@ class Timer(RestoreEntity): self._duration = cv.time_period_str(config[CONF_DURATION]) self._remaining: timedelta | None = None self._end: datetime | None = None - self._listener = None + self._listener: Callable[[], None] | None = None @classmethod def from_yaml(cls, config: dict) -> Timer: diff --git a/mypy.ini b/mypy.ini index 2d94878ee2a..afc6c9b28af 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1631,9 +1631,6 @@ ignore_errors = true [mypy-homeassistant.components.tesla.*] ignore_errors = true -[mypy-homeassistant.components.timer.*] -ignore_errors = true - [mypy-homeassistant.components.todoist.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index b761bdee919..454413624fb 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -136,7 +136,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.telegram_bot.*", "homeassistant.components.template.*", "homeassistant.components.tesla.*", - "homeassistant.components.timer.*", "homeassistant.components.todoist.*", "homeassistant.components.toon.*", "homeassistant.components.tplink.*", From 4ef376a9714325597968592832931c4bc285a580 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Aug 2021 20:23:53 +0200 Subject: [PATCH 651/903] Activate mypy for volumio (#55054) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/volumio/media_player.py | 2 -- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 6 deletions(-) diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 36555a0f94e..86747519149 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -36,8 +36,6 @@ from homeassistant.util import Throttle from .browse_media import browse_node, browse_top_level from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN -_CONFIGURING = {} - SUPPORT_VOLUMIO = ( SUPPORT_PAUSE | SUPPORT_VOLUME_SET diff --git a/mypy.ini b/mypy.ini index afc6c9b28af..f9db7f199cf 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1661,9 +1661,6 @@ ignore_errors = true [mypy-homeassistant.components.vizio.*] ignore_errors = true -[mypy-homeassistant.components.volumio.*] -ignore_errors = true - [mypy-homeassistant.components.wemo.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 454413624fb..a25760c4825 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -146,7 +146,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.vera.*", "homeassistant.components.verisure.*", "homeassistant.components.vizio.*", - "homeassistant.components.volumio.*", "homeassistant.components.wemo.*", "homeassistant.components.wink.*", "homeassistant.components.withings.*", From 0065bbc56d285c1c6e40462a59adcb5bd286f5cc Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 23 Aug 2021 19:47:09 +0100 Subject: [PATCH 652/903] Add volatile organic compounds to homekit_controller (#55093) --- .../components/homekit_controller/const.py | 23 ++++++++-- .../components/homekit_controller/sensor.py | 7 +++ .../homekit_controller/test_air_quality.py | 45 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 9e2ac1ce75b..fa28bab7606 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -50,8 +50,23 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: "sensor", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: "sensor", CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: "number", - CharacteristicsTypes.get_uuid(CharacteristicsTypes.TEMPERATURE_CURRENT): "sensor", - CharacteristicsTypes.get_uuid( - CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT - ): "sensor", + CharacteristicsTypes.TEMPERATURE_CURRENT: "sensor", + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: "sensor", + CharacteristicsTypes.AIR_QUALITY: "sensor", + CharacteristicsTypes.DENSITY_PM25: "sensor", + CharacteristicsTypes.DENSITY_PM10: "sensor", + CharacteristicsTypes.DENSITY_OZONE: "sensor", + CharacteristicsTypes.DENSITY_NO2: "sensor", + CharacteristicsTypes.DENSITY_SO2: "sensor", + CharacteristicsTypes.DENSITY_VOC: "sensor", } + +# For legacy reasons, "built-in" characteristic types are in their short form +# And vendor types don't have a short form +# This means long and short forms get mixed up in this dict, and comparisons +# don't work! +# We call get_uuid on *every* type to normalise them to the long form +# Eventually aiohomekit will use the long form exclusively amd this can be removed. +for k, v in list(CHARACTERISTIC_PLATFORMS.items()): + value = CHARACTERISTIC_PLATFORMS.pop(k) + CHARACTERISTIC_PLATFORMS[CharacteristicsTypes.get_uuid(k)] = value diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index ac4f19dadb4..cac61d59ac4 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, LIGHT_LUX, PERCENTAGE, POWER_WATT, @@ -114,6 +115,12 @@ SIMPLE_SENSOR = { "state_class": STATE_CLASS_MEASUREMENT, "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, }, + CharacteristicsTypes.DENSITY_VOC: { + "name": "Volatile Organic Compound Density", + "device_class": DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, } # For legacy reasons, "built-in" characteristic types are in their short form diff --git a/tests/components/homekit_controller/test_air_quality.py b/tests/components/homekit_controller/test_air_quality.py index f75335ca357..2477c6bacfd 100644 --- a/tests/components/homekit_controller/test_air_quality.py +++ b/tests/components/homekit_controller/test_air_quality.py @@ -2,6 +2,7 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER from homeassistant.helpers import entity_registry as er from tests.components.homekit_controller.common import setup_test_component @@ -53,3 +54,47 @@ async def test_air_quality_sensor_read_state(hass, utcnow): assert state.attributes["particulate_matter_2_5"] == 4444 assert state.attributes["particulate_matter_10"] == 5555 assert state.attributes["volatile_organic_compounds"] == 6666 + + +async def test_air_quality_sensor_read_state_even_if_air_quality_off(hass, utcnow): + """The air quality entity is disabled by default, the replacement sensors should always be available.""" + await setup_test_component(hass, create_air_quality_sensor_service) + + entity_registry = er.async_get(hass) + + sensors = [ + {"entity_id": "sensor.testdevice_air_quality"}, + { + "entity_id": "sensor.testdevice_pm10_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_pm2_5_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_pm10_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_ozone_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_sulphur_dioxide_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_nitrogen_dioxide_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_volatile_organic_compound_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + ] + + for sensor in sensors: + entry = entity_registry.async_get(sensor["entity_id"]) + assert entry is not None + assert entry.unit_of_measurement == sensor.get("units") From 32ac1340d80cde2fc6448366a7adaf54badd76f7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 20:52:05 +0200 Subject: [PATCH 653/903] Use EntityDescription - travisci (#55038) --- homeassistant/components/travisci/sensor.py | 121 +++++++++++--------- 1 file changed, 70 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index c4c68197677..427283260e0 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -1,4 +1,6 @@ """This component provides HA sensor support for Travis CI framework.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,7 +8,11 @@ from travispy import TravisPy from travispy.errors import TravisError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, @@ -27,15 +33,41 @@ DEFAULT_BRANCH_NAME = "master" SCAN_INTERVAL = timedelta(seconds=30) -# sensor_type [ description, unit, icon ] -SENSOR_TYPES = { - "last_build_id": ["Last Build ID", "", "mdi:card-account-details"], - "last_build_duration": ["Last Build Duration", TIME_SECONDS, "mdi:timelapse"], - "last_build_finished_at": ["Last Build Finished At", "", "mdi:timetable"], - "last_build_started_at": ["Last Build Started At", "", "mdi:timetable"], - "last_build_state": ["Last Build State", "", "mdi:github"], - "state": ["State", "", "mdi:github"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="last_build_id", + name="Last Build ID", + icon="mdi:card-account-details", + ), + SensorEntityDescription( + key="last_build_duration", + name="Last Build Duration", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timelapse", + ), + SensorEntityDescription( + key="last_build_finished_at", + name="Last Build Finished At", + icon="mdi:timetable", + ), + SensorEntityDescription( + key="last_build_started_at", + name="Last Build Started At", + icon="mdi:timetable", + ), + SensorEntityDescription( + key="last_build_state", + name="Last Build State", + icon="mdi:github", + ), + SensorEntityDescription( + key="state", + name="State", + icon="mdi:github", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] NOTIFICATION_ID = "travisci" NOTIFICATION_TITLE = "Travis CI Sensor Setup" @@ -43,8 +75,8 @@ NOTIFICATION_TITLE = "Travis CI Sensor Setup" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Required(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Required(CONF_BRANCH, default=DEFAULT_BRANCH_NAME): cv.string, vol.Optional(CONF_REPOSITORY, default=[]): vol.All(cv.ensure_list, [cv.string]), @@ -56,9 +88,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Travis CI sensor.""" - token = config.get(CONF_API_KEY) - repositories = config.get(CONF_REPOSITORY) - branch = config.get(CONF_BRANCH) + token = config[CONF_API_KEY] + repositories = config[CONF_REPOSITORY] + branch = config[CONF_BRANCH] try: travis = TravisPy.github_auth(token) @@ -75,52 +107,43 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ) return False - sensors = [] - # non specific repository selected, then show all associated if not repositories: all_repos = travis.repos(member=user.login) repositories = [repo.slug for repo in all_repos] + entities = [] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] for repo in repositories: if "/" not in repo: repo = f"{user.login}/{repo}" - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - sensors.append(TravisCISensor(travis, repo, user, branch, sensor_type)) + entities.extend( + [ + TravisCISensor(travis, repo, user, branch, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + ) - add_entities(sensors, True) - return True + add_entities(entities, True) class TravisCISensor(SensorEntity): """Representation of a Travis CI sensor.""" - def __init__(self, data, repo_name, user, branch, sensor_type): + def __init__( + self, data, repo_name, user, branch, description: SensorEntityDescription + ): """Initialize the sensor.""" + self.entity_description = description self._build = None - self._sensor_type = sensor_type self._data = data self._repo_name = repo_name self._user = user self._branch = branch - self._state = None - self._name = f"{self._repo_name} {SENSOR_TYPES[self._sensor_type][0]}" - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self._sensor_type][1] - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + self._attr_name = f"{repo_name} {description.name}" @property def extra_state_attributes(self): @@ -128,8 +151,8 @@ class TravisCISensor(SensorEntity): attrs = {} attrs[ATTR_ATTRIBUTION] = ATTRIBUTION - if self._build and self._state is not None: - if self._user and self._sensor_type == "state": + if self._build and self._attr_native_value is not None: + if self._user and self.entity_description.key == "state": attrs["Owner Name"] = self._user.name attrs["Owner Email"] = self._user.email else: @@ -141,23 +164,19 @@ class TravisCISensor(SensorEntity): return attrs - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return SENSOR_TYPES[self._sensor_type][2] - def update(self): """Get the latest data and updates the states.""" - _LOGGER.debug("Updating sensor %s", self._name) + _LOGGER.debug("Updating sensor %s", self.name) repo = self._data.repo(self._repo_name) self._build = self._data.build(repo.last_build_id) if self._build: - if self._sensor_type == "state": + sensor_type = self.entity_description.key + if sensor_type == "state": branch_stats = self._data.branch(self._branch, self._repo_name) - self._state = branch_stats.state + self._attr_native_value = branch_stats.state else: - param = self._sensor_type.replace("last_build_", "") - self._state = getattr(self._build, param) + param = sensor_type.replace("last_build_", "") + self._attr_native_value = getattr(self._build, param) From 4d452dbccf870845a8ba41aa9af6c8d14ccb238a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 20:56:15 +0200 Subject: [PATCH 654/903] Use EntityDescription - fido (#55037) --- homeassistant/components/fido/sensor.py | 221 +++++++++++++++++------- tests/components/fido/test_sensor.py | 8 +- 2 files changed, 162 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 0723e097967..0e61b580902 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -4,6 +4,8 @@ Support for Fido. Get data from 'Usage Summary' page: https://www.fido.ca/pages/#/my-account/wireless """ +from __future__ import annotations + from datetime import timedelta import logging @@ -11,7 +13,11 @@ from pyfido import FidoClient from pyfido.client import PyFidoError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_VARIABLES, CONF_NAME, @@ -33,33 +39,135 @@ DEFAULT_NAME = "Fido" REQUESTS_TIMEOUT = 15 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) -SENSOR_TYPES = { - "fido_dollar": ["Fido dollar", PRICE, "mdi:cash-usd"], - "balance": ["Balance", PRICE, "mdi:cash-usd"], - "data_used": ["Data used", DATA_KILOBITS, "mdi:download"], - "data_limit": ["Data limit", DATA_KILOBITS, "mdi:download"], - "data_remaining": ["Data remaining", DATA_KILOBITS, "mdi:download"], - "text_used": ["Text used", MESSAGES, "mdi:message-text"], - "text_limit": ["Text limit", MESSAGES, "mdi:message-text"], - "text_remaining": ["Text remaining", MESSAGES, "mdi:message-text"], - "mms_used": ["MMS used", MESSAGES, "mdi:message-image"], - "mms_limit": ["MMS limit", MESSAGES, "mdi:message-image"], - "mms_remaining": ["MMS remaining", MESSAGES, "mdi:message-image"], - "text_int_used": ["International text used", MESSAGES, "mdi:message-alert"], - "text_int_limit": ["International text limit", MESSAGES, "mdi:message-alert"], - "text_int_remaining": ["International remaining", MESSAGES, "mdi:message-alert"], - "talk_used": ["Talk used", TIME_MINUTES, "mdi:cellphone"], - "talk_limit": ["Talk limit", TIME_MINUTES, "mdi:cellphone"], - "talk_remaining": ["Talk remaining", TIME_MINUTES, "mdi:cellphone"], - "other_talk_used": ["Other Talk used", TIME_MINUTES, "mdi:cellphone"], - "other_talk_limit": ["Other Talk limit", TIME_MINUTES, "mdi:cellphone"], - "other_talk_remaining": ["Other Talk remaining", TIME_MINUTES, "mdi:cellphone"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="fido_dollar", + name="Fido dollar", + native_unit_of_measurement=PRICE, + icon="mdi:cash-usd", + ), + SensorEntityDescription( + key="balance", + name="Balance", + native_unit_of_measurement=PRICE, + icon="mdi:cash-usd", + ), + SensorEntityDescription( + key="data_used", + name="Data used", + native_unit_of_measurement=DATA_KILOBITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="data_limit", + name="Data limit", + native_unit_of_measurement=DATA_KILOBITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="data_remaining", + name="Data remaining", + native_unit_of_measurement=DATA_KILOBITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="text_used", + name="Text used", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-text", + ), + SensorEntityDescription( + key="text_limit", + name="Text limit", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-text", + ), + SensorEntityDescription( + key="text_remaining", + name="Text remaining", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-text", + ), + SensorEntityDescription( + key="mms_used", + name="MMS used", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-image", + ), + SensorEntityDescription( + key="mms_limit", + name="MMS limit", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-image", + ), + SensorEntityDescription( + key="mms_remaining", + name="MMS remaining", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-image", + ), + SensorEntityDescription( + key="text_int_used", + name="International text used", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-alert", + ), + SensorEntityDescription( + key="text_int_limit", + name="International text limit", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-alert", + ), + SensorEntityDescription( + key="text_int_remaining", + name="International remaining", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-alert", + ), + SensorEntityDescription( + key="talk_used", + name="Talk used", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="talk_limit", + name="Talk limit", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="talk_remaining", + name="Talk remaining", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="other_talk_used", + name="Other Talk used", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="other_talk_limit", + name="Other Talk limit", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="other_talk_remaining", + name="Other Talk remaining", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_VARIABLES): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -70,8 +178,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Fido sensor.""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] httpsession = hass.helpers.aiohttp_client.async_get_clientsession() fido_data = FidoData(username, password, httpsession) @@ -79,49 +187,28 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if ret is False: return - name = config.get(CONF_NAME) + name = config[CONF_NAME] + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities = [ + FidoSensor(fido_data, name, number, description) + for number in fido_data.client.get_phone_numbers() + for description in SENSOR_TYPES + if description.key in monitored_variables + ] - sensors = [] - for number in fido_data.client.get_phone_numbers(): - for variable in config[CONF_MONITORED_VARIABLES]: - sensors.append(FidoSensor(fido_data, variable, name, number)) - - async_add_entities(sensors, True) + async_add_entities(entities, True) class FidoSensor(SensorEntity): """Implementation of a Fido sensor.""" - def __init__(self, fido_data, sensor_type, name, number): + def __init__(self, fido_data, name, number, description: SensorEntityDescription): """Initialize the sensor.""" - self.client_name = name - self._number = number - self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description self.fido_data = fido_data - self._state = None + self._number = number - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._number} {self._name}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + self._attr_name = f"{name} {number} {description.name}" @property def extra_state_attributes(self): @@ -131,13 +218,15 @@ class FidoSensor(SensorEntity): async def async_update(self): """Get the latest data from Fido and update the state.""" await self.fido_data.async_update() - if self.type == "balance": - if self.fido_data.data.get(self.type) is not None: - self._state = round(self.fido_data.data[self.type], 2) + sensor_type = self.entity_description.key + if sensor_type == "balance": + if self.fido_data.data.get(sensor_type) is not None: + self._attr_native_value = round(self.fido_data.data[sensor_type], 2) else: - if self.fido_data.data.get(self._number, {}).get(self.type) is not None: - self._state = self.fido_data.data[self._number][self.type] - self._state = round(self._state, 2) + if self.fido_data.data.get(self._number, {}).get(sensor_type) is not None: + self._attr_native_value = round( + self.fido_data.data[self._number][sensor_type], 2 + ) class FidoData: diff --git a/tests/components/fido/test_sensor.py b/tests/components/fido/test_sensor.py index 3baaf2e350c..bcece50f6e4 100644 --- a/tests/components/fido/test_sensor.py +++ b/tests/components/fido/test_sensor.py @@ -66,7 +66,13 @@ async def test_error(hass, caplog): """Test the Fido sensor errors.""" caplog.set_level(logging.ERROR) - config = {} + config = { + "platform": "fido", + "name": "fido", + "username": "myusername", + "password": "password", + "monitored_variables": ["balance", "data_remaining"], + } fake_async_add_entities = MagicMock() with patch("homeassistant.components.fido.sensor.FidoClient", FidoClientMockError): await fido.async_setup_platform(hass, config, fake_async_add_entities) From 6ad0e0220a283184c9ffa537941333d09cd74fbd Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 23 Aug 2021 20:56:45 +0200 Subject: [PATCH 655/903] Update alarm control panel and deconz alarm event to reflect the finalized implementation in deCONZ (#54936) * Update alarm control panel and deconz alarm event to reflect the new implementation in deCONZ * Bump dependency to v83 --- .../components/deconz/alarm_control_panel.py | 72 +++-- .../components/deconz/deconz_event.py | 39 +-- homeassistant/components/deconz/manifest.json | 2 +- homeassistant/components/deconz/services.yaml | 22 -- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../deconz/test_alarm_control_panel.py | 255 ++++++++++-------- tests/components/deconz/test_deconz_event.py | 136 +++++++--- 8 files changed, 294 insertions(+), 236 deletions(-) diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index 4fc3e2ad0b8..c0ebc9d3134 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -5,13 +5,19 @@ from pydeconz.sensor import ( ANCILLARY_CONTROL_ARMED_AWAY, ANCILLARY_CONTROL_ARMED_NIGHT, ANCILLARY_CONTROL_ARMED_STAY, + ANCILLARY_CONTROL_ARMING_AWAY, + ANCILLARY_CONTROL_ARMING_NIGHT, + ANCILLARY_CONTROL_ARMING_STAY, ANCILLARY_CONTROL_DISARMED, + ANCILLARY_CONTROL_ENTRY_DELAY, + ANCILLARY_CONTROL_EXIT_DELAY, + ANCILLARY_CONTROL_IN_ALARM, AncillaryControl, ) -import voluptuous as vol from homeassistant.components.alarm_control_panel import ( DOMAIN, + FORMAT_NUMBER, SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, @@ -21,40 +27,39 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, ) from homeassistant.core import callback -from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import NEW_SENSOR from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry -PANEL_ENTRY_DELAY = "entry_delay" -PANEL_EXIT_DELAY = "exit_delay" -PANEL_NOT_READY_TO_ARM = "not_ready_to_arm" - -SERVICE_ALARM_PANEL_STATE = "alarm_panel_state" -CONF_ALARM_PANEL_STATE = "panel_state" -SERVICE_ALARM_PANEL_STATE_SCHEMA = { - vol.Required(CONF_ALARM_PANEL_STATE): vol.In( - [ - PANEL_ENTRY_DELAY, - PANEL_EXIT_DELAY, - PANEL_NOT_READY_TO_ARM, - ] - ) -} - DECONZ_TO_ALARM_STATE = { ANCILLARY_CONTROL_ARMED_AWAY: STATE_ALARM_ARMED_AWAY, ANCILLARY_CONTROL_ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, ANCILLARY_CONTROL_ARMED_STAY: STATE_ALARM_ARMED_HOME, + ANCILLARY_CONTROL_ARMING_AWAY: STATE_ALARM_ARMING, + ANCILLARY_CONTROL_ARMING_NIGHT: STATE_ALARM_ARMING, + ANCILLARY_CONTROL_ARMING_STAY: STATE_ALARM_ARMING, ANCILLARY_CONTROL_DISARMED: STATE_ALARM_DISARMED, + ANCILLARY_CONTROL_ENTRY_DELAY: STATE_ALARM_PENDING, + ANCILLARY_CONTROL_EXIT_DELAY: STATE_ALARM_PENDING, + ANCILLARY_CONTROL_IN_ALARM: STATE_ALARM_TRIGGERED, } +def get_alarm_system_for_unique_id(gateway, unique_id: str): + """Retrieve alarm system unique ID is registered to.""" + for alarm_system in gateway.api.alarm_systems.values(): + if unique_id in alarm_system.devices: + return alarm_system + + async def async_setup_entry(hass, config_entry, async_add_entities) -> None: """Set up the deCONZ alarm control panel devices. @@ -63,8 +68,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() - platform = entity_platform.async_get_current_platform() - @callback def async_add_alarm_control_panel(sensors=gateway.api.sensors.values()) -> None: """Add alarm control panel devices from deCONZ.""" @@ -75,15 +78,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: if ( sensor.type in AncillaryControl.ZHATYPE and sensor.uniqueid not in gateway.entities[DOMAIN] + and get_alarm_system_for_unique_id(gateway, sensor.uniqueid) ): + entities.append(DeconzAlarmControlPanel(sensor, gateway)) if entities: - platform.async_register_entity_service( - SERVICE_ALARM_PANEL_STATE, - SERVICE_ALARM_PANEL_STATE_SCHEMA, - "async_set_panel_state", - ) async_add_entities(entities) config_entry.async_on_unload( @@ -102,7 +102,7 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): TYPE = DOMAIN - _attr_code_arm_required = False + _attr_code_format = FORMAT_NUMBER _attr_supported_features = ( SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_NIGHT ) @@ -110,16 +110,12 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): def __init__(self, device, gateway) -> None: """Set up alarm control panel device.""" super().__init__(device, gateway) - self._service_to_device_panel_command = { - PANEL_ENTRY_DELAY: self._device.entry_delay, - PANEL_EXIT_DELAY: self._device.exit_delay, - PANEL_NOT_READY_TO_ARM: self._device.not_ready_to_arm, - } + self.alarm_system = get_alarm_system_for_unique_id(gateway, device.uniqueid) @callback def async_update_callback(self, force_update: bool = False) -> None: """Update the control panels state.""" - keys = {"armed", "reachable"} + keys = {"panel", "reachable"} if force_update or ( self._device.changed_keys.intersection(keys) and self._device.state in DECONZ_TO_ALARM_STATE @@ -133,20 +129,16 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): async def async_alarm_arm_away(self, code: None = None) -> None: """Send arm away command.""" - await self._device.arm_away() + await self.alarm_system.arm_away(code) async def async_alarm_arm_home(self, code: None = None) -> None: """Send arm home command.""" - await self._device.arm_stay() + await self.alarm_system.arm_stay(code) async def async_alarm_arm_night(self, code: None = None) -> None: """Send arm night command.""" - await self._device.arm_night() + await self.alarm_system.arm_night(code) async def async_alarm_disarm(self, code: None = None) -> None: """Send disarm command.""" - await self._device.disarm() - - async def async_set_panel_state(self, panel_state: str) -> None: - """Send panel_state command.""" - await self._service_to_device_panel_command[panel_state]() + await self.alarm_system.disarm(code) diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index c5336825878..2fa9ec87fe3 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -1,25 +1,20 @@ """Representation of a deCONZ remote or keypad.""" from pydeconz.sensor import ( - ANCILLARY_CONTROL_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY, - ANCILLARY_CONTROL_DISARMED, + ANCILLARY_CONTROL_EMERGENCY, + ANCILLARY_CONTROL_FIRE, + ANCILLARY_CONTROL_INVALID_CODE, + ANCILLARY_CONTROL_PANIC, AncillaryControl, Switch, ) from homeassistant.const import ( - CONF_CODE, CONF_DEVICE_ID, CONF_EVENT, CONF_ID, CONF_UNIQUE_ID, CONF_XY, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -31,11 +26,11 @@ from .deconz_device import DeconzBase CONF_DECONZ_EVENT = "deconz_event" CONF_DECONZ_ALARM_EVENT = "deconz_alarm_event" -DECONZ_TO_ALARM_STATE = { - ANCILLARY_CONTROL_ARMED_AWAY: STATE_ALARM_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY: STATE_ALARM_ARMED_HOME, - ANCILLARY_CONTROL_DISARMED: STATE_ALARM_DISARMED, +SUPPORTED_DECONZ_ALARM_EVENTS = { + ANCILLARY_CONTROL_EMERGENCY, + ANCILLARY_CONTROL_FIRE, + ANCILLARY_CONTROL_INVALID_CODE, + ANCILLARY_CONTROL_PANIC, } @@ -155,31 +150,23 @@ class DeconzEvent(DeconzBase): class DeconzAlarmEvent(DeconzEvent): - """Alarm control panel companion event when user inputs a code.""" + """Alarm control panel companion event when user interacts with a keypad.""" @callback def async_update_callback(self, force_update=False): - """Fire the event if reason is that state is updated.""" + """Fire the event if reason is new action is updated.""" if ( self.gateway.ignore_state_updates or "action" not in self._device.changed_keys + or self._device.action not in SUPPORTED_DECONZ_ALARM_EVENTS ): return - try: - state, code, _area = self._device.action.split(",") - except (AttributeError, ValueError): - return - - if state not in DECONZ_TO_ALARM_STATE: - return - data = { CONF_ID: self.event_id, CONF_UNIQUE_ID: self.serial, CONF_DEVICE_ID: self.device_id, - CONF_EVENT: DECONZ_TO_ALARM_STATE[state], - CONF_CODE: code, + CONF_EVENT: self._device.action, } self.gateway.hass.bus.async_fire(CONF_DECONZ_ALARM_EVENT, data) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 0ae9e8c98b0..69deac8b5e0 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", "requirements": [ - "pydeconz==82" + "pydeconz==83" ], "ssdp": [ { diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index fbaf47b009c..9084728a216 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -63,25 +63,3 @@ remove_orphaned_entries: example: "00212EFFFF012345" selector: text: - -alarm_panel_state: - name: Alarm panel state - description: Put keypad panel in an intermediate state, to help with visual and audible cues to the user. - target: - entity: - integration: deconz - domain: alarm_control_panel - fields: - panel_state: - name: Panel state - description: >- - - "entry_delay": make panel beep until panel is disarmed. Beep interval is long. - - "exit_delay": make panel beep until panel is set to armed state. Beep interval is short. - - "not_ready_to_arm": turn on yellow status led on the panel. Indicate not all conditions for arming are met. - required: true - selector: - select: - options: - - "entry_delay" - - "exit_delay" - - "not_ready_to_arm" diff --git a/requirements_all.txt b/requirements_all.txt index 81b94ac5f43..e79382fef33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1400,7 +1400,7 @@ pydaikin==2.4.4 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==82 +pydeconz==83 # homeassistant.components.delijn pydelijn==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86c2921598b..9704fdc168a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -797,7 +797,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.4.4 # homeassistant.components.deconz -pydeconz==82 +pydeconz==83 # homeassistant.components.dexcom pydexcom==0.2.0 diff --git a/tests/components/deconz/test_alarm_control_panel.py b/tests/components/deconz/test_alarm_control_panel.py index b8de1f10d85..5bafdc2fbb6 100644 --- a/tests/components/deconz/test_alarm_control_panel.py +++ b/tests/components/deconz/test_alarm_control_panel.py @@ -6,24 +6,21 @@ from pydeconz.sensor import ( ANCILLARY_CONTROL_ARMED_AWAY, ANCILLARY_CONTROL_ARMED_NIGHT, ANCILLARY_CONTROL_ARMED_STAY, + ANCILLARY_CONTROL_ARMING_AWAY, + ANCILLARY_CONTROL_ARMING_NIGHT, + ANCILLARY_CONTROL_ARMING_STAY, ANCILLARY_CONTROL_DISARMED, ANCILLARY_CONTROL_ENTRY_DELAY, ANCILLARY_CONTROL_EXIT_DELAY, - ANCILLARY_CONTROL_NOT_READY_TO_ARM, + ANCILLARY_CONTROL_IN_ALARM, + ANCILLARY_CONTROL_NOT_READY, ) from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) -from homeassistant.components.deconz.alarm_control_panel import ( - CONF_ALARM_PANEL_STATE, - PANEL_ENTRY_DELAY, - PANEL_EXIT_DELAY, - PANEL_NOT_READY_TO_ARM, - SERVICE_ALARM_PANEL_STATE, -) -from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, @@ -32,7 +29,10 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, STATE_UNAVAILABLE, ) @@ -52,38 +52,68 @@ async def test_no_sensors(hass, aioclient_mock): async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): """Test successful creation of alarm control panel entities.""" data = { + "alarmsystems": { + "0": { + "name": "default", + "config": { + "armmode": "armed_away", + "configured": True, + "disarmed_entry_delay": 0, + "disarmed_exit_delay": 0, + "armed_away_entry_delay": 120, + "armed_away_exit_delay": 120, + "armed_away_trigger_duration": 120, + "armed_stay_entry_delay": 120, + "armed_stay_exit_delay": 120, + "armed_stay_trigger_duration": 120, + "armed_night_entry_delay": 120, + "armed_night_exit_delay": 120, + "armed_night_trigger_duration": 120, + }, + "state": {"armstate": "armed_away", "seconds_remaining": 0}, + "devices": { + "00:00:00:00:00:00:00:00-00": {}, + "00:15:8d:00:02:af:95:f9-01-0101": { + "armmask": "AN", + "trigger": "state/vibration", + }, + }, + } + }, "sensors": { "0": { "config": { - "armed": "disarmed", - "enrolled": 0, + "battery": 95, + "enrolled": 1, "on": True, - "panel": "disarmed", "pending": [], "reachable": True, }, "ep": 1, - "etag": "3c4008d74035dfaa1f0bb30d24468b12", - "lastseen": "2021-04-02T13:07Z", - "manufacturername": "Universal Electronics Inc", - "modelid": "URC4450BC0-X-R", + "etag": "5aaa1c6bae8501f59929539c6e8f44d6", + "lastseen": "2021-07-25T18:07Z", + "manufacturername": "lk", + "modelid": "ZB-KeypadGeneric-D0002", "name": "Keypad", "state": { - "action": "armed_away,1111,55", - "lastupdated": "2021-04-02T13:08:18.937", + "action": "armed_stay", + "lastupdated": "2021-07-25T18:02:51.172", "lowbattery": False, - "tampered": True, + "panel": "exit_delay", + "seconds_remaining": 55, + "tampered": False, }, + "swversion": "3.13", "type": "ZHAAncillaryControl", - "uniqueid": "00:0d:6f:00:13:4f:61:39-01-0501", + "uniqueid": "00:00:00:00:00:00:00:00-00", } - } + }, } with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - assert len(hass.states.async_all()) == 2 - assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_DISARMED + assert len(hass.states.async_all()) == 3 + assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_PENDING # Event signals alarm control panel armed away @@ -92,7 +122,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "config": {"armed": ANCILLARY_CONTROL_ARMED_AWAY}, + "state": {"panel": ANCILLARY_CONTROL_ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -106,7 +136,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "config": {"armed": ANCILLARY_CONTROL_ARMED_NIGHT}, + "state": {"panel": ANCILLARY_CONTROL_ARMED_NIGHT}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -122,29 +152,13 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "config": {"armed": ANCILLARY_CONTROL_ARMED_STAY}, + "state": {"panel": ANCILLARY_CONTROL_ARMED_STAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_ARMED_HOME - # Event signals alarm control panel armed night - - event_changed_sensor = { - "t": "event", - "e": "changed", - "r": "sensors", - "id": "0", - "config": {"armed": ANCILLARY_CONTROL_ARMED_NIGHT}, - } - await mock_deconz_websocket(data=event_changed_sensor) - await hass.async_block_till_done() - - assert ( - hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_ARMED_NIGHT - ) - # Event signals alarm control panel disarmed event_changed_sensor = { @@ -152,116 +166,139 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "config": {"armed": ANCILLARY_CONTROL_DISARMED}, + "state": {"panel": ANCILLARY_CONTROL_DISARMED}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_DISARMED + # Event signals alarm control panel arming + + for arming_event in { + ANCILLARY_CONTROL_ARMING_AWAY, + ANCILLARY_CONTROL_ARMING_NIGHT, + ANCILLARY_CONTROL_ARMING_STAY, + }: + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "0", + "state": {"panel": arming_event}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_ARMING + + # Event signals alarm control panel pending + + for pending_event in {ANCILLARY_CONTROL_ENTRY_DELAY, ANCILLARY_CONTROL_EXIT_DELAY}: + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "0", + "state": {"panel": pending_event}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert ( + hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_PENDING + ) + + # Event signals alarm control panel triggered + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "0", + "state": {"panel": ANCILLARY_CONTROL_IN_ALARM}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_TRIGGERED + + # Event signals alarm control panel unknown state keeps previous state + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "0", + "state": {"panel": ANCILLARY_CONTROL_NOT_READY}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_TRIGGERED + # Verify service calls - mock_deconz_put_request(aioclient_mock, config_entry.data, "/sensors/0/config") - # Service set alarm to away mode + mock_deconz_put_request( + aioclient_mock, config_entry.data, "/alarmsystems/0/arm_away" + ) + await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_AWAY, - {ATTR_ENTITY_ID: "alarm_control_panel.keypad"}, + {ATTR_ENTITY_ID: "alarm_control_panel.keypad", ATTR_CODE: "1234"}, blocking=True, ) - assert aioclient_mock.mock_calls[1][2] == { - "armed": ANCILLARY_CONTROL_ARMED_AWAY, - "panel": ANCILLARY_CONTROL_ARMED_AWAY, - } + assert aioclient_mock.mock_calls[1][2] == {"code0": "1234"} # Service set alarm to home mode + mock_deconz_put_request( + aioclient_mock, config_entry.data, "/alarmsystems/0/arm_stay" + ) + await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_HOME, - {ATTR_ENTITY_ID: "alarm_control_panel.keypad"}, + {ATTR_ENTITY_ID: "alarm_control_panel.keypad", ATTR_CODE: "2345"}, blocking=True, ) - assert aioclient_mock.mock_calls[2][2] == { - "armed": ANCILLARY_CONTROL_ARMED_STAY, - "panel": ANCILLARY_CONTROL_ARMED_STAY, - } + assert aioclient_mock.mock_calls[2][2] == {"code0": "2345"} # Service set alarm to night mode + mock_deconz_put_request( + aioclient_mock, config_entry.data, "/alarmsystems/0/arm_night" + ) + await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_NIGHT, - {ATTR_ENTITY_ID: "alarm_control_panel.keypad"}, + {ATTR_ENTITY_ID: "alarm_control_panel.keypad", ATTR_CODE: "3456"}, blocking=True, ) - assert aioclient_mock.mock_calls[3][2] == { - "armed": ANCILLARY_CONTROL_ARMED_NIGHT, - "panel": ANCILLARY_CONTROL_ARMED_NIGHT, - } + assert aioclient_mock.mock_calls[3][2] == {"code0": "3456"} # Service set alarm to disarmed + mock_deconz_put_request(aioclient_mock, config_entry.data, "/alarmsystems/0/disarm") + await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_DISARM, - {ATTR_ENTITY_ID: "alarm_control_panel.keypad"}, + {ATTR_ENTITY_ID: "alarm_control_panel.keypad", ATTR_CODE: "4567"}, blocking=True, ) - assert aioclient_mock.mock_calls[4][2] == { - "armed": ANCILLARY_CONTROL_DISARMED, - "panel": ANCILLARY_CONTROL_DISARMED, - } - - # Verify entity service calls - - # Service set panel to entry delay - - await hass.services.async_call( - DECONZ_DOMAIN, - SERVICE_ALARM_PANEL_STATE, - { - ATTR_ENTITY_ID: "alarm_control_panel.keypad", - CONF_ALARM_PANEL_STATE: PANEL_ENTRY_DELAY, - }, - blocking=True, - ) - assert aioclient_mock.mock_calls[5][2] == {"panel": ANCILLARY_CONTROL_ENTRY_DELAY} - - # Service set panel to exit delay - - await hass.services.async_call( - DECONZ_DOMAIN, - SERVICE_ALARM_PANEL_STATE, - { - ATTR_ENTITY_ID: "alarm_control_panel.keypad", - CONF_ALARM_PANEL_STATE: PANEL_EXIT_DELAY, - }, - blocking=True, - ) - assert aioclient_mock.mock_calls[6][2] == {"panel": ANCILLARY_CONTROL_EXIT_DELAY} - - # Service set panel to not ready to arm - - await hass.services.async_call( - DECONZ_DOMAIN, - SERVICE_ALARM_PANEL_STATE, - { - ATTR_ENTITY_ID: "alarm_control_panel.keypad", - CONF_ALARM_PANEL_STATE: PANEL_NOT_READY_TO_ARM, - }, - blocking=True, - ) - assert aioclient_mock.mock_calls[7][2] == { - "panel": ANCILLARY_CONTROL_NOT_READY_TO_ARM - } + assert aioclient_mock.mock_calls[4][2] == {"code0": "4567"} await hass.config_entries.async_unload(config_entry.entry_id) states = hass.states.async_all() - assert len(states) == 2 + assert len(states) == 3 for state in states: assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index 418545f11cf..ca76631728e 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -2,13 +2,20 @@ from unittest.mock import patch +from pydeconz.sensor import ( + ANCILLARY_CONTROL_ARMED_AWAY, + ANCILLARY_CONTROL_EMERGENCY, + ANCILLARY_CONTROL_FIRE, + ANCILLARY_CONTROL_INVALID_CODE, + ANCILLARY_CONTROL_PANIC, +) + from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.deconz.deconz_event import ( CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT, ) from homeassistant.const import ( - CONF_CODE, CONF_DEVICE_ID, CONF_EVENT, CONF_ID, @@ -200,39 +207,69 @@ async def test_deconz_events(hass, aioclient_mock, mock_deconz_websocket): async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): """Test successful creation of deconz alarm events.""" data = { + "alarmsystems": { + "0": { + "name": "default", + "config": { + "armmode": "armed_away", + "configured": True, + "disarmed_entry_delay": 0, + "disarmed_exit_delay": 0, + "armed_away_entry_delay": 120, + "armed_away_exit_delay": 120, + "armed_away_trigger_duration": 120, + "armed_stay_entry_delay": 120, + "armed_stay_exit_delay": 120, + "armed_stay_trigger_duration": 120, + "armed_night_entry_delay": 120, + "armed_night_exit_delay": 120, + "armed_night_trigger_duration": 120, + }, + "state": {"armstate": "armed_away", "seconds_remaining": 0}, + "devices": { + "00:00:00:00:00:00:00:01-00": {}, + "00:15:8d:00:02:af:95:f9-01-0101": { + "armmask": "AN", + "trigger": "state/vibration", + }, + }, + } + }, "sensors": { "1": { "config": { - "armed": "disarmed", - "enrolled": 0, + "battery": 95, + "enrolled": 1, "on": True, - "panel": "disarmed", "pending": [], "reachable": True, }, "ep": 1, - "etag": "3c4008d74035dfaa1f0bb30d24468b12", - "lastseen": "2021-04-02T13:07Z", - "manufacturername": "Universal Electronics Inc", - "modelid": "URC4450BC0-X-R", + "etag": "5aaa1c6bae8501f59929539c6e8f44d6", + "lastseen": "2021-07-25T18:07Z", + "manufacturername": "lk", + "modelid": "ZB-KeypadGeneric-D0002", "name": "Keypad", "state": { - "action": "armed_away,1111,55", - "lastupdated": "2021-04-02T13:08:18.937", + "action": "invalid_code", + "lastupdated": "2021-07-25T18:02:51.172", "lowbattery": False, - "tampered": True, + "panel": "exit_delay", + "seconds_remaining": 55, + "tampered": False, }, + "swversion": "3.13", "type": "ZHAAncillaryControl", - "uniqueid": "00:00:00:00:00:00:00:01-01-0501", + "uniqueid": "00:00:00:00:00:00:00:01-00", } - } + }, } with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) device_registry = await hass.helpers.device_registry.async_get_registry() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 # 1 alarm control device + 2 additional devices for deconz service and host assert ( len(async_entries_for_config_entry(device_registry, config_entry.entry_id)) == 3 @@ -240,14 +277,14 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): captured_events = async_capture_events(hass, CONF_DECONZ_ALARM_EVENT) - # Armed away event + # Emergency event event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "state": {"action": "armed_away,1234,1"}, + "state": {"action": ANCILLARY_CONTROL_EMERGENCY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -261,86 +298,113 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): CONF_ID: "keypad", CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", CONF_DEVICE_ID: device.id, - CONF_EVENT: "armed_away", - CONF_CODE: "1234", + CONF_EVENT: ANCILLARY_CONTROL_EMERGENCY, } - # Unsupported events - - # Bad action string; string is None + # Fire event event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "state": {"action": None}, + "state": {"action": ANCILLARY_CONTROL_FIRE}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() - assert len(captured_events) == 1 + device = device_registry.async_get_device( + identifiers={(DECONZ_DOMAIN, "00:00:00:00:00:00:00:01")} + ) - # Bad action string; empty string + assert len(captured_events) == 2 + assert captured_events[1].data == { + CONF_ID: "keypad", + CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", + CONF_DEVICE_ID: device.id, + CONF_EVENT: ANCILLARY_CONTROL_FIRE, + } + + # Invalid code event event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ""}, + "state": {"action": ANCILLARY_CONTROL_INVALID_CODE}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() - assert len(captured_events) == 1 + device = device_registry.async_get_device( + identifiers={(DECONZ_DOMAIN, "00:00:00:00:00:00:00:01")} + ) - # Bad action string; too few "," + assert len(captured_events) == 3 + assert captured_events[2].data == { + CONF_ID: "keypad", + CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", + CONF_DEVICE_ID: device.id, + CONF_EVENT: ANCILLARY_CONTROL_INVALID_CODE, + } + + # Panic event event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "state": {"action": "armed_away,1234"}, + "state": {"action": ANCILLARY_CONTROL_PANIC}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() - assert len(captured_events) == 1 + device = device_registry.async_get_device( + identifiers={(DECONZ_DOMAIN, "00:00:00:00:00:00:00:01")} + ) - # Bad action string; unsupported command + assert len(captured_events) == 4 + assert captured_events[3].data == { + CONF_ID: "keypad", + CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", + CONF_DEVICE_ID: device.id, + CONF_EVENT: ANCILLARY_CONTROL_PANIC, + } + + # Only care for changes to specific action events event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "state": {"action": "unsupported,1234,1"}, + "state": {"action": ANCILLARY_CONTROL_ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() - assert len(captured_events) == 1 + assert len(captured_events) == 4 - # Only care for changes to action + # Only care for action events event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "config": {"panel": "armed_away"}, + "state": {"panel": ANCILLARY_CONTROL_ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() - assert len(captured_events) == 1 + assert len(captured_events) == 4 await hass.config_entries.async_unload(config_entry.entry_id) states = hass.states.async_all() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 for state in states: assert state.state == STATE_UNAVAILABLE From a5c1fbcb1a25844b496ede01eeab482fa23612a4 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Aug 2021 20:57:37 +0200 Subject: [PATCH 656/903] Activate mypy for velbus (#55055) --- homeassistant/components/velbus/config_flow.py | 4 +++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index a10d59bad4a..93dd68c9eea 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -1,4 +1,6 @@ """Config flow for the Velbus platform.""" +from __future__ import annotations + import velbus import voluptuous as vol @@ -25,7 +27,7 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the velbus config flow.""" - self._errors = {} + self._errors: dict[str, str] = {} def _create_device(self, name: str, prt: str): """Create an entry async.""" diff --git a/mypy.ini b/mypy.ini index f9db7f199cf..c0495cd5c4c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1649,9 +1649,6 @@ ignore_errors = true [mypy-homeassistant.components.upnp.*] ignore_errors = true -[mypy-homeassistant.components.velbus.*] -ignore_errors = true - [mypy-homeassistant.components.vera.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index a25760c4825..ef681cc5ea3 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -142,7 +142,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.tuya.*", "homeassistant.components.unifi.*", "homeassistant.components.upnp.*", - "homeassistant.components.velbus.*", "homeassistant.components.vera.*", "homeassistant.components.verisure.*", "homeassistant.components.vizio.*", From 99465f53c773ea81d751f4bd4deabf876f9983fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Aug 2021 14:00:26 -0500 Subject: [PATCH 657/903] Bump HAP-python to 4.1.0 (#55005) --- homeassistant/components/homekit/__init__.py | 6 +----- homeassistant/components/homekit/accessories.py | 4 ++-- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homekit/test_accessories.py | 4 ++-- tests/components/homekit/test_homekit.py | 8 ++++---- 7 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 705b671f28a..58f3ab14ca9 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -532,11 +532,6 @@ class HomeKit: # as pyhap uses a random one until state is restored if os.path.exists(persist_file): self.driver.load() - self.driver.state.config_version += 1 - if self.driver.state.config_version > 65535: - self.driver.state.config_version = 1 - - self.driver.persist() async def async_reset_accessories(self, entity_ids): """Reset the accessory to load the latest configuration.""" @@ -688,6 +683,7 @@ class HomeKit: self._async_register_bridge() _LOGGER.debug("Driver start for %s", self._name) await self.driver.async_start() + self.driver.async_persist() self.status = STATUS_RUNNING if self.driver.state.paired: diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index ec6ef670f44..2cd63facf24 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -528,9 +528,9 @@ class HomeDriver(AccessoryDriver): self._bridge_name = bridge_name self._entry_title = entry_title - def pair(self, client_uuid, client_public): + def pair(self, client_uuid, client_public, client_permissions): """Override super function to dismiss setup message if paired.""" - success = super().pair(client_uuid, client_public) + success = super().pair(client_uuid, client_public, client_permissions) if success: dismiss_setup_message(self.hass, self._entry_id) return success diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index c63ce2a8927..e40d743068c 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==4.0.0", + "HAP-python==4.1.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/requirements_all.txt b/requirements_all.txt index e79382fef33..1163c6f5a5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -14,7 +14,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==4.0.0 +HAP-python==4.1.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9704fdc168a..a32c0fad370 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.homekit -HAP-python==4.0.0 +HAP-python==4.1.0 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 975864b42d5..3f08ca6fda2 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -696,9 +696,9 @@ def test_home_driver(): with patch("pyhap.accessory_driver.AccessoryDriver.pair") as mock_pair, patch( "homeassistant.components.homekit.accessories.dismiss_setup_message" ) as mock_dissmiss_msg: - driver.pair("client_uuid", "client_public") + driver.pair("client_uuid", "client_public", b"1") - mock_pair.assert_called_with("client_uuid", "client_public") + mock_pair.assert_called_with("client_uuid", "client_public", b"1") mock_dissmiss_msg.assert_called_with("hass", "entry_id") # unpair diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 138c8fd8209..17c03ba1dcd 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -560,7 +560,7 @@ async def test_homekit_start(hass, hk_driver, mock_zeroconf, device_reg): assert (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections assert len(device_reg.devices) == 1 - assert homekit.driver.state.config_version == 2 + assert homekit.driver.state.config_version == 1 async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroconf): @@ -695,7 +695,7 @@ async def test_homekit_unpair(hass, device_reg, mock_zeroconf): homekit.status = STATUS_RUNNING state = homekit.driver.state - state.paired_clients = {"client1": "any"} + state.add_paired_client("client1", "any", b"1") formatted_mac = device_registry.format_mac(state.mac) hk_bridge_dev = device_reg.async_get_device( {}, {(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)} @@ -734,7 +734,7 @@ async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_zeroconf) homekit.status = STATUS_RUNNING state = homekit.driver.state - state.paired_clients = {"client1": "any"} + state.add_paired_client("client1", "any", b"1") with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, @@ -780,7 +780,7 @@ async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_zeroconf ) state = homekit.driver.state - state.paired_clients = {"client1": "any"} + state.add_paired_client("client1", "any", b"1") with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, From 20f94d7ad47082ec2f1c204130abe318c4b014dd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 21:05:57 +0200 Subject: [PATCH 658/903] Use EntityDescription - pushbullet (#54999) --- homeassistant/components/pushbullet/sensor.py | 104 +++++++++++------- 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 3585c198a51..f7c946a91d9 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -1,34 +1,72 @@ """Pushbullet platform for sensor component.""" +from __future__ import annotations + import logging import threading from pushbullet import InvalidKeyError, Listener, PushBullet import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "application_name": ["Application name"], - "body": ["Body"], - "notification_id": ["Notification ID"], - "notification_tag": ["Notification tag"], - "package_name": ["Package name"], - "receiver_email": ["Receiver email"], - "sender_email": ["Sender email"], - "source_device_iden": ["Sender device ID"], - "title": ["Title"], - "type": ["Type"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="application_name", + name="Application name", + ), + SensorEntityDescription( + key="body", + name="Body", + ), + SensorEntityDescription( + key="notification_id", + name="Notification ID", + ), + SensorEntityDescription( + key="notification_tag", + name="Notification tag", + ), + SensorEntityDescription( + key="package_name", + name="Package name", + ), + SensorEntityDescription( + key="receiver_email", + name="Receiver email", + ), + SensorEntityDescription( + key="sender_email", + name="Sender email", + ), + SensorEntityDescription( + key="source_device_iden", + name="Sender device ID", + ), + SensorEntityDescription( + key="title", + name="Title", + ), + SensorEntityDescription( + key="type", + name="Type", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS, default=["title", "body"]): vol.All( - cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)] + cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_KEYS)] ), } ) @@ -45,21 +83,24 @@ def setup_platform(hass, config, add_entities, discovery_info=None): pbprovider = PushBulletNotificationProvider(pushbullet) - devices = [] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - devices.append(PushBulletNotificationSensor(pbprovider, sensor_type)) - add_entities(devices) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + PushBulletNotificationSensor(pbprovider, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + add_entities(entities) class PushBulletNotificationSensor(SensorEntity): """Representation of a Pushbullet Sensor.""" - def __init__(self, pb, element): + def __init__(self, pb, description: SensorEntityDescription): """Initialize the Pushbullet sensor.""" + self.entity_description = description self.pushbullet = pb - self._element = element - self._state = None - self._state_attributes = None + + self._attr_name = f"Pushbullet {description.key}" def update(self): """Fetch the latest data from the sensor. @@ -68,26 +109,11 @@ class PushBulletNotificationSensor(SensorEntity): attributes into self._state_attributes. """ try: - self._state = self.pushbullet.data[self._element] - self._state_attributes = self.pushbullet.data + self._attr_native_value = self.pushbullet.data[self.entity_description.key] + self._attr_extra_state_attributes = self.pushbullet.data except (KeyError, TypeError): pass - @property - def name(self): - """Return the name of the sensor.""" - return f"Pushbullet {self._element}" - - @property - def native_value(self): - """Return the current state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return all known attributes of the sensor.""" - return self._state_attributes - class PushBulletNotificationProvider: """Provider for an account, leading to one or more sensors.""" From 6637ed48685e3e9628fa4641dab564c3c739869d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 21:20:35 +0200 Subject: [PATCH 659/903] Use EntityDescription - deluge (#55085) --- homeassistant/components/deluge/sensor.py | 101 +++++++++++----------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 63c9645dac4..a5667f35064 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -1,10 +1,16 @@ """Support for monitoring the Deluge BitTorrent client API.""" +from __future__ import annotations + import logging from deluge_client import DelugeRPCClient, FailedToReconnectException import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_MONITORED_VARIABLES, @@ -25,11 +31,24 @@ DEFAULT_NAME = "Deluge" DEFAULT_PORT = 58846 DHT_UPLOAD = 1000 DHT_DOWNLOAD = 1000 -SENSOR_TYPES = { - "current_status": ["Status", None], - "download_speed": ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], - "upload_speed": ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="current_status", + name="Status", + ), + SensorEntityDescription( + key="download_speed", + name="Down Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), + SensorEntityDescription( + key="upload_speed", + name="Up Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -39,7 +58,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -60,46 +79,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except ConnectionRefusedError as err: _LOGGER.error("Connection to Deluge Daemon failed") raise PlatformNotReady from err - dev = [] - for variable in config[CONF_MONITORED_VARIABLES]: - dev.append(DelugeSensor(variable, deluge_api, name)) + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities = [ + DelugeSensor(deluge_api, name, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] - add_entities(dev) + add_entities(entities) class DelugeSensor(SensorEntity): """Representation of a Deluge sensor.""" - def __init__(self, sensor_type, deluge_client, client_name): + def __init__( + self, deluge_client, client_name, description: SensorEntityDescription + ): """Initialize the sensor.""" - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.client = deluge_client - self.type = sensor_type - self.client_name = client_name - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self.data = None - self._available = False - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return true if device is available.""" - return self._available - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_available = False + self._attr_name = f"{client_name} {description.name}" def update(self): """Get the latest data from Deluge and updates the state.""" @@ -114,34 +116,35 @@ class DelugeSensor(SensorEntity): "dht_download_rate", ], ) - self._available = True + self._attr_available = True except FailedToReconnectException: _LOGGER.error("Connection to Deluge Daemon Lost") - self._available = False + self._attr_available = False return upload = self.data[b"upload_rate"] - self.data[b"dht_upload_rate"] download = self.data[b"download_rate"] - self.data[b"dht_download_rate"] - if self.type == "current_status": + sensor_type = self.entity_description.key + if sensor_type == "current_status": if self.data: if upload > 0 and download > 0: - self._state = "Up/Down" + self._attr_native_value = "Up/Down" elif upload > 0 and download == 0: - self._state = "Seeding" + self._attr_native_value = "Seeding" elif upload == 0 and download > 0: - self._state = "Downloading" + self._attr_native_value = "Downloading" else: - self._state = STATE_IDLE + self._attr_native_value = STATE_IDLE else: - self._state = None + self._attr_native_value = None if self.data: - if self.type == "download_speed": + if sensor_type == "download_speed": kb_spd = float(download) kb_spd = kb_spd / 1024 - self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1) - elif self.type == "upload_speed": + self._attr_native_value = round(kb_spd, 2 if kb_spd < 0.1 else 1) + elif sensor_type == "upload_speed": kb_spd = float(upload) kb_spd = kb_spd / 1024 - self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1) + self._attr_native_value = round(kb_spd, 2 if kb_spd < 0.1 else 1) From 791ccca042f07ed78096e666048026d3e3654ad0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 21:29:22 +0200 Subject: [PATCH 660/903] Use EntityDescription - openevse (#55084) --- homeassistant/components/openevse/sensor.py | 128 ++++++++++++-------- 1 file changed, 75 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index a1920e145bb..3459671c829 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -1,11 +1,17 @@ """Support for monitoring an OpenEVSE Charger.""" +from __future__ import annotations + import logging import openevsewifi from requests import RequestException import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_MONITORED_VARIABLES, @@ -18,21 +24,53 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "status": ["Charging Status", None, None], - "charge_time": ["Charge Time Elapsed", TIME_MINUTES, None], - "ambient_temp": ["Ambient Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "ir_temp": ["IR Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "rtc_temp": ["RTC Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "usage_session": ["Usage this Session", ENERGY_KILO_WATT_HOUR, None], - "usage_total": ["Total Usage", ENERGY_KILO_WATT_HOUR, None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="status", + name="Charging Status", + ), + SensorEntityDescription( + key="charge_time", + name="Charge Time Elapsed", + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key="ambient_temp", + name="Ambient Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="ir_temp", + name="IR Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="rtc_temp", + name="RTC Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="usage_session", + name="Usage this Session", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + SensorEntityDescription( + key="usage_total", + name="Total Usage", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_MONITORED_VARIABLES, default=["status"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -40,63 +78,47 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the OpenEVSE sensor.""" - host = config.get(CONF_HOST) - monitored_variables = config.get(CONF_MONITORED_VARIABLES) + host = config[CONF_HOST] + monitored_variables = config[CONF_MONITORED_VARIABLES] charger = openevsewifi.Charger(host) - dev = [] - for variable in monitored_variables: - dev.append(OpenEVSESensor(variable, charger)) + entities = [ + OpenEVSESensor(charger, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] - add_entities(dev, True) + add_entities(entities, True) class OpenEVSESensor(SensorEntity): """Implementation of an OpenEVSE sensor.""" - def __init__(self, sensor_type, charger): + def __init__(self, charger, description: SensorEntityDescription): """Initialize the sensor.""" - self._name = SENSOR_TYPES[sensor_type][0] - self.type = sensor_type - self._state = None + self.entity_description = description self.charger = charger - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this sensor.""" - return self._unit_of_measurement def update(self): """Get the monitored data from the charger.""" try: - if self.type == "status": - self._state = self.charger.getStatus() - elif self.type == "charge_time": - self._state = self.charger.getChargeTimeElapsed() / 60 - elif self.type == "ambient_temp": - self._state = self.charger.getAmbientTemperature() - elif self.type == "ir_temp": - self._state = self.charger.getIRTemperature() - elif self.type == "rtc_temp": - self._state = self.charger.getRTCTemperature() - elif self.type == "usage_session": - self._state = float(self.charger.getUsageSession()) / 1000 - elif self.type == "usage_total": - self._state = float(self.charger.getUsageTotal()) / 1000 + sensor_type = self.entity_description.key + if sensor_type == "status": + self._attr_native_value = self.charger.getStatus() + elif sensor_type == "charge_time": + self._attr_native_value = self.charger.getChargeTimeElapsed() / 60 + elif sensor_type == "ambient_temp": + self._attr_native_value = self.charger.getAmbientTemperature() + elif sensor_type == "ir_temp": + self._attr_native_value = self.charger.getIRTemperature() + elif sensor_type == "rtc_temp": + self._attr_native_value = self.charger.getRTCTemperature() + elif sensor_type == "usage_session": + self._attr_native_value = float(self.charger.getUsageSession()) / 1000 + elif sensor_type == "usage_total": + self._attr_native_value = float(self.charger.getUsageTotal()) / 1000 else: - self._state = "Unknown" + self._attr_native_value = "Unknown" except (RequestException, ValueError, KeyError): _LOGGER.warning("Could not update status for %s", self.name) From d5c26aece1eec91fb3928acbf6ec04acd2fd63aa Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Aug 2021 21:30:04 +0200 Subject: [PATCH 661/903] Activate mypy for tuya (#55057) --- homeassistant/components/tuya/config_flow.py | 5 ++++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 970a1c54f1e..476a2295fc4 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -1,5 +1,8 @@ """Config flow for Tuya.""" +from __future__ import annotations + import logging +from typing import Any from tuyaha import TuyaApi from tuyaha.tuyaapi import ( @@ -155,7 +158,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry self._conf_devs_id = None - self._conf_devs_option = {} + self._conf_devs_option: dict[str, Any] = {} self._form_error = None def _get_form_error(self): diff --git a/mypy.ini b/mypy.ini index c0495cd5c4c..f425c3179ed 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1640,9 +1640,6 @@ ignore_errors = true [mypy-homeassistant.components.tplink.*] ignore_errors = true -[mypy-homeassistant.components.tuya.*] -ignore_errors = true - [mypy-homeassistant.components.unifi.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index ef681cc5ea3..a8b26b8c6f5 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -139,7 +139,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.todoist.*", "homeassistant.components.toon.*", "homeassistant.components.tplink.*", - "homeassistant.components.tuya.*", "homeassistant.components.unifi.*", "homeassistant.components.upnp.*", "homeassistant.components.vera.*", From 3bd309299ef17c2ed55ba8a5724bb78af9fa2b50 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 21:38:40 +0200 Subject: [PATCH 662/903] Use EntityDescription - foobot (#54996) --- homeassistant/components/foobot/sensor.py | 117 +++++++++++----------- 1 file changed, 60 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index dd9f086d0d9..21510771cd5 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -1,4 +1,6 @@ """Support for the Foobot indoor air quality monitor.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -7,7 +9,7 @@ import aiohttp from foobot_async import FoobotClient import voluptuous as vol -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_TIME, @@ -36,25 +38,49 @@ ATTR_CARBON_DIOXIDE = "CO2" ATTR_VOLATILE_ORGANIC_COMPOUNDS = "VOC" ATTR_FOOBOT_INDEX = "index" -SENSOR_TYPES = { - "time": [ATTR_TIME, TIME_SECONDS, None, None], - "pm": [ATTR_PM2_5, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "mdi:cloud", None], - "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "hum": [ATTR_HUMIDITY, PERCENTAGE, "mdi:water-percent", None], - "co2": [ - ATTR_CARBON_DIOXIDE, - CONCENTRATION_PARTS_PER_MILLION, - "mdi:molecule-co2", - None, - ], - "voc": [ - ATTR_VOLATILE_ORGANIC_COMPOUNDS, - CONCENTRATION_PARTS_PER_BILLION, - "mdi:cloud", - None, - ], - "allpollu": [ATTR_FOOBOT_INDEX, PERCENTAGE, "mdi:percent", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="time", + name=ATTR_TIME, + native_unit_of_measurement=TIME_SECONDS, + ), + SensorEntityDescription( + key="pm", + name=ATTR_PM2_5, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:cloud", + ), + SensorEntityDescription( + key="tmp", + name=ATTR_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="hum", + name=ATTR_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="co2", + name=ATTR_CARBON_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon="mdi:molecule-co2", + ), + SensorEntityDescription( + key="voc", + name=ATTR_VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + icon="mdi:cloud", + ), + SensorEntityDescription( + key="allpollu", + name=ATTR_FOOBOT_INDEX, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), +) SCAN_INTERVAL = timedelta(minutes=10) PARALLEL_UPDATES = 1 @@ -74,17 +100,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= client = FoobotClient( token, username, async_get_clientsession(hass), timeout=TIMEOUT ) - dev = [] + entities = [] try: devices = await client.get_devices() _LOGGER.debug("The following devices were found: %s", devices) for device in devices: foobot_data = FoobotData(client, device["uuid"]) - for sensor_type in SENSOR_TYPES: - if sensor_type == "time": - continue - foobot_sensor = FoobotSensor(foobot_data, device, sensor_type) - dev.append(foobot_sensor) + entities.extend( + [ + FoobotSensor(foobot_data, device, description) + for description in SENSOR_TYPES + if description.key != "time" + ] + ) except ( aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError, @@ -96,54 +124,29 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= except FoobotClient.ClientError: _LOGGER.error("Failed to fetch data from foobot servers") return - async_add_entities(dev, True) + async_add_entities(entities, True) class FoobotSensor(SensorEntity): """Implementation of a Foobot sensor.""" - def __init__(self, data, device, sensor_type): + def __init__(self, data, device, description: SensorEntityDescription): """Initialize the sensor.""" - self._uuid = device["uuid"] + self.entity_description = description self.foobot_data = data - self._name = f"Foobot {device['name']} {SENSOR_TYPES[sensor_type][0]}" - self.type = sensor_type - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES[self.type][3] - - @property - def icon(self): - """Icon to use in the frontend.""" - return SENSOR_TYPES[self.type][2] + self._attr_name = f"Foobot {device['name']} {description.name}" + self._attr_unique_id = f"{device['uuid']}_{description.key}" @property def native_value(self): """Return the state of the device.""" try: - data = self.foobot_data.data[self.type] + data = self.foobot_data.data[self.entity_description.key] except (KeyError, TypeError): data = None return data - @property - def unique_id(self): - """Return the unique id of this entity.""" - return f"{self._uuid}_{self.type}" - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement - async def async_update(self): """Get the latest data.""" await self.foobot_data.async_update() From 4a57392881302814b952723e54b84bd01b1fd40c Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Mon, 23 Aug 2021 22:11:20 +0200 Subject: [PATCH 663/903] Add state_class and device_class to Solarlog platform (#53946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Co-authored-by: Joakim Sørensen Co-authored-by: Franck Nijhof --- homeassistant/components/solarlog/const.py | 277 ++++++++++++++------ homeassistant/components/solarlog/sensor.py | 98 +++---- 2 files changed, 231 insertions(+), 144 deletions(-) diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index 0d989642d07..a339f5c873d 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -1,7 +1,20 @@ """Constants for the Solar-Log integration.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntityDescription, +) from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLTAGE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, PERCENTAGE, @@ -17,83 +30,187 @@ DEFAULT_NAME = "solarlog" """Fixed constants.""" SCAN_INTERVAL = timedelta(seconds=60) -"""Supported sensor types.""" -SENSOR_TYPES = { - "time": ["TIME", "last update", None, "mdi:calendar-clock"], - "power_ac": ["powerAC", "power AC", POWER_WATT, "mdi:solar-power"], - "power_dc": ["powerDC", "power DC", POWER_WATT, "mdi:solar-power"], - "voltage_ac": ["voltageAC", "voltage AC", ELECTRIC_POTENTIAL_VOLT, "mdi:flash"], - "voltage_dc": ["voltageDC", "voltage DC", ELECTRIC_POTENTIAL_VOLT, "mdi:flash"], - "yield_day": ["yieldDAY", "yield day", ENERGY_KILO_WATT_HOUR, "mdi:solar-power"], - "yield_yesterday": [ - "yieldYESTERDAY", - "yield yesterday", - ENERGY_KILO_WATT_HOUR, - "mdi:solar-power", - ], - "yield_month": [ - "yieldMONTH", - "yield month", - ENERGY_KILO_WATT_HOUR, - "mdi:solar-power", - ], - "yield_year": ["yieldYEAR", "yield year", ENERGY_KILO_WATT_HOUR, "mdi:solar-power"], - "yield_total": [ - "yieldTOTAL", - "yield total", - ENERGY_KILO_WATT_HOUR, - "mdi:solar-power", - ], - "consumption_ac": ["consumptionAC", "consumption AC", POWER_WATT, "mdi:power-plug"], - "consumption_day": [ - "consumptionDAY", - "consumption day", - ENERGY_KILO_WATT_HOUR, - "mdi:power-plug", - ], - "consumption_yesterday": [ - "consumptionYESTERDAY", - "consumption yesterday", - ENERGY_KILO_WATT_HOUR, - "mdi:power-plug", - ], - "consumption_month": [ - "consumptionMONTH", - "consumption month", - ENERGY_KILO_WATT_HOUR, - "mdi:power-plug", - ], - "consumption_year": [ - "consumptionYEAR", - "consumption year", - ENERGY_KILO_WATT_HOUR, - "mdi:power-plug", - ], - "consumption_total": [ - "consumptionTOTAL", - "consumption total", - ENERGY_KILO_WATT_HOUR, - "mdi:power-plug", - ], - "total_power": ["totalPOWER", "total power", "Wp", "mdi:solar-power"], - "alternator_loss": [ - "alternatorLOSS", - "alternator loss", - POWER_WATT, - "mdi:solar-power", - ], - "capacity": ["CAPACITY", "capacity", PERCENTAGE, "mdi:solar-power"], - "efficiency": [ - "EFFICIENCY", - "efficiency", - f"% {POWER_WATT}/{POWER_WATT}p", - "mdi:solar-power", - ], - "power_available": [ - "powerAVAILABLE", - "power available", - POWER_WATT, - "mdi:solar-power", - ], - "usage": ["USAGE", "usage", None, "mdi:solar-power"], -} + +@dataclass +class SolarlogRequiredKeysMixin: + """Mixin for required keys.""" + + json_key: str + + +@dataclass +class SolarLogSensorEntityDescription( + SensorEntityDescription, SolarlogRequiredKeysMixin +): + """Describes Solarlog sensor entity.""" + + +SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( + SolarLogSensorEntityDescription( + key="time", + json_key="TIME", + name="last update", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SolarLogSensorEntityDescription( + key="power_ac", + json_key="powerAC", + name="power AC", + icon="mdi:solar-power", + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="power_dc", + json_key="powerDC", + name="power DC", + icon="mdi:solar-power", + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="voltage_ac", + json_key="voltageAC", + name="voltage AC", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="voltage_dc", + json_key="voltageDC", + name="voltage DC", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="yield_day", + json_key="yieldDAY", + name="yield day", + icon="mdi:solar-power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + SolarLogSensorEntityDescription( + key="yield_yesterday", + json_key="yieldYESTERDAY", + name="yield yesterday", + icon="mdi:solar-power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + SolarLogSensorEntityDescription( + key="yield_month", + json_key="yieldMONTH", + name="yield month", + icon="mdi:solar-power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + SolarLogSensorEntityDescription( + key="yield_year", + json_key="yieldYEAR", + name="yield year", + icon="mdi:solar-power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + SolarLogSensorEntityDescription( + key="yield_total", + json_key="yieldTOTAL", + name="yield total", + icon="mdi:solar-power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SolarLogSensorEntityDescription( + key="consumption_ac", + json_key="consumptionAC", + name="consumption AC", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="consumption_day", + json_key="consumptionDAY", + name="consumption day", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + SolarLogSensorEntityDescription( + key="consumption_yesterday", + json_key="consumptionYESTERDAY", + name="consumption yesterday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + SolarLogSensorEntityDescription( + key="consumption_month", + json_key="consumptionMONTH", + name="consumption month", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + SolarLogSensorEntityDescription( + key="consumption_year", + json_key="consumptionYEAR", + name="consumption year", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + SolarLogSensorEntityDescription( + key="consumption_total", + json_key="consumptionTOTAL", + name="consumption total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SolarLogSensorEntityDescription( + key="total_power", + json_key="totalPOWER", + name="total power", + icon="mdi:solar-power", + native_unit_of_measurement="Wp", + ), + SolarLogSensorEntityDescription( + key="alternator_loss", + json_key="alternatorLOSS", + name="alternator loss", + icon="mdi:solar-power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="capacity", + json_key="CAPACITY", + name="capacity", + icon="mdi:solar-power", + native_unit_of_measurement="W/Wp", + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="efficiency", + json_key="EFFICIENCY", + name="efficiency", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="power_available", + json_key="powerAVAILABLE", + name="power available", + icon="mdi:solar-power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="usage", + json_key="USAGE", + name="usage", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), +) diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index a6a35bad80c..5df86d64997 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.const import CONF_HOST from homeassistant.util import Throttle -from .const import DOMAIN, SCAN_INTERVAL, SENSOR_TYPES +from .const import DOMAIN, SCAN_INTERVAL, SENSOR_TYPES, SolarLogSensorEntityDescription _LOGGER = logging.getLogger(__name__) @@ -46,71 +46,14 @@ async def async_setup_entry(hass, entry, async_add_entities): data = await hass.async_add_executor_job(SolarlogData, hass, api, host) # Create a new sensor for each sensor type. - entities = [] - for sensor_key in SENSOR_TYPES: - sensor = SolarlogSensor(entry.entry_id, device_name, sensor_key, data) - entities.append(sensor) - + entities = [ + SolarlogSensor(entry.entry_id, device_name, data, description) + for description in SENSOR_TYPES + ] async_add_entities(entities, True) return True -class SolarlogSensor(SensorEntity): - """Representation of a Sensor.""" - - def __init__(self, entry_id, device_name, sensor_key, data): - """Initialize the sensor.""" - self.device_name = device_name - self.sensor_key = sensor_key - self.data = data - self.entry_id = entry_id - self._state = None - - self._json_key = SENSOR_TYPES[self.sensor_key][0] - self._label = SENSOR_TYPES[self.sensor_key][1] - self._unit_of_measurement = SENSOR_TYPES[self.sensor_key][2] - self._icon = SENSOR_TYPES[self.sensor_key][3] - - @property - def unique_id(self): - """Return the unique id.""" - return f"{self.entry_id}_{self.sensor_key}" - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.device_name} {self._label}" - - @property - def native_unit_of_measurement(self): - """Return the state of the sensor.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the sensor icon.""" - return self._icon - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_info(self): - """Return the device information.""" - return { - "identifiers": {(DOMAIN, self.entry_id)}, - "name": self.device_name, - "manufacturer": "Solar-Log", - } - - def update(self): - """Get the latest data from the sensor and update the state.""" - self.data.update() - self._state = self.data.data[self._json_key] - - class SolarlogData: """Get and update the latest data.""" @@ -154,10 +97,37 @@ class SolarlogData: self.data["consumptionTOTAL"] = self.api.consumption_total / 1000 self.data["totalPOWER"] = self.api.total_power self.data["alternatorLOSS"] = self.api.alternator_loss - self.data["CAPACITY"] = round(self.api.capacity * 100, 0) + self.data["CAPACITY"] = round(self.api.capacity, 3) self.data["EFFICIENCY"] = round(self.api.efficiency * 100, 0) self.data["powerAVAILABLE"] = self.api.power_available - self.data["USAGE"] = self.api.usage + self.data["USAGE"] = round(self.api.usage * 100, 0) _LOGGER.debug("Updated Solarlog overview data: %s", self.data) except AttributeError: _LOGGER.error("Missing details data in Solarlog response") + + +class SolarlogSensor(SensorEntity): + """Representation of a Sensor.""" + + def __init__( + self, + entry_id: str, + device_name: str, + data: SolarlogData, + description: SolarLogSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + self.data = data + self._attr_name = f"{device_name} {description.name}" + self._attr_unique_id = f"{entry_id}_{description.key}" + self._attr_device_info = { + "identifiers": {(DOMAIN, entry_id)}, + "name": device_name, + "manufacturer": "Solar-Log", + } + + def update(self): + """Get the latest data from the sensor and update the state.""" + self.data.update() + self._attr_native_value = self.data.data[self.entity_description.json_key] From 90f7131328f65c9062780a0dfa197660b8c29109 Mon Sep 17 00:00:00 2001 From: gjong Date: Mon, 23 Aug 2021 22:14:56 +0200 Subject: [PATCH 664/903] Update YouLess integration for long time measurements (#54767) --- homeassistant/components/youless/sensor.py | 31 +++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index bc0f1ee873b..22fecfe1ec6 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -3,10 +3,22 @@ from __future__ import annotations from youless_api.youless_sensor import YoulessSensor -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.components.youless import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, DEVICE_CLASS_POWER +from homeassistant.const import ( + CONF_DEVICE, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + VOLUME_CUBIC_METERS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -95,6 +107,10 @@ class YoulessBaseSensor(CoordinatorEntity, SensorEntity): class GasSensor(YoulessBaseSensor): """The Youless gas sensor.""" + _attr_native_unit_of_measurement = VOLUME_CUBIC_METERS + _attr_device_class = DEVICE_CLASS_GAS + _attr_state_class = STATE_CLASS_TOTAL_INCREASING + def __init__(self, coordinator: DataUpdateCoordinator, device: str) -> None: """Instantiate a gas sensor.""" super().__init__(coordinator, device, "gas", "Gas meter", "gas") @@ -110,7 +126,9 @@ class GasSensor(YoulessBaseSensor): class CurrentPowerSensor(YoulessBaseSensor): """The current power usage sensor.""" + _attr_native_unit_of_measurement = POWER_WATT _attr_device_class = DEVICE_CLASS_POWER + _attr_state_class = STATE_CLASS_MEASUREMENT def __init__(self, coordinator: DataUpdateCoordinator, device: str) -> None: """Instantiate the usage meter.""" @@ -127,7 +145,9 @@ class CurrentPowerSensor(YoulessBaseSensor): class DeliveryMeterSensor(YoulessBaseSensor): """The Youless delivery meter value sensor.""" - _attr_device_class = DEVICE_CLASS_POWER + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_TOTAL_INCREASING def __init__( self, coordinator: DataUpdateCoordinator, device: str, dev_type: str @@ -151,7 +171,9 @@ class DeliveryMeterSensor(YoulessBaseSensor): class PowerMeterSensor(YoulessBaseSensor): """The Youless low meter value sensor.""" - _attr_device_class = DEVICE_CLASS_POWER + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_TOTAL_INCREASING def __init__( self, coordinator: DataUpdateCoordinator, device: str, dev_type: str @@ -176,6 +198,7 @@ class PowerMeterSensor(YoulessBaseSensor): class ExtraMeterSensor(YoulessBaseSensor): """The Youless extra meter value sensor (s0).""" + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_device_class = DEVICE_CLASS_POWER def __init__( From a84f1284c0eb8be9041495759d72ff7fd106f31a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:24:32 +0200 Subject: [PATCH 665/903] Use EntityDescription - bbox (#55064) --- homeassistant/components/bbox/sensor.py | 123 +++++++++++++++--------- 1 file changed, 75 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index 9ccd197e05e..d2c06f45875 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -1,4 +1,6 @@ """Support for Bbox Bouygues Modem Router.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,7 +8,11 @@ import pybbox import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_MONITORED_VARIABLES, @@ -26,36 +32,52 @@ DEFAULT_NAME = "Bbox" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -# Sensor types are defined like so: Name, unit, icon -SENSOR_TYPES = { - "down_max_bandwidth": [ - "Maximum Download Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:download", - ], - "up_max_bandwidth": [ - "Maximum Upload Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:upload", - ], - "current_down_bandwidth": [ - "Currently Used Download Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:download", - ], - "current_up_bandwidth": [ - "Currently Used Upload Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:upload", - ], - "uptime": ["Uptime", None, "mdi:clock"], - "number_of_reboots": ["Number of reboot", None, "mdi:restart"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="down_max_bandwidth", + name="Maximum Download Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + icon="mdi:download", + ), + SensorEntityDescription( + key="up_max_bandwidth", + name="Maximum Upload Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + icon="mdi:upload", + ), + SensorEntityDescription( + key="current_down_bandwidth", + name="Currently Used Download Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + icon="mdi:download", + ), + SensorEntityDescription( + key="current_up_bandwidth", + name="Currently Used Upload Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + icon="mdi:upload", + ), + SensorEntityDescription( + key="number_of_reboots", + name="Number of reboot", + icon="mdi:restart", + ), +) + +SENSOR_TYPES_UPTIME: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="uptime", + name="Uptime", + icon="mdi:clock", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in (*SENSOR_TYPES, *SENSOR_TYPES_UPTIME)] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_VARIABLES): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } @@ -75,14 +97,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config[CONF_NAME] - sensors = [] - for variable in config[CONF_MONITORED_VARIABLES]: - if variable == "uptime": - sensors.append(BboxUptimeSensor(bbox_data, variable, name)) - else: - sensors.append(BboxSensor(bbox_data, variable, name)) + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities: list[BboxSensor | BboxUptimeSensor] = [ + BboxSensor(bbox_data, name, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] + entities.extend( + [ + BboxUptimeSensor(bbox_data, name, description) + for description in SENSOR_TYPES_UPTIME + if description.key in monitored_variables + ] + ) - add_entities(sensors, True) + add_entities(entities, True) class BboxUptimeSensor(SensorEntity): @@ -91,11 +120,10 @@ class BboxUptimeSensor(SensorEntity): _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_device_class = DEVICE_CLASS_TIMESTAMP - def __init__(self, bbox_data, sensor_type, name): + def __init__(self, bbox_data, name, description: SensorEntityDescription): """Initialize the sensor.""" - self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" - self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.bbox_data = bbox_data def update(self): @@ -112,34 +140,33 @@ class BboxSensor(SensorEntity): _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} - def __init__(self, bbox_data, sensor_type, name): + def __init__(self, bbox_data, name, description: SensorEntityDescription): """Initialize the sensor.""" - self.type = sensor_type - self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" - self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.bbox_data = bbox_data def update(self): """Get the latest data from Bbox and update the state.""" self.bbox_data.update() - if self.type == "down_max_bandwidth": + sensor_type = self.entity_description.key + if sensor_type == "down_max_bandwidth": self._attr_native_value = round( self.bbox_data.data["rx"]["maxBandwidth"] / 1000, 2 ) - elif self.type == "up_max_bandwidth": + elif sensor_type == "up_max_bandwidth": self._attr_native_value = round( self.bbox_data.data["tx"]["maxBandwidth"] / 1000, 2 ) - elif self.type == "current_down_bandwidth": + elif sensor_type == "current_down_bandwidth": self._attr_native_value = round( self.bbox_data.data["rx"]["bandwidth"] / 1000, 2 ) - elif self.type == "current_up_bandwidth": + elif sensor_type == "current_up_bandwidth": self._attr_native_value = round( self.bbox_data.data["tx"]["bandwidth"] / 1000, 2 ) - elif self.type == "number_of_reboots": + elif sensor_type == "number_of_reboots": self._attr_native_value = self.bbox_data.router_infos["device"][ "numberofboots" ] From 4b069b42f00e15a061fa3f64c4006c90ace3e2d4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:27:42 +0200 Subject: [PATCH 666/903] Use EntityDescription - radarr (#54997) --- homeassistant/components/radarr/sensor.py | 159 +++++++++++++--------- 1 file changed, 92 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 02e898c8e0f..fc4fff6c274 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -1,12 +1,19 @@ """Support for Radarr.""" +from __future__ import annotations + from datetime import datetime, timedelta import logging import time +from typing import Any import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -42,14 +49,46 @@ DEFAULT_UNIT = DATA_GIGABYTES SCAN_INTERVAL = timedelta(minutes=10) -SENSOR_TYPES = { - "diskspace": ["Disk Space", DATA_GIGABYTES, "mdi:harddisk"], - "upcoming": ["Upcoming", "Movies", "mdi:television"], - "wanted": ["Wanted", "Movies", "mdi:television"], - "movies": ["Movies", "Movies", "mdi:television"], - "commands": ["Commands", "Commands", "mdi:code-braces"], - "status": ["Status", "Status", "mdi:information"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="diskspace", + name="Disk Space", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:harddisk", + ), + SensorEntityDescription( + key="upcoming", + name="Upcoming", + native_unit_of_measurement="Movies", + icon="mdi:television", + ), + SensorEntityDescription( + key="wanted", + name="Wanted", + native_unit_of_measurement="Movies", + icon="mdi:television", + ), + SensorEntityDescription( + key="movies", + name="Movies", + native_unit_of_measurement="Movies", + icon="mdi:television", + ), + SensorEntityDescription( + key="commands", + name="Commands", + native_unit_of_measurement="Commands", + icon="mdi:code-braces", + ), + SensorEntityDescription( + key="status", + name="Status", + native_unit_of_measurement="Status", + icon="mdi:information", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] ENDPOINTS = { "diskspace": "{0}://{1}:{2}/{3}api/diskspace", @@ -78,7 +117,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_INCLUDED, default=[]): cv.ensure_list, vol.Optional(CONF_MONITORED_CONDITIONS, default=["movies"]): vol.All( - cv.ensure_list, [vol.In(list(SENSOR_TYPES))] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=False): cv.boolean, @@ -90,15 +129,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Radarr platform.""" - conditions = config.get(CONF_MONITORED_CONDITIONS) - add_entities([RadarrSensor(hass, config, sensor) for sensor in conditions], True) + conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + RadarrSensor(hass, config, description) + for description in SENSOR_TYPES + if description.key in conditions + ] + add_entities(entities, True) class RadarrSensor(SensorEntity): """Implementation of the Radarr sensor.""" - def __init__(self, hass, conf, sensor_type): + def __init__(self, hass, conf, description: SensorEntityDescription): """Create Radarr entity.""" + self.entity_description = description self.conf = conf self.host = conf.get(CONF_HOST) @@ -110,78 +155,55 @@ class RadarrSensor(SensorEntity): self.included = conf.get(CONF_INCLUDED) self.days = int(conf.get(CONF_DAYS)) self.ssl = "https" if conf.get(CONF_SSL) else "http" - self._state = None - self.data = [] - self.type = sensor_type - self._name = SENSOR_TYPES[self.type][0] - if self.type == "diskspace": - self._unit = conf.get(CONF_UNIT) - else: - self._unit = SENSOR_TYPES[self.type][1] - self._icon = SENSOR_TYPES[self.type][2] - self._available = False - - @property - def name(self): - """Return the name of the sensor.""" - return "{} {}".format("Radarr", self._name) - - @property - def native_value(self): - """Return sensor state.""" - return self._state - - @property - def available(self): - """Return sensor availability.""" - return self._available - - @property - def native_unit_of_measurement(self): - """Return the unit of the sensor.""" - return self._unit + self.data: list[Any] = [] + self._attr_name = f"Radarr {description.name}" + if description.key == "diskspace": + self._attr_native_unit_of_measurement = conf.get(CONF_UNIT) + self._attr_available = False @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" attributes = {} - if self.type == "upcoming": + sensor_type = self.entity_description.key + if sensor_type == "upcoming": for movie in self.data: attributes[to_key(movie)] = get_release_date(movie) - elif self.type == "commands": + elif sensor_type == "commands": for command in self.data: attributes[command["name"]] = command["state"] - elif self.type == "diskspace": + elif sensor_type == "diskspace": for data in self.data: - free_space = to_unit(data["freeSpace"], self._unit) - total_space = to_unit(data["totalSpace"], self._unit) + free_space = to_unit(data["freeSpace"], self.native_unit_of_measurement) + total_space = to_unit( + data["totalSpace"], self.native_unit_of_measurement + ) percentage_used = ( 0 if total_space == 0 else free_space / total_space * 100 ) attributes[data["path"]] = "{:.2f}/{:.2f}{} ({:.2f}%)".format( - free_space, total_space, self._unit, percentage_used + free_space, + total_space, + self.native_unit_of_measurement, + percentage_used, ) - elif self.type == "movies": + elif sensor_type == "movies": for movie in self.data: attributes[to_key(movie)] = movie["downloaded"] - elif self.type == "status": + elif sensor_type == "status": attributes = self.data return attributes - @property - def icon(self): - """Return the icon of the sensor.""" - return self._icon - def update(self): """Update the data for the sensor.""" + sensor_type = self.entity_description.key time_zone = dt_util.get_time_zone(self.hass.config.time_zone) start = get_date(time_zone) end = get_date(time_zone, self.days) try: res = requests.get( - ENDPOINTS[self.type].format( + ENDPOINTS[sensor_type].format( self.ssl, self.host, self.port, self.urlbase, start, end ), headers={"X-Api-Key": self.apikey}, @@ -189,15 +211,15 @@ class RadarrSensor(SensorEntity): ) except OSError: _LOGGER.warning("Host %s is not available", self.host) - self._available = False - self._state = None + self._attr_available = False + self._attr_native_value = None return if res.status_code == HTTP_OK: - if self.type in ("upcoming", "movies", "commands"): + if sensor_type in ("upcoming", "movies", "commands"): self.data = res.json() - self._state = len(self.data) - elif self.type == "diskspace": + self._attr_native_value = len(self.data) + elif sensor_type == "diskspace": # If included paths are not provided, use all data if self.included == []: self.data = res.json() @@ -206,13 +228,16 @@ class RadarrSensor(SensorEntity): self.data = list( filter(lambda x: x["path"] in self.included, res.json()) ) - self._state = "{:.2f}".format( - to_unit(sum(data["freeSpace"] for data in self.data), self._unit) + self._attr_native_value = "{:.2f}".format( + to_unit( + sum(data["freeSpace"] for data in self.data), + self.native_unit_of_measurement, + ) ) - elif self.type == "status": + elif sensor_type == "status": self.data = res.json() - self._state = self.data["version"] - self._available = True + self._attr_native_value = self.data["version"] + self._attr_available = True def get_date(zone, offset=0): From e5a350e78657c859fbf3125f043f2366678fe567 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:30:01 +0200 Subject: [PATCH 667/903] Use EntityDescription - miflora (#55020) --- homeassistant/components/miflora/sensor.py | 173 +++++++++------------ 1 file changed, 72 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py index f712ffe6fe5..0e9abe5c757 100644 --- a/homeassistant/components/miflora/sensor.py +++ b/homeassistant/components/miflora/sensor.py @@ -1,7 +1,9 @@ """Support for Xiaomi Mi Flora BLE plant sensor.""" +from __future__ import annotations from datetime import timedelta import logging +from typing import Any import btlewrap from btlewrap import BluetoothBackendException @@ -12,6 +14,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import ( CONDUCTIVITY, @@ -27,12 +30,10 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, TEMP_CELSIUS, - TEMP_FAHRENHEIT, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -from homeassistant.util.temperature import celsius_to_fahrenheit try: import bluepy.btle # noqa: F401 pylint: disable=unused-import @@ -57,20 +58,46 @@ SCAN_INTERVAL = timedelta(seconds=1200) ATTR_LAST_SUCCESSFUL_UPDATE = "last_successful_update" -# Sensor types are defined like: Name, units, icon, device_class -SENSOR_TYPES = { - "temperature": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "light": ["Light intensity", LIGHT_LUX, None, DEVICE_CLASS_ILLUMINANCE], - "moisture": ["Moisture", PERCENTAGE, "mdi:water-percent", None], - "conductivity": ["Conductivity", CONDUCTIVITY, "mdi:flash-circle", None], - "battery": ["Battery", PERCENTAGE, None, DEVICE_CLASS_BATTERY], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="light", + name="Light intensity", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + SensorEntityDescription( + key="moisture", + name="Moisture", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="conductivity", + name="Conductivity", + native_unit_of_measurement=CONDUCTIVITY, + icon="mdi:flash-circle", + ), + SensorEntityDescription( + key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MAC): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int, @@ -90,74 +117,55 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= cache = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL).total_seconds() poller = miflora_poller.MiFloraPoller( - config.get(CONF_MAC), + config[CONF_MAC], cache_timeout=cache, - adapter=config.get(CONF_ADAPTER), + adapter=config[CONF_ADAPTER], backend=backend, ) - force_update = config.get(CONF_FORCE_UPDATE) - median = config.get(CONF_MEDIAN) + force_update = config[CONF_FORCE_UPDATE] + median = config[CONF_MEDIAN] - go_unavailable_timeout = config.get(CONF_GO_UNAVAILABLE_TIMEOUT) + go_unavailable_timeout = config[CONF_GO_UNAVAILABLE_TIMEOUT] - devs = [] - - for parameter in config[CONF_MONITORED_CONDITIONS]: - name = SENSOR_TYPES[parameter][0] - unit = ( - hass.config.units.temperature_unit - if parameter == "temperature" - else SENSOR_TYPES[parameter][1] + prefix = config[CONF_NAME] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + MiFloraSensor( + description, + poller, + prefix, + force_update, + median, + go_unavailable_timeout, ) - icon = SENSOR_TYPES[parameter][2] - device_class = SENSOR_TYPES[parameter][3] + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - prefix = config.get(CONF_NAME) - if prefix: - name = f"{prefix} {name}" - - devs.append( - MiFloraSensor( - poller, - parameter, - name, - unit, - icon, - device_class, - force_update, - median, - go_unavailable_timeout, - ) - ) - - async_add_entities(devs) + async_add_entities(entities) class MiFloraSensor(SensorEntity): """Implementing the MiFlora sensor.""" + _attr_state_class = STATE_CLASS_MEASUREMENT + def __init__( self, + description: SensorEntityDescription, poller, - parameter, - name, - unit, - icon, - device_class, + prefix, force_update, median, go_unavailable_timeout, ): """Initialize the sensor.""" + self.entity_description = description self.poller = poller - self.parameter = parameter - self._unit = unit - self._icon = icon - self._name = name - self._state = None - self._device_class = device_class - self.data = [] - self._force_update = force_update + self.data: list[Any] = [] + if prefix: + self._attr_name = f"{prefix} {description.name}" + self._attr_force_update = force_update self.go_unavailable_timeout = go_unavailable_timeout self.last_successful_update = dt_util.utc_from_timestamp(0) # Median is used to filter out outliers. median of 3 will filter @@ -174,16 +182,6 @@ class MiFloraSensor(SensorEntity): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, on_startup) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - @property def available(self): """Return True if did update since 2h.""" @@ -196,31 +194,6 @@ class MiFloraSensor(SensorEntity): """Return the state attributes of the device.""" return {ATTR_LAST_SUCCESSFUL_UPDATE: self.last_successful_update} - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def state_class(self): - """Return the state class of this entity.""" - return STATE_CLASS_MEASUREMENT - - @property - def native_unit_of_measurement(self): - """Return the units of measurement.""" - return self._unit - - @property - def icon(self): - """Return the icon of the sensor.""" - return self._icon - - @property - def force_update(self): - """Force update.""" - return self._force_update - def update(self): """ Update current conditions. @@ -229,15 +202,13 @@ class MiFloraSensor(SensorEntity): """ try: _LOGGER.debug("Polling data for %s", self.name) - data = self.poller.parameter_value(self.parameter) + data = self.poller.parameter_value(self.entity_description.key) except (OSError, BluetoothBackendException) as err: _LOGGER.info("Polling error %s: %s", type(err).__name__, err) return if data is not None: _LOGGER.debug("%s = %s", self.name, data) - if self._unit == TEMP_FAHRENHEIT: - data = celsius_to_fahrenheit(data) self.data.append(data) self.last_successful_update = dt_util.utcnow() else: @@ -247,7 +218,7 @@ class MiFloraSensor(SensorEntity): if self.data: self.data = self.data[1:] else: - self._state = None + self._attr_native_value = None return _LOGGER.debug("Data collected: %s", self.data) @@ -257,9 +228,9 @@ class MiFloraSensor(SensorEntity): if len(self.data) == self.median_count: median = sorted(self.data)[int((self.median_count - 1) / 2)] _LOGGER.debug("Median is: %s", median) - self._state = median - elif self._state is None: + self._attr_native_value = median + elif self._attr_native_value is None: _LOGGER.debug("Set initial state") - self._state = self.data[0] + self._attr_native_value = self.data[0] else: _LOGGER.debug("Not yet enough data for median calculation") From 1f6a70bafd327980fe88f3834be28d9d711f7716 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:32:01 +0200 Subject: [PATCH 668/903] Use EntityDescription - amcrest (#54998) --- homeassistant/components/amcrest/__init__.py | 4 +- homeassistant/components/amcrest/sensor.py | 88 +++++++++----------- 2 files changed, 41 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 20775688b1b..ca99524f611 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -48,7 +48,7 @@ from .const import ( SERVICE_UPDATE, ) from .helpers import service_signal -from .sensor import SENSORS +from .sensor import SENSOR_KEYS _LOGGER = logging.getLogger(__name__) @@ -102,7 +102,7 @@ AMCREST_SCHEMA = vol.Schema( cv.ensure_list, [vol.In(BINARY_SENSORS)], vol.Unique(), check_binary_sensors ), vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.In(SENSORS)], vol.Unique() + cv.ensure_list, [vol.In(SENSOR_KEYS)], vol.Unique() ), vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean, } diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index de8370a15fc..95a92b205f0 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -1,10 +1,12 @@ """Support for Amcrest IP camera sensors.""" +from __future__ import annotations + from datetime import timedelta import logging from amcrest import AmcrestError -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import CONF_NAME, CONF_SENSORS, PERCENTAGE from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -17,11 +19,22 @@ SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS) SENSOR_PTZ_PRESET = "ptz_preset" SENSOR_SDCARD = "sdcard" -# Sensor types are defined like: Name, units, icon -SENSORS = { - SENSOR_PTZ_PRESET: ["PTZ Preset", None, "mdi:camera-iris"], - SENSOR_SDCARD: ["SD Used", PERCENTAGE, "mdi:sd"], -} + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_PTZ_PRESET, + name="PTZ Preset", + icon="mdi:camera-iris", + ), + SensorEntityDescription( + key=SENSOR_SDCARD, + name="SD Used", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:sd", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -31,10 +44,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= name = discovery_info[CONF_NAME] device = hass.data[DATA_AMCREST][DEVICES][name] + sensors = discovery_info[CONF_SENSORS] async_add_entities( [ - AmcrestSensor(name, device, sensor_type) - for sensor_type in discovery_info[CONF_SENSORS] + AmcrestSensor(name, device, description) + for description in SENSOR_TYPES + if description.key in sensors ], True, ) @@ -43,42 +58,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AmcrestSensor(SensorEntity): """A sensor implementation for Amcrest IP camera.""" - def __init__(self, name, device, sensor_type): + def __init__(self, name, device, description: SensorEntityDescription): """Initialize a sensor for Amcrest camera.""" - self._name = f"{name} {SENSORS[sensor_type][0]}" + self.entity_description = description self._signal_name = name self._api = device.api - self._sensor_type = sensor_type - self._state = None - self._attrs = {} - self._unit_of_measurement = SENSORS[sensor_type][1] - self._icon = SENSORS[sensor_type][2] self._unsub_dispatcher = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attrs - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def native_unit_of_measurement(self): - """Return the units of measurement.""" - return self._unit_of_measurement + self._attr_name = f"{name} {description.name}" @property def available(self): @@ -89,32 +76,35 @@ class AmcrestSensor(SensorEntity): """Get the latest data and updates the state.""" if not self.available: return - _LOGGER.debug("Updating %s sensor", self._name) + _LOGGER.debug("Updating %s sensor", self.name) + sensor_type = self.entity_description.key try: - if self._sensor_type == SENSOR_PTZ_PRESET: - self._state = self._api.ptz_presets_count + if sensor_type == SENSOR_PTZ_PRESET: + self._attr_native_value = self._api.ptz_presets_count - elif self._sensor_type == SENSOR_SDCARD: + elif sensor_type == SENSOR_SDCARD: storage = self._api.storage_all try: - self._attrs[ + self._attr_extra_state_attributes[ "Total" ] = f"{storage['total'][0]:.2f} {storage['total'][1]}" except ValueError: - self._attrs[ + self._attr_extra_state_attributes[ "Total" ] = f"{storage['total'][0]} {storage['total'][1]}" try: - self._attrs[ + self._attr_extra_state_attributes[ "Used" ] = f"{storage['used'][0]:.2f} {storage['used'][1]}" except ValueError: - self._attrs["Used"] = f"{storage['used'][0]} {storage['used'][1]}" + self._attr_extra_state_attributes[ + "Used" + ] = f"{storage['used'][0]} {storage['used'][1]}" try: - self._state = f"{storage['used_percent']:.2f}" + self._attr_native_value = f"{storage['used_percent']:.2f}" except ValueError: - self._state = storage["used_percent"] + self._attr_native_value = storage["used_percent"] except AmcrestError as error: log_update_error(_LOGGER, "update", self.name, "sensor", error) From 4a1906a833f684f85633402018736f35166140dc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:33:59 +0200 Subject: [PATCH 669/903] Use EntityDescription - startca (#55036) --- homeassistant/components/startca/sensor.py | 151 ++++++++++++++------- 1 file changed, 100 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index d4124ec3d7c..8079ea42c4c 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -1,4 +1,6 @@ """Support for Start.ca Bandwidth Monitor.""" +from __future__ import annotations + from datetime import timedelta import logging from xml.parsers.expat import ExpatError @@ -7,7 +9,11 @@ import async_timeout import voluptuous as vol import xmltodict -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_API_KEY, CONF_MONITORED_VARIABLES, @@ -28,25 +34,87 @@ CONF_TOTAL_BANDWIDTH = "total_bandwidth" MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) REQUEST_TIMEOUT = 5 # seconds -SENSOR_TYPES = { - "usage": ["Usage Ratio", PERCENTAGE, "mdi:percent"], - "usage_gb": ["Usage", DATA_GIGABYTES, "mdi:download"], - "limit": ["Data limit", DATA_GIGABYTES, "mdi:download"], - "used_download": ["Used Download", DATA_GIGABYTES, "mdi:download"], - "used_upload": ["Used Upload", DATA_GIGABYTES, "mdi:upload"], - "used_total": ["Used Total", DATA_GIGABYTES, "mdi:download"], - "grace_download": ["Grace Download", DATA_GIGABYTES, "mdi:download"], - "grace_upload": ["Grace Upload", DATA_GIGABYTES, "mdi:upload"], - "grace_total": ["Grace Total", DATA_GIGABYTES, "mdi:download"], - "total_download": ["Total Download", DATA_GIGABYTES, "mdi:download"], - "total_upload": ["Total Upload", DATA_GIGABYTES, "mdi:download"], - "used_remaining": ["Remaining", DATA_GIGABYTES, "mdi:download"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="usage", + name="Usage Ratio", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), + SensorEntityDescription( + key="usage_gb", + name="Usage", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="limit", + name="Data limit", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="used_download", + name="Used Download", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="used_upload", + name="Used Upload", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:upload", + ), + SensorEntityDescription( + key="used_total", + name="Used Total", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="grace_download", + name="Grace Download", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="grace_upload", + name="Grace Upload", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:upload", + ), + SensorEntityDescription( + key="grace_total", + name="Grace Total", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="total_download", + name="Total Download", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="total_upload", + name="Total Upload", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="used_remaining", + name="Remaining", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_VARIABLES): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_TOTAL_BANDWIDTH): cv.positive_int, @@ -58,8 +126,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the sensor platform.""" websession = async_get_clientsession(hass) - apikey = config.get(CONF_API_KEY) - bandwidthcap = config.get(CONF_TOTAL_BANDWIDTH) + apikey = config[CONF_API_KEY] + bandwidthcap = config[CONF_TOTAL_BANDWIDTH] ts_data = StartcaData(hass.loop, websession, apikey, bandwidthcap) ret = await ts_data.async_update() @@ -67,51 +135,32 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.error("Invalid Start.ca API key: %s", apikey) return - name = config.get(CONF_NAME) - sensors = [] - for variable in config[CONF_MONITORED_VARIABLES]: - sensors.append(StartcaSensor(ts_data, variable, name)) - async_add_entities(sensors, True) + name = config[CONF_NAME] + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities = [ + StartcaSensor(ts_data, name, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] + async_add_entities(entities, True) class StartcaSensor(SensorEntity): """Representation of Start.ca Bandwidth sensor.""" - def __init__(self, startcadata, sensor_type, name): + def __init__(self, startcadata, name, description: SensorEntityDescription): """Initialize the sensor.""" - self.client_name = name - self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description self.startcadata = startcadata - self._state = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + self._attr_name = f"{name} {description.name}" async def async_update(self): """Get the latest data from Start.ca and update the state.""" await self.startcadata.async_update() - if self.type in self.startcadata.data: - self._state = round(self.startcadata.data[self.type], 2) + sensor_type = self.entity_description.key + if sensor_type in self.startcadata.data: + self._attr_native_value = round(self.startcadata.data[sensor_type], 2) class StartcaData: From c7926e56b8d579cf95127151ad5e9ff648455099 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:35:59 +0200 Subject: [PATCH 670/903] Use EntityDescription - sensehat (#54995) --- homeassistant/components/envirophat/sensor.py | 4 +- homeassistant/components/sensehat/sensor.py | 84 ++++++++++--------- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/envirophat/sensor.py b/homeassistant/components/envirophat/sensor.py index 48f53709c40..cff3e95f355 100644 --- a/homeassistant/components/envirophat/sensor.py +++ b/homeassistant/components/envirophat/sensor.py @@ -126,9 +126,7 @@ SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_KEYS)): [ - vol.In(SENSOR_KEYS) - ], + vol.Required(CONF_DISPLAY_OPTIONS, default=SENSOR_KEYS): [vol.In(SENSOR_KEYS)], vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_USE_LEDS, default=False): cv.boolean, } diff --git a/homeassistant/components/sensehat/sensor.py b/homeassistant/components/sensehat/sensor.py index 9274f133441..8dc74ae4e08 100644 --- a/homeassistant/components/sensehat/sensor.py +++ b/homeassistant/components/sensehat/sensor.py @@ -1,4 +1,6 @@ """Support for Sense HAT sensors.""" +from __future__ import annotations + from datetime import timedelta import logging from pathlib import Path @@ -6,7 +8,11 @@ from pathlib import Path from sense_hat import SenseHat import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_DISPLAY_OPTIONS, CONF_NAME, @@ -24,17 +30,30 @@ CONF_IS_HAT_ATTACHED = "is_hat_attached" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -SENSOR_TYPES = { - "temperature": ["temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "humidity": ["humidity", PERCENTAGE, None], - "pressure": ["pressure", "mb", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="humidity", + name="humidity", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="pressure", + name="pressure", + native_unit_of_measurement="mb", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)): [ - vol.In(SENSOR_TYPES) - ], + vol.Required(CONF_DISPLAY_OPTIONS, default=SENSOR_KEYS): [vol.In(SENSOR_KEYS)], vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_IS_HAT_ATTACHED, default=True): cv.boolean, } @@ -61,39 +80,23 @@ def get_average(temp_base): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Sense HAT sensor platform.""" data = SenseHatData(config.get(CONF_IS_HAT_ATTACHED)) - dev = [] - for variable in config[CONF_DISPLAY_OPTIONS]: - dev.append(SenseHatSensor(data, variable)) + display_options = config[CONF_DISPLAY_OPTIONS] + entities = [ + SenseHatSensor(data, description) + for description in SENSOR_TYPES + if description.key in display_options + ] - add_entities(dev, True) + add_entities(entities, True) class SenseHatSensor(SensorEntity): """Representation of a Sense HAT sensor.""" - def __init__(self, data, sensor_types): + def __init__(self, data, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._name = SENSOR_TYPES[sensor_types][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_types][1] - self.type = sensor_types - self._state = None - self._attr_device_class = SENSOR_TYPES[sensor_types][2] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement def update(self): """Get the latest data and updates the states.""" @@ -102,12 +105,13 @@ class SenseHatSensor(SensorEntity): _LOGGER.error("Don't receive data") return - if self.type == "temperature": - self._state = self.data.temperature - if self.type == "humidity": - self._state = self.data.humidity - if self.type == "pressure": - self._state = self.data.pressure + sensor_type = self.entity_description.key + if sensor_type == "temperature": + self._attr_native_value = self.data.temperature + elif sensor_type == "humidity": + self._attr_native_value = self.data.humidity + elif sensor_type == "pressure": + self._attr_native_value = self.data.pressure class SenseHatData: From a23f27a7a8cd9c288d0cba764523f1bf526a6b23 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:38:10 +0200 Subject: [PATCH 671/903] Use EntityDescription - magicseaweed (#54943) --- .../components/magicseaweed/sensor.py | 129 ++++++++++-------- 1 file changed, 70 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py index 12288c5ab78..5979759f416 100644 --- a/homeassistant/components/magicseaweed/sensor.py +++ b/homeassistant/components/magicseaweed/sensor.py @@ -1,11 +1,17 @@ """Support for magicseaweed data from magicseaweed.com.""" +from __future__ import annotations + from datetime import timedelta import logging import magicseaweed import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, @@ -30,18 +36,30 @@ ICON = "mdi:waves" HOURS = ["12AM", "3AM", "6AM", "9AM", "12PM", "3PM", "6PM", "9PM"] -SENSOR_TYPES = { - "max_breaking_swell": ["Max"], - "min_breaking_swell": ["Min"], - "swell_forecast": ["Forecast"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="max_breaking_swell", + name="Max", + ), + SensorEntityDescription( + key="min_breaking_swell", + name="Min", + ), + SensorEntityDescription( + key="swell_forecast", + name="Forecast", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + UNITS = ["eu", "uk", "us"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_SPOT_ID): vol.All(cv.ensure_list, [cv.string]), @@ -78,67 +96,59 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if forecast_data.currently is None or forecast_data.hourly is None: return - sensors = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - sensors.append(MagicSeaweedSensor(forecast_data, variable, name, units)) - if "forecast" not in variable and hours is not None: - for hour in hours: - sensors.append( - MagicSeaweedSensor(forecast_data, variable, name, units, hour) - ) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + sensors = [ + MagicSeaweedSensor(forecast_data, name, units, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + sensors.extend( + [ + MagicSeaweedSensor(forecast_data, name, units, description, hour) + for description in SENSOR_TYPES + if description.key in monitored_conditions + and "forecast" not in description.key + for hour in hours + if hour is not None + ] + ) add_entities(sensors, True) class MagicSeaweedSensor(SensorEntity): """Implementation of a MagicSeaweed sensor.""" - def __init__(self, forecast_data, sensor_type, name, unit_system, hour=None): + _attr_icon = ICON + + def __init__( + self, + forecast_data, + name, + unit_system, + description: SensorEntityDescription, + hour=None, + ): """Initialize the sensor.""" + self.entity_description = description self.client_name = name self.data = forecast_data self.hour = hour - self.type = sensor_type - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._name = SENSOR_TYPES[sensor_type][0] - self._icon = None - self._state = None self._unit_system = unit_system - self._unit_of_measurement = None - @property - def name(self): - """Return the name of the sensor.""" - if self.hour is None and "forecast" in self.type: - return f"{self.client_name} {self._name}" - if self.hour is None: - return f"Current {self.client_name} {self._name}" - return f"{self.hour} {self.client_name} {self._name}" + if hour is None and "forecast" in description.key: + self._attr_name = f"{name} {description.name}" + elif hour is None: + self._attr_name = f"Current {name} {description.name}" + else: + self._attr_name = f"{hour} {name} {description.name}" - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} @property def unit_system(self): """Return the unit system of this entity.""" return self._unit_system - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the entity weather icon, if any.""" - return ICON - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attrs - def update(self): """Get the latest data from Magicseaweed and updates the states.""" self.data.update() @@ -147,22 +157,23 @@ class MagicSeaweedSensor(SensorEntity): else: forecast = self.data.hourly[self.hour] - self._unit_of_measurement = forecast.swell_unit - if self.type == "min_breaking_swell": - self._state = forecast.swell_minBreakingHeight - elif self.type == "max_breaking_swell": - self._state = forecast.swell_maxBreakingHeight - elif self.type == "swell_forecast": + self._attr_native_unit_of_measurement = forecast.swell_unit + sensor_type = self.entity_description.key + if sensor_type == "min_breaking_swell": + self._attr_native_value = forecast.swell_minBreakingHeight + elif sensor_type == "max_breaking_swell": + self._attr_native_value = forecast.swell_maxBreakingHeight + elif sensor_type == "swell_forecast": summary = f"{forecast.swell_minBreakingHeight} - {forecast.swell_maxBreakingHeight}" - self._state = summary + self._attr_native_value = summary if self.hour is None: for hour, data in self.data.hourly.items(): occurs = hour hr_summary = f"{data.swell_minBreakingHeight} - {data.swell_maxBreakingHeight} {data.swell_unit}" - self._attrs[occurs] = hr_summary + self._attr_extra_state_attributes[occurs] = hr_summary - if self.type != "swell_forecast": - self._attrs.update(forecast.attrs) + if sensor_type != "swell_forecast": + self._attr_extra_state_attributes.update(forecast.attrs) class MagicSeaweedData: From 1152330865d9ef6dd8d8a7ba14ff791bdbad94bd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:41:02 +0200 Subject: [PATCH 672/903] Use EntityDescription - dovado (#54945) --- homeassistant/components/dovado/sensor.py | 127 +++++++++++++--------- 1 file changed, 77 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index 46f4c34cc31..180a886740f 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -1,10 +1,17 @@ """Support for sensors from the Dovado router.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import re import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_SENSORS, DATA_GIGABYTES, PERCENTAGE import homeassistant.helpers.config_validation as cv @@ -18,26 +25,59 @@ SENSOR_SIGNAL = "signal" SENSOR_NETWORK = "network" SENSOR_SMS_UNREAD = "sms" -SENSORS = { - SENSOR_NETWORK: ("signal strength", "Network", None, "mdi:access-point-network"), - SENSOR_SIGNAL: ( - "signal strength", - "Signal Strength", - PERCENTAGE, - "mdi:signal", + +@dataclass +class DovadoRequiredKeysMixin: + """Mixin for required keys.""" + + identifier: str + + +@dataclass +class DovadoSensorEntityDescription(SensorEntityDescription, DovadoRequiredKeysMixin): + """Describes Dovado sensor entity.""" + + +SENSOR_TYPES: tuple[DovadoSensorEntityDescription, ...] = ( + DovadoSensorEntityDescription( + identifier=SENSOR_NETWORK, + key="signal strength", + name="Network", + icon="mdi:access-point-network", ), - SENSOR_SMS_UNREAD: ("sms unread", "SMS unread", "", "mdi:message-text-outline"), - SENSOR_UPLOAD: ("traffic modem tx", "Sent", DATA_GIGABYTES, "mdi:cloud-upload"), - SENSOR_DOWNLOAD: ( - "traffic modem rx", - "Received", - DATA_GIGABYTES, - "mdi:cloud-download", + DovadoSensorEntityDescription( + identifier=SENSOR_SIGNAL, + key="signal strength", + name="Signal Strength", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:signal", ), -} + DovadoSensorEntityDescription( + identifier=SENSOR_SMS_UNREAD, + key="sms unread", + name="SMS unread", + icon="mdi:message-text-outline", + ), + DovadoSensorEntityDescription( + identifier=SENSOR_UPLOAD, + key="traffic modem tx", + name="Sent", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:cloud-upload", + ), + DovadoSensorEntityDescription( + identifier=SENSOR_DOWNLOAD, + key="traffic modem rx", + name="Received", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:cloud-download", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)])} + {vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSOR_KEYS)])} ) @@ -45,63 +85,50 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Dovado sensor platform.""" dovado = hass.data[DOVADO_DOMAIN] - entities = [] - for sensor in config[CONF_SENSORS]: - entities.append(DovadoSensor(dovado, sensor)) - + sensors = config[CONF_SENSORS] + entities = [ + DovadoSensor(dovado, description) + for description in SENSOR_TYPES + if description.key in sensors + ] add_entities(entities) class DovadoSensor(SensorEntity): """Representation of a Dovado sensor.""" - def __init__(self, data, sensor): + entity_description: DovadoSensorEntityDescription + + def __init__(self, data, description: DovadoSensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self._data = data - self._sensor = sensor - self._state = self._compute_state() + + self._attr_name = f"{data.name} {description.name}" + self._attr_native_value = self._compute_state() def _compute_state(self): """Compute the state of the sensor.""" - state = self._data.state.get(SENSORS[self._sensor][0]) - if self._sensor == SENSOR_NETWORK: + state = self._data.state.get(self.entity_description.key) + sensor_identifier = self.entity_description.identifier + if sensor_identifier == SENSOR_NETWORK: match = re.search(r"\((.+)\)", state) return match.group(1) if match else None - if self._sensor == SENSOR_SIGNAL: + if sensor_identifier == SENSOR_SIGNAL: try: return int(state.split()[0]) except ValueError: return None - if self._sensor == SENSOR_SMS_UNREAD: + if sensor_identifier == SENSOR_SMS_UNREAD: return int(state) - if self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: + if sensor_identifier in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: return round(float(state) / 1e6, 1) return state def update(self): """Update sensor values.""" self._data.update() - self._state = self._compute_state() - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._data.name} {SENSORS[self._sensor][1]}" - - @property - def native_value(self): - """Return the sensor state.""" - return self._state - - @property - def icon(self): - """Return the icon for the sensor.""" - return SENSORS[self._sensor][3] - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSORS[self._sensor][2] + self._attr_native_value = self._compute_state() @property def extra_state_attributes(self): From 9fe434ac364d810375f34e72606cf1e7c0637438 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:44:03 +0200 Subject: [PATCH 673/903] Use EntityDescription - zamg (#54942) --- homeassistant/components/zamg/sensor.py | 224 +++++++++++++++--------- 1 file changed, 140 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 5659e4835db..054646800a9 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -1,16 +1,20 @@ """Sensor for the Austrian "Zentralanstalt für Meteorologie und Geodynamik".""" +from __future__ import annotations + import csv +from dataclasses import dataclass from datetime import datetime, timedelta import gzip import json import logging import os +from typing import Type, Union from aiohttp.hdrs import USER_AGENT import requests import voluptuous as vol -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( AREA_SQUARE_METERS, ATTR_ATTRIBUTION, @@ -43,72 +47,141 @@ DEFAULT_NAME = "zamg" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") -SENSOR_TYPES = { - "pressure": ("Pressure", PRESSURE_HPA, None, "LDstat hPa", float), - "pressure_sealevel": ( - "Pressure at Sea Level", - PRESSURE_HPA, - None, - "LDred hPa", - float, +DTypeT = Union[Type[int], Type[float], Type[str]] + + +@dataclass +class ZamgRequiredKeysMixin: + """Mixin for required keys.""" + + col_heading: str + dtype: DTypeT + + +@dataclass +class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin): + """Describes Zamg sensor entity.""" + + +SENSOR_TYPES: tuple[ZamgSensorEntityDescription, ...] = ( + ZamgSensorEntityDescription( + key="pressure", + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + col_heading="LDstat hPa", + dtype=float, ), - "humidity": ("Humidity", PERCENTAGE, None, "RF %", int), - "wind_speed": ( - "Wind Speed", - SPEED_KILOMETERS_PER_HOUR, - None, - f"WG {SPEED_KILOMETERS_PER_HOUR}", - float, + ZamgSensorEntityDescription( + key="pressure_sealevel", + name="Pressure at Sea Level", + native_unit_of_measurement=PRESSURE_HPA, + col_heading="LDred hPa", + dtype=float, ), - "wind_bearing": ("Wind Bearing", DEGREE, None, f"WR {DEGREE}", int), - "wind_max_speed": ( - "Top Wind Speed", - None, - SPEED_KILOMETERS_PER_HOUR, - f"WSG {SPEED_KILOMETERS_PER_HOUR}", - float, + ZamgSensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + col_heading="RF %", + dtype=int, ), - "wind_max_bearing": ("Top Wind Bearing", DEGREE, None, f"WSR {DEGREE}", int), - "sun_last_hour": ("Sun Last Hour", PERCENTAGE, None, f"SO {PERCENTAGE}", int), - "temperature": ( - "Temperature", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - f"T {TEMP_CELSIUS}", - float, + ZamgSensorEntityDescription( + key="wind_speed", + name="Wind Speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + col_heading=f"WG {SPEED_KILOMETERS_PER_HOUR}", + dtype=float, ), - "precipitation": ( - "Precipitation", - None, - f"l/{AREA_SQUARE_METERS}", - f"N l/{AREA_SQUARE_METERS}", - float, + ZamgSensorEntityDescription( + key="wind_bearing", + name="Wind Bearing", + native_unit_of_measurement=DEGREE, + col_heading=f"WR {DEGREE}", + dtype=int, ), - "dewpoint": ( - "Dew Point", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - f"TP {TEMP_CELSIUS}", - float, + ZamgSensorEntityDescription( + key="wind_max_speed", + name="Top Wind Speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + col_heading=f"WSG {SPEED_KILOMETERS_PER_HOUR}", + dtype=float, + ), + ZamgSensorEntityDescription( + key="wind_max_bearing", + name="Top Wind Bearing", + native_unit_of_measurement=DEGREE, + col_heading=f"WSR {DEGREE}", + dtype=int, + ), + ZamgSensorEntityDescription( + key="sun_last_hour", + name="Sun Last Hour", + native_unit_of_measurement=PERCENTAGE, + col_heading=f"SO {PERCENTAGE}", + dtype=int, + ), + ZamgSensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + col_heading=f"T {TEMP_CELSIUS}", + dtype=float, + ), + ZamgSensorEntityDescription( + key="precipitation", + name="Precipitation", + native_unit_of_measurement=f"l/{AREA_SQUARE_METERS}", + col_heading=f"N l/{AREA_SQUARE_METERS}", + dtype=float, + ), + ZamgSensorEntityDescription( + key="dewpoint", + name="Dew Point", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + col_heading=f"TP {TEMP_CELSIUS}", + dtype=float, ), # The following probably not useful for general consumption, # but we need them to fill in internal attributes - "station_name": ("Station Name", None, None, "Name", str), - "station_elevation": ( - "Station Elevation", - LENGTH_METERS, - None, - f"Höhe {LENGTH_METERS}", - int, + ZamgSensorEntityDescription( + key="station_name", + name="Station Name", + col_heading="Name", + dtype=str, ), - "update_date": ("Update Date", None, None, "Datum", str), - "update_time": ("Update Time", None, None, "Zeit", str), + ZamgSensorEntityDescription( + key="station_elevation", + name="Station Elevation", + native_unit_of_measurement=LENGTH_METERS, + col_heading=f"Höhe {LENGTH_METERS}", + dtype=int, + ), + ZamgSensorEntityDescription( + key="update_date", + name="Update Date", + col_heading="Datum", + dtype=str, + ), + ZamgSensorEntityDescription( + key="update_time", + name="Update Time", + col_heading="Zeit", + dtype=str, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + +API_FIELDS: dict[str, tuple[str, DTypeT]] = { + desc.col_heading: (desc.key, desc.dtype) for desc in SENSOR_TYPES } PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS, default=["temperature"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_STATION_ID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -124,7 +197,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ZAMG sensor platform.""" - name = config.get(CONF_NAME) + name = config[CONF_NAME] latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) @@ -146,10 +219,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Received error from ZAMG: %s", err) return False + monitored_conditions = config[CONF_MONITORED_CONDITIONS] add_entities( [ - ZamgSensor(probe, variable, name) - for variable in config[CONF_MONITORED_CONDITIONS] + ZamgSensor(probe, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions ], True, ) @@ -158,27 +233,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ZamgSensor(SensorEntity): """Implementation of a ZAMG sensor.""" - def __init__(self, probe, variable, name): - """Initialize the sensor.""" - self.probe = probe - self.client_name = name - self.variable = variable - self._attr_device_class = SENSOR_TYPES[variable][2] + entity_description: ZamgSensorEntityDescription - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self.variable}" + def __init__(self, probe, name, description: ZamgSensorEntityDescription): + """Initialize the sensor.""" + self.entity_description = description + self.probe = probe + self._attr_name = f"{name} {description.key}" @property def native_value(self): """Return the state of the sensor.""" - return self.probe.get_data(self.variable) - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self.variable][1] + return self.probe.get_data(self.entity_description.key) @property def extra_state_attributes(self): @@ -238,22 +304,12 @@ class ZamgData: for row in self.current_observations(): if row.get("Station") == self._station_id: - api_fields = { - col_heading: (standard_name, dtype) - for standard_name, ( - _, - _, - _, - col_heading, - dtype, - ) in SENSOR_TYPES.items() - } self.data = { - api_fields.get(col_heading)[0]: api_fields.get(col_heading)[1]( + API_FIELDS[col_heading][0]: API_FIELDS[col_heading][1]( v.replace(",", ".") ) for col_heading, v in row.items() - if col_heading in api_fields and v + if col_heading in API_FIELDS and v } break else: From 4d93f23feb6dd1632d2af6e62d4d8ac67aad7e55 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:47:46 +0200 Subject: [PATCH 674/903] Use EntityDescription - enocean (#55087) --- homeassistant/components/enocean/sensor.py | 183 ++++++++++----------- 1 file changed, 85 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index ef7fe242092..ccf01eec448 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -1,7 +1,13 @@ """Support for EnOcean sensors.""" +from __future__ import annotations + import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ID, @@ -32,32 +38,36 @@ SENSOR_TYPE_POWER = "powersensor" SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_TYPE_WINDOWHANDLE = "windowhandle" -SENSOR_TYPES = { - SENSOR_TYPE_HUMIDITY: { - "name": "Humidity", - "unit": PERCENTAGE, - "icon": "mdi:water-percent", - "class": DEVICE_CLASS_HUMIDITY, - }, - SENSOR_TYPE_POWER: { - "name": "Power", - "unit": POWER_WATT, - "icon": "mdi:power-plug", - "class": DEVICE_CLASS_POWER, - }, - SENSOR_TYPE_TEMPERATURE: { - "name": "Temperature", - "unit": TEMP_CELSIUS, - "icon": "mdi:thermometer", - "class": DEVICE_CLASS_TEMPERATURE, - }, - SENSOR_TYPE_WINDOWHANDLE: { - "name": "WindowHandle", - "unit": None, - "icon": "mdi:window", - "class": None, - }, -} +SENSOR_DESC_TEMPERATURE = SensorEntityDescription( + key=SENSOR_TYPE_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=DEVICE_CLASS_TEMPERATURE, +) + +SENSOR_DESC_HUMIDITY = SensorEntityDescription( + key=SENSOR_TYPE_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + device_class=DEVICE_CLASS_HUMIDITY, +) + +SENSOR_DESC_POWER = SensorEntityDescription( + key=SENSOR_TYPE_POWER, + name="Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:power-plug", + device_class=DEVICE_CLASS_POWER, +) + +SENSOR_DESC_WINDOWHANDLE = SensorEntityDescription( + key=SENSOR_TYPE_WINDOWHANDLE, + name="WindowHandle", + icon="mdi:window", +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -74,81 +84,60 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up an EnOcean sensor device.""" - dev_id = config.get(CONF_ID) - dev_name = config.get(CONF_NAME) - sensor_type = config.get(CONF_DEVICE_CLASS) + dev_id = config[CONF_ID] + dev_name = config[CONF_NAME] + sensor_type = config[CONF_DEVICE_CLASS] + entities: list[EnOceanSensor] = [] if sensor_type == SENSOR_TYPE_TEMPERATURE: - temp_min = config.get(CONF_MIN_TEMP) - temp_max = config.get(CONF_MAX_TEMP) - range_from = config.get(CONF_RANGE_FROM) - range_to = config.get(CONF_RANGE_TO) - add_entities( - [ - EnOceanTemperatureSensor( - dev_id, dev_name, temp_min, temp_max, range_from, range_to - ) - ] - ) + temp_min = config[CONF_MIN_TEMP] + temp_max = config[CONF_MAX_TEMP] + range_from = config[CONF_RANGE_FROM] + range_to = config[CONF_RANGE_TO] + entities = [ + EnOceanTemperatureSensor( + dev_id, + dev_name, + SENSOR_DESC_TEMPERATURE, + scale_min=temp_min, + scale_max=temp_max, + range_from=range_from, + range_to=range_to, + ) + ] elif sensor_type == SENSOR_TYPE_HUMIDITY: - add_entities([EnOceanHumiditySensor(dev_id, dev_name)]) + entities = [EnOceanHumiditySensor(dev_id, dev_name, SENSOR_DESC_HUMIDITY)] elif sensor_type == SENSOR_TYPE_POWER: - add_entities([EnOceanPowerSensor(dev_id, dev_name)]) + entities = [EnOceanPowerSensor(dev_id, dev_name, SENSOR_DESC_POWER)] elif sensor_type == SENSOR_TYPE_WINDOWHANDLE: - add_entities([EnOceanWindowHandle(dev_id, dev_name)]) + entities = [EnOceanWindowHandle(dev_id, dev_name, SENSOR_DESC_WINDOWHANDLE)] + + if entities: + add_entities(entities) class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): """Representation of an EnOcean sensor device such as a power meter.""" - def __init__(self, dev_id, dev_name, sensor_type): + def __init__(self, dev_id, dev_name, description: SensorEntityDescription): """Initialize the EnOcean sensor device.""" super().__init__(dev_id, dev_name) - self._sensor_type = sensor_type - self._device_class = SENSOR_TYPES[self._sensor_type]["class"] - self._dev_name = f"{SENSOR_TYPES[self._sensor_type]['name']} {dev_name}" - self._unit_of_measurement = SENSOR_TYPES[self._sensor_type]["unit"] - self._icon = SENSOR_TYPES[self._sensor_type]["icon"] - self._state = None - - @property - def name(self): - """Return the name of the device.""" - return self._dev_name - - @property - def icon(self): - """Icon to use in the frontend.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement + self.entity_description = description + self._attr_name = f"{description.name} {dev_name}" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" # If not None, we got an initial value. await super().async_added_to_hass() - if self._state is not None: + if self._attr_native_value is not None: return state = await self.async_get_last_state() if state is not None: - self._state = state.state + self._attr_native_value = state.state def value_changed(self, packet): """Update the internal state of the sensor.""" @@ -161,10 +150,6 @@ class EnOceanPowerSensor(EnOceanSensor): - A5-12-01 (Automated Meter Reading, Electricity) """ - def __init__(self, dev_id, dev_name): - """Initialize the EnOcean power sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_POWER) - def value_changed(self, packet): """Update the internal state of the sensor.""" if packet.rorg != 0xA5: @@ -174,7 +159,7 @@ class EnOceanPowerSensor(EnOceanSensor): # this packet reports the current value raw_val = packet.parsed["MR"]["raw_value"] divisor = packet.parsed["DIV"]["raw_value"] - self._state = raw_val / (10 ** divisor) + self._attr_native_value = raw_val / (10 ** divisor) self.schedule_update_ha_state() @@ -196,9 +181,19 @@ class EnOceanTemperatureSensor(EnOceanSensor): - A5-10-10 to A5-10-14 """ - def __init__(self, dev_id, dev_name, scale_min, scale_max, range_from, range_to): + def __init__( + self, + dev_id, + dev_name, + description: SensorEntityDescription, + *, + scale_min, + scale_max, + range_from, + range_to, + ): """Initialize the EnOcean temperature sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_TEMPERATURE) + super().__init__(dev_id, dev_name, description) self._scale_min = scale_min self._scale_max = scale_max self.range_from = range_from @@ -213,7 +208,7 @@ class EnOceanTemperatureSensor(EnOceanSensor): raw_val = packet.data[3] temperature = temp_scale / temp_range * (raw_val - self.range_from) temperature += self._scale_min - self._state = round(temperature, 1) + self._attr_native_value = round(temperature, 1) self.schedule_update_ha_state() @@ -226,16 +221,12 @@ class EnOceanHumiditySensor(EnOceanSensor): - A5-10-10 to A5-10-14 (Room Operating Panels) """ - def __init__(self, dev_id, dev_name): - """Initialize the EnOcean humidity sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_HUMIDITY) - def value_changed(self, packet): """Update the internal state of the sensor.""" if packet.rorg != 0xA5: return humidity = packet.data[2] * 100 / 250 - self._state = round(humidity, 1) + self._attr_native_value = round(humidity, 1) self.schedule_update_ha_state() @@ -246,20 +237,16 @@ class EnOceanWindowHandle(EnOceanSensor): - F6-10-00 (Mechanical handle / Hoppe AG) """ - def __init__(self, dev_id, dev_name): - """Initialize the EnOcean window handle sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_WINDOWHANDLE) - def value_changed(self, packet): """Update the internal state of the sensor.""" action = (packet.data[1] & 0x70) >> 4 if action == 0x07: - self._state = STATE_CLOSED + self._attr_native_value = STATE_CLOSED if action in (0x04, 0x06): - self._state = STATE_OPEN + self._attr_native_value = STATE_OPEN if action == 0x05: - self._state = "tilt" + self._attr_native_value = "tilt" self.schedule_update_ha_state() From 52bbaa827718bf1b22ad07b0b7d7936c58d6e6bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Aug 2021 15:50:44 -0500 Subject: [PATCH 675/903] Bump pymyq to 3.1.3 (#55099) --- homeassistant/components/myq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 33cbea71bcd..fa9313eb9a1 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -2,7 +2,7 @@ "domain": "myq", "name": "MyQ", "documentation": "https://www.home-assistant.io/integrations/myq", - "requirements": ["pymyq==3.1.2"], + "requirements": ["pymyq==3.1.3"], "codeowners": ["@bdraco","@ehendrix23"], "config_flow": true, "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index 1163c6f5a5d..592d47b198b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1623,7 +1623,7 @@ pymonoprice==0.3 pymsteams==0.1.12 # homeassistant.components.myq -pymyq==3.1.2 +pymyq==3.1.3 # homeassistant.components.mysensors pymysensors==0.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a32c0fad370..86a56c1376a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -936,7 +936,7 @@ pymodbus==2.5.3rc1 pymonoprice==0.3 # homeassistant.components.myq -pymyq==3.1.2 +pymyq==3.1.3 # homeassistant.components.mysensors pymysensors==0.21.0 From 0c68a54deaca7edcbec6851c98dd206c7d791cb9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 23 Aug 2021 22:51:29 +0200 Subject: [PATCH 676/903] Fix TypeError in Xiaomi Miio fan platform (#55091) --- homeassistant/components/xiaomi_miio/fan.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 54ce701cf92..24b75122424 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -316,7 +316,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): } ) self._mode = self._state_attrs.get(ATTR_MODE) - self._fan_level = self._state_attrs.get(ATTR_FAN_LEVEL) + self._fan_level = self.coordinator.data.fan_level self.async_write_ha_state() # @@ -423,7 +423,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): {attribute: None for attribute in self._available_attributes} ) self._mode = self._state_attrs.get(ATTR_MODE) - self._fan_level = self._state_attrs.get(ATTR_FAN_LEVEL) + self._fan_level = self.coordinator.data.fan_level @property def preset_mode(self): @@ -451,6 +451,10 @@ class XiaomiAirPurifier(XiaomiGenericDevice): This method is a coroutine. """ + if percentage == 0: + await self.async_turn_off() + return + speed_mode = math.ceil( percentage_to_ranged_value((1, self._speed_count), percentage) ) @@ -510,6 +514,8 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): @property def percentage(self): """Return the current percentage based speed.""" + if self._fan_level is None: + return None if self._state: return ranged_value_to_percentage((1, 3), self._fan_level) @@ -529,6 +535,10 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): This method is a coroutine. """ + if percentage == 0: + await self.async_turn_off() + return + fan_level = math.ceil(percentage_to_ranged_value((1, 3), percentage)) if not fan_level: return From cada3d181971a845be1a0e1e0da7310c416e82c2 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Aug 2021 22:52:34 +0200 Subject: [PATCH 677/903] Activate mypy for smarttub (#55070) --- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 2 files changed, 4 deletions(-) diff --git a/mypy.ini b/mypy.ini index f425c3179ed..e91ddd41902 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1580,9 +1580,6 @@ ignore_errors = true [mypy-homeassistant.components.smartthings.*] ignore_errors = true -[mypy-homeassistant.components.smarttub.*] -ignore_errors = true - [mypy-homeassistant.components.solaredge.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index a8b26b8c6f5..936e8ea4234 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -119,7 +119,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.sharkiq.*", "homeassistant.components.sma.*", "homeassistant.components.smartthings.*", - "homeassistant.components.smarttub.*", "homeassistant.components.solaredge.*", "homeassistant.components.somfy.*", "homeassistant.components.somfy_mylink.*", From 9b472aee9a348b99eb01c99e6b20c88b4cf5a534 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Aug 2021 22:55:00 +0200 Subject: [PATCH 678/903] Activate mypy for wink (#55077) --- homeassistant/components/wink/__init__.py | 5 ++++- homeassistant/components/wink/climate.py | 4 ++-- homeassistant/components/wink/fan.py | 4 +++- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index f346d9145f8..702851a5e14 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -1,9 +1,12 @@ """Support for Wink hubs.""" +from __future__ import annotations + from datetime import timedelta import json import logging import os import time +from typing import Any from aiohttp.web import Response from pubnubsubhandler import PubNubSubscriptionHandler @@ -208,7 +211,7 @@ WINK_COMPONENTS = [ "water_heater", ] -WINK_HUBS = [] +WINK_HUBS: list[Any] = [] def _request_app_setup(hass, config): diff --git a/homeassistant/components/wink/climate.py b/homeassistant/components/wink/climate.py index 4c783e6bde1..7836d71614f 100644 --- a/homeassistant/components/wink/climate.py +++ b/homeassistant/components/wink/climate.py @@ -234,7 +234,7 @@ class WinkThermostat(WinkDevice, ClimateEntity): return HVAC_MODE_HEAT if wink_mode == "eco": return HVAC_MODE_AUTO - return WINK_HVAC_TO_HA.get(wink_mode) + return WINK_HVAC_TO_HA.get(wink_mode, "") @property def hvac_modes(self): @@ -437,7 +437,7 @@ class WinkAC(WinkDevice, ClimateEntity): wink_mode = self.wink.current_mode() if wink_mode == "auto_eco": return HVAC_MODE_COOL - return WINK_HVAC_TO_HA.get(wink_mode) + return WINK_HVAC_TO_HA.get(wink_mode, "") @property def hvac_modes(self): diff --git a/homeassistant/components/wink/fan.py b/homeassistant/components/wink/fan.py index 3aab66e353d..b918d596ef4 100644 --- a/homeassistant/components/wink/fan.py +++ b/homeassistant/components/wink/fan.py @@ -1,4 +1,6 @@ """Support for Wink fans.""" +from __future__ import annotations + import pywink from homeassistant.components.fan import ( @@ -67,7 +69,7 @@ class WinkFanDevice(WinkDevice, FanEntity): return self.wink.state() @property - def speed(self) -> str: + def speed(self) -> str | None: """Return the current speed.""" current_wink_speed = self.wink.current_fan_speed() if SPEED_AUTO == current_wink_speed: diff --git a/mypy.ini b/mypy.ini index e91ddd41902..767ecf18ea1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1655,9 +1655,6 @@ ignore_errors = true [mypy-homeassistant.components.wemo.*] ignore_errors = true -[mypy-homeassistant.components.wink.*] -ignore_errors = true - [mypy-homeassistant.components.withings.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 936e8ea4234..799a36f92ea 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -144,7 +144,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.verisure.*", "homeassistant.components.vizio.*", "homeassistant.components.wemo.*", - "homeassistant.components.wink.*", "homeassistant.components.withings.*", "homeassistant.components.xbox.*", "homeassistant.components.xiaomi_aqara.*", From c2b2c8604f2626e95d09d3f78412c3baaaf76832 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 22:56:31 +0200 Subject: [PATCH 679/903] Use EntityDescription - volkszaehler (#55034) --- .../components/volkszaehler/sensor.py | 84 +++++++++++-------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py index 21705d494d9..a6f9061319e 100644 --- a/homeassistant/components/volkszaehler/sensor.py +++ b/homeassistant/components/volkszaehler/sensor.py @@ -1,4 +1,6 @@ """Support for consuming values for the Volkszaehler API.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,7 +8,11 @@ from volkszaehler import Volkszaehler from volkszaehler.exceptions import VolkszaehlerApiConnectionError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_MONITORED_CONDITIONS, @@ -30,12 +36,34 @@ DEFAULT_PORT = 80 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) -SENSOR_TYPES = { - "average": ["Average", POWER_WATT, "mdi:power-off"], - "consumption": ["Consumption", ENERGY_WATT_HOUR, "mdi:power-plug"], - "max": ["Max", POWER_WATT, "mdi:arrow-up"], - "min": ["Min", POWER_WATT, "mdi:arrow-down"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="average", + name="Average", + native_unit_of_measurement=POWER_WATT, + icon="mdi:power-off", + ), + SensorEntityDescription( + key="consumption", + name="Consumption", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:power-plug", + ), + SensorEntityDescription( + key="max", + name="Max", + native_unit_of_measurement=POWER_WATT, + icon="mdi:arrow-up", + ), + SensorEntityDescription( + key="min", + name="Min", + native_unit_of_measurement=POWER_WATT, + icon="mdi:arrow-down", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -44,7 +72,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_MONITORED_CONDITIONS, default=["average"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -69,54 +97,38 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if vz_api.api.data is None: raise PlatformNotReady - dev = [] - for condition in conditions: - dev.append(VolkszaehlerSensor(vz_api, name, condition)) + entities = [ + VolkszaehlerSensor(vz_api, name, description) + for description in SENSOR_TYPES + if description.key in conditions + ] - async_add_entities(dev, True) + async_add_entities(entities, True) class VolkszaehlerSensor(SensorEntity): """Implementation of a Volkszaehler sensor.""" - def __init__(self, vz_api, name, sensor_type): + def __init__(self, vz_api, name, description: SensorEntityDescription): """Initialize the Volkszaehler sensor.""" + self.entity_description = description self.vz_api = vz_api - self._name = name - self.type = sensor_type - self._state = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {SENSOR_TYPES[self.type][0]}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return SENSOR_TYPES[self.type][1] + self._attr_name = f"{name} {description.name}" @property def available(self): """Could the device be accessed during the last update call.""" return self.vz_api.available - @property - def native_value(self): - """Return the state of the resources.""" - return self._state - async def async_update(self): """Get the latest data from REST API.""" await self.vz_api.async_update() if self.vz_api.api.data is not None: - self._state = round(getattr(self.vz_api.api, self.type), 2) + self._attr_native_value = round( + getattr(self.vz_api.api, self.entity_description.key), 2 + ) class VolkszaehlerData: From dae40530bd4738786ef5bff96e4640574035ff64 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Aug 2021 22:57:33 +0200 Subject: [PATCH 680/903] Activate mypy for synology_srm (#55059) --- homeassistant/components/synology_srm/device_tracker.py | 8 +++++--- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 55f455ad7cc..f97b1735e21 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -1,4 +1,6 @@ """Device tracker for Synology SRM routers.""" +from __future__ import annotations + import logging import synology_srm @@ -6,7 +8,7 @@ import voluptuous as vol from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import ( @@ -26,7 +28,7 @@ DEFAULT_PORT = 8001 DEFAULT_SSL = True DEFAULT_VERIFY_SSL = False -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, @@ -106,7 +108,7 @@ class SynologySrmDeviceScanner(DeviceScanner): device = next( (result for result in self.devices if result["mac"] == device), None ) - filtered_attributes = {} + filtered_attributes: dict[str, str] = {} if not device: return filtered_attributes for attribute, alias in ATTRIBUTE_ALIAS.items(): diff --git a/mypy.ini b/mypy.ini index 767ecf18ea1..0db7853177e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1607,9 +1607,6 @@ ignore_errors = true [mypy-homeassistant.components.switchbot.*] ignore_errors = true -[mypy-homeassistant.components.synology_srm.*] -ignore_errors = true - [mypy-homeassistant.components.system_health.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 799a36f92ea..b8b80ce3afd 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -128,7 +128,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.stt.*", "homeassistant.components.surepetcare.*", "homeassistant.components.switchbot.*", - "homeassistant.components.synology_srm.*", "homeassistant.components.system_health.*", "homeassistant.components.system_log.*", "homeassistant.components.tado.*", From 354dbe91b7941d334c5e05828426da0947732fb3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 23:00:14 +0200 Subject: [PATCH 681/903] Use EntityDescription - ombi (#55086) --- homeassistant/components/ombi/const.py | 44 ++++++++++++++---- homeassistant/components/ombi/sensor.py | 61 ++++++++----------------- 2 files changed, 56 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/ombi/const.py b/homeassistant/components/ombi/const.py index 784b46a99b7..3ed67389003 100644 --- a/homeassistant/components/ombi/const.py +++ b/homeassistant/components/ombi/const.py @@ -1,4 +1,8 @@ """Support for Ombi.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntityDescription + ATTR_SEASON = "season" CONF_URLBASE = "urlbase" @@ -13,11 +17,35 @@ SERVICE_MOVIE_REQUEST = "submit_movie_request" SERVICE_MUSIC_REQUEST = "submit_music_request" SERVICE_TV_REQUEST = "submit_tv_request" -SENSOR_TYPES = { - "movies": {"type": "Movie requests", "icon": "mdi:movie"}, - "tv": {"type": "TV show requests", "icon": "mdi:television-classic"}, - "music": {"type": "Music album requests", "icon": "mdi:album"}, - "pending": {"type": "Pending requests", "icon": "mdi:clock-alert-outline"}, - "approved": {"type": "Approved requests", "icon": "mdi:check"}, - "available": {"type": "Available requests", "icon": "mdi:download"}, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="movies", + name="Movie requests", + icon="mdi:movie", + ), + SensorEntityDescription( + key="tv", + name="TV show requests", + icon="mdi:television-classic", + ), + SensorEntityDescription( + key="music", + name="Music album requests", + icon="mdi:album", + ), + SensorEntityDescription( + key="pending", + name="Pending requests", + icon="mdi:clock-alert-outline", + ), + SensorEntityDescription( + key="approved", + name="Approved requests", + icon="mdi:check", + ), + SensorEntityDescription( + key="available", + name="Available requests", + icon="mdi:download", + ), +) diff --git a/homeassistant/components/ombi/sensor.py b/homeassistant/components/ombi/sensor.py index 50bb121dc4b..e8d7da78cc8 100644 --- a/homeassistant/components/ombi/sensor.py +++ b/homeassistant/components/ombi/sensor.py @@ -4,7 +4,7 @@ import logging from pyombi import OmbiError -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from .const import DOMAIN, SENSOR_TYPES @@ -18,60 +18,39 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if discovery_info is None: return - sensors = [] - ombi = hass.data[DOMAIN]["instance"] - for sensor, sensor_val in SENSOR_TYPES.items(): - sensor_label = sensor - sensor_type = sensor_val["type"] - sensor_icon = sensor_val["icon"] - sensors.append(OmbiSensor(sensor_label, sensor_type, ombi, sensor_icon)) + entities = [OmbiSensor(ombi, description) for description in SENSOR_TYPES] - add_entities(sensors, True) + add_entities(entities, True) class OmbiSensor(SensorEntity): """Representation of an Ombi sensor.""" - def __init__(self, label, sensor_type, ombi, icon): + def __init__(self, ombi, description: SensorEntityDescription): """Initialize the sensor.""" - self._state = None - self._label = label - self._type = sensor_type + self.entity_description = description self._ombi = ombi - self._icon = icon - @property - def name(self): - """Return the name of the sensor.""" - return f"Ombi {self._type}" - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return self._icon - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + self._attr_name = f"Ombi {description.name}" def update(self): """Update the sensor.""" try: - if self._label == "movies": - self._state = self._ombi.movie_requests - elif self._label == "tv": - self._state = self._ombi.tv_requests - elif self._label == "music": - self._state = self._ombi.music_requests - elif self._label == "pending": - self._state = self._ombi.total_requests["pending"] - elif self._label == "approved": - self._state = self._ombi.total_requests["approved"] - elif self._label == "available": - self._state = self._ombi.total_requests["available"] + sensor_type = self.entity_description.key + if sensor_type == "movies": + self._attr_native_value = self._ombi.movie_requests + elif sensor_type == "tv": + self._attr_native_value = self._ombi.tv_requests + elif sensor_type == "music": + self._attr_native_value = self._ombi.music_requests + elif sensor_type == "pending": + self._attr_native_value = self._ombi.total_requests["pending"] + elif sensor_type == "approved": + self._attr_native_value = self._ombi.total_requests["approved"] + elif sensor_type == "available": + self._attr_native_value = self._ombi.total_requests["available"] except OmbiError as err: _LOGGER.warning("Unable to update Ombi sensor: %s", err) - self._state = None + self._attr_native_value = None From 7314b1c8e122a67343c54828afffac491d223461 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Aug 2021 23:04:17 +0200 Subject: [PATCH 682/903] Use EntityDescription - thinkingcleaner (#55068) --- .../components/thinkingcleaner/sensor.py | 80 +++++++++---------- .../components/thinkingcleaner/switch.py | 62 ++++++++------ 2 files changed, 76 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py index e7530636169..588fc212138 100644 --- a/homeassistant/components/thinkingcleaner/sensor.py +++ b/homeassistant/components/thinkingcleaner/sensor.py @@ -1,22 +1,39 @@ """Support for ThinkingCleaner sensors.""" +from __future__ import annotations + from datetime import timedelta from pythinkingcleaner import Discovery, ThinkingCleaner import voluptuous as vol from homeassistant import util -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_HOST, PERCENTAGE import homeassistant.helpers.config_validation as cv MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) -SENSOR_TYPES = { - "battery": ["Battery", PERCENTAGE, "mdi:battery"], - "state": ["State", None, None], - "capacity": ["Capacity", None, None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery", + ), + SensorEntityDescription( + key="state", + name="State", + ), + SensorEntityDescription( + key="capacity", + name="Capacity", + ), +) STATES = { "st_base": "On homebase: Not Charging", @@ -64,53 +81,34 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for device_object in devices: device_object.update() - dev = [] - for device in devices: - for type_name in SENSOR_TYPES: - dev.append(ThinkingCleanerSensor(device, type_name, update_devices)) + entities = [ + ThinkingCleanerSensor(device, update_devices, description) + for device in devices + for description in SENSOR_TYPES + ] - add_entities(dev) + add_entities(entities) class ThinkingCleanerSensor(SensorEntity): """Representation of a ThinkingCleaner Sensor.""" - def __init__(self, tc_object, sensor_type, update_devices): + def __init__(self, tc_object, update_devices, description: SensorEntityDescription): """Initialize the ThinkingCleaner.""" - self.type = sensor_type - + self.entity_description = description self._tc_object = tc_object self._update_devices = update_devices - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._state = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._tc_object.name} {SENSOR_TYPES[self.type][0]}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_name = f"{tc_object.name} {description.name}" def update(self): """Update the sensor.""" self._update_devices() - if self.type == "battery": - self._state = self._tc_object.battery - elif self.type == "state": - self._state = STATES[self._tc_object.status] - elif self.type == "capacity": - self._state = self._tc_object.capacity + sensor_type = self.entity_description.key + if sensor_type == "battery": + self._attr_native_value = self._tc_object.battery + elif sensor_type == "state": + self._attr_native_value = STATES[self._tc_object.status] + elif sensor_type == "capacity": + self._attr_native_value = self._tc_object.capacity diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 6a1d20f9509..eb9f37cede6 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -1,4 +1,6 @@ """Support for ThinkingCleaner switches.""" +from __future__ import annotations + from datetime import timedelta import time @@ -9,7 +11,7 @@ from homeassistant import util from homeassistant.components.switch import PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) @@ -17,11 +19,20 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) MIN_TIME_TO_WAIT = timedelta(seconds=5) MIN_TIME_TO_LOCK_UPDATE = 5 -SWITCH_TYPES = { - "clean": ["Clean", None, None], - "dock": ["Dock", None, None], - "find": ["Find", None, None], -} +SWITCH_TYPES: tuple[ToggleEntityDescription, ...] = ( + ToggleEntityDescription( + key="clean", + name="Clean", + ), + ToggleEntityDescription( + key="dock", + name="Dock", + ), + ToggleEntityDescription( + key="find", + name="Find", + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_HOST): cv.string}) @@ -41,28 +52,33 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for device_object in devices: device_object.update() - dev = [] - for device in devices: - for type_name in SWITCH_TYPES: - dev.append(ThinkingCleanerSwitch(device, type_name, update_devices)) + entities = [ + ThinkingCleanerSwitch(device, update_devices, description) + for device in devices + for description in SWITCH_TYPES + ] - add_entities(dev) + add_entities(entities) class ThinkingCleanerSwitch(ToggleEntity): """ThinkingCleaner Switch (dock, clean, find me).""" - def __init__(self, tc_object, switch_type, update_devices): + def __init__(self, tc_object, update_devices, description: ToggleEntityDescription): """Initialize the ThinkingCleaner.""" - self.type = switch_type + self.entity_description = description self._update_devices = update_devices self._tc_object = tc_object - self._state = self._tc_object.is_cleaning if switch_type == "clean" else False + self._state = ( + self._tc_object.is_cleaning if description.key == "clean" else False + ) self.lock = False self.last_lock_time = None self.graceful_state = False + self._attr_name = f"{tc_object} {description.name}" + def lock_update(self): """Lock the update since TC clean takes some time to update.""" if self.is_update_locked(): @@ -92,15 +108,10 @@ class ThinkingCleanerSwitch(ToggleEntity): return True - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._tc_object.name} {SWITCH_TYPES[self.type][0]}" - @property def is_on(self): """Return true if device is on.""" - if self.type == "clean": + if self.entity_description.key == "clean": return ( self.graceful_state if self.is_update_locked() @@ -111,22 +122,23 @@ class ThinkingCleanerSwitch(ToggleEntity): def turn_on(self, **kwargs): """Turn the device on.""" - if self.type == "clean": + sensor_type = self.entity_description.key + if sensor_type == "clean": self.set_graceful_lock(True) self._tc_object.start_cleaning() - elif self.type == "dock": + elif sensor_type == "dock": self._tc_object.dock() - elif self.type == "find": + elif sensor_type == "find": self._tc_object.find_me() def turn_off(self, **kwargs): """Turn the device off.""" - if self.type == "clean": + if self.entity_description.key == "clean": self.set_graceful_lock(False) self._tc_object.stop_cleaning() def update(self): """Update the switch state (Only for clean).""" - if self.type == "clean" and not self.is_update_locked(): + if self.entity_description.key == "clean" and not self.is_update_locked(): self._tc_object.update() self._state = STATE_ON if self._tc_object.is_cleaning else STATE_OFF From f91d214ba4337874c598d6a0b7a2645d9ce146c0 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Aug 2021 23:55:57 +0200 Subject: [PATCH 683/903] Break out mock of pymodbus return from mock_modbus to new fixture (#55063) * Remove unused mock_modbus. * Break out mock pymodbus return values. * Review comments. --- tests/components/modbus/conftest.py | 43 +++++++++++++------ tests/components/modbus/test_binary_sensor.py | 12 ++++-- tests/components/modbus/test_climate.py | 2 +- tests/components/modbus/test_cover.py | 4 +- tests/components/modbus/test_fan.py | 13 ++++-- tests/components/modbus/test_light.py | 13 ++++-- tests/components/modbus/test_sensor.py | 35 ++++++++++++--- tests/components/modbus/test_switch.py | 13 ++++-- 8 files changed, 98 insertions(+), 37 deletions(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 33ecf909a6f..7942a8193b3 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -67,6 +67,12 @@ def config_addon(): return None +@pytest.fixture +def do_exception(): + """Remove side_effect to pymodbus calls.""" + return False + + @pytest.fixture async def mock_modbus( hass, caplog, register_words, check_config_loaded, config_addon, do_config @@ -92,18 +98,6 @@ async def mock_modbus( with mock.patch( "homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb ): - if register_words is None: - exc = ModbusException("fail read_coils") - mock_pb.read_coils.side_effect = exc - mock_pb.read_discrete_inputs.side_effect = exc - mock_pb.read_input_registers.side_effect = exc - mock_pb.read_holding_registers.side_effect = exc - else: - read_result = ReadResult(register_words) - mock_pb.read_coils.return_value = read_result - mock_pb.read_discrete_inputs.return_value = read_result - mock_pb.read_input_registers.return_value = read_result - mock_pb.read_holding_registers.return_value = read_result now = dt_util.utcnow() with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): result = await async_setup_component(hass, DOMAIN, config) @@ -113,7 +107,28 @@ async def mock_modbus( @pytest.fixture -async def mock_do_cycle(hass): +async def mock_pymodbus_exception(hass, do_exception, mock_modbus): + """Trigger update call with time_changed event.""" + if do_exception: + exc = ModbusException("fail read_coils") + mock_modbus.read_coils.side_effect = exc + mock_modbus.read_discrete_inputs.side_effect = exc + mock_modbus.read_input_registers.side_effect = exc + mock_modbus.read_holding_registers.side_effect = exc + + +@pytest.fixture +async def mock_pymodbus_return(hass, register_words, mock_modbus): + """Trigger update call with time_changed event.""" + read_result = ReadResult(register_words) + mock_modbus.read_coils.return_value = read_result + mock_modbus.read_discrete_inputs.return_value = read_result + mock_modbus.read_input_registers.return_value = read_result + mock_modbus.read_holding_registers.return_value = read_result + + +@pytest.fixture +async def mock_do_cycle(hass, mock_pymodbus_exception, mock_pymodbus_return): """Trigger update call with time_changed event.""" now = dt_util.utcnow() + timedelta(seconds=90) with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): @@ -129,7 +144,7 @@ async def mock_test_state(hass, request): @pytest.fixture -async def mock_ha(hass): +async def mock_ha(hass, mock_pymodbus_return): """Load homeassistant to allow service calls.""" assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index a4442eb3609..5de03287592 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -80,35 +80,41 @@ async def test_config_binary_sensor(hass, mock_modbus): ], ) @pytest.mark.parametrize( - "register_words,expected", + "register_words,do_exception,expected", [ ( [0xFF], + False, STATE_ON, ), ( [0x01], + False, STATE_ON, ), ( [0x00], + False, STATE_OFF, ), ( [0x80], + False, STATE_OFF, ), ( [0xFE], + False, STATE_OFF, ), ( - None, + [0x00], + True, STATE_UNAVAILABLE, ), ], ) -async def test_all_binary_sensor(hass, expected, mock_modbus, mock_do_cycle): +async def test_all_binary_sensor(hass, expected, mock_do_cycle): """Run test for given config.""" assert hass.states.get(ENTITY_ID).state == expected diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index f3d50317782..187c049b069 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -86,7 +86,7 @@ async def test_config_climate(hass, mock_modbus): ), ], ) -async def test_temperature_climate(hass, expected, mock_modbus, mock_do_cycle): +async def test_temperature_climate(hass, expected, mock_do_cycle): """Run test for given config.""" assert hass.states.get(ENTITY_ID).state == expected diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index 0b639bc0858..9c5b08d59b6 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -106,7 +106,7 @@ async def test_config_cover(hass, mock_modbus): ), ], ) -async def test_coil_cover(hass, expected, mock_modbus, mock_do_cycle): +async def test_coil_cover(hass, expected, mock_do_cycle): """Run test for given config.""" assert hass.states.get(ENTITY_ID).state == expected @@ -150,7 +150,7 @@ async def test_coil_cover(hass, expected, mock_modbus, mock_do_cycle): ), ], ) -async def test_register_cover(hass, expected, mock_modbus, mock_do_cycle): +async def test_register_cover(hass, expected, mock_do_cycle): """Run test for given config.""" assert hass.states.get(ENTITY_ID).state == expected diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 77cb650a184..b2793d15bff 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -157,36 +157,41 @@ async def test_config_fan(hass, mock_modbus): ], ) @pytest.mark.parametrize( - "register_words,config_addon,expected", + "register_words,do_exception,config_addon,expected", [ ( [0x00], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( [0x01], + False, {CONF_VERIFY: {}}, STATE_ON, ), ( [0xFE], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( - None, + [0x00], + True, {CONF_VERIFY: {}}, STATE_UNAVAILABLE, ), ( - None, + [0x00], + True, None, STATE_OFF, ), ], ) -async def test_all_fan(hass, mock_modbus, mock_do_cycle, expected): +async def test_all_fan(hass, mock_do_cycle, expected): """Run test for given config.""" assert hass.states.get(ENTITY_ID).state == expected diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 4d277d0267f..65d42dff987 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -157,36 +157,41 @@ async def test_config_light(hass, mock_modbus): ], ) @pytest.mark.parametrize( - "register_words,config_addon,expected", + "register_words,do_exception,config_addon,expected", [ ( [0x00], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( [0x01], + False, {CONF_VERIFY: {}}, STATE_ON, ), ( [0xFE], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( - None, + [0x00], + True, {CONF_VERIFY: {}}, STATE_UNAVAILABLE, ), ( - None, + [0x00], + True, None, STATE_OFF, ), ], ) -async def test_all_light(hass, mock_modbus, mock_do_cycle, expected): +async def test_all_light(hass, mock_do_cycle, expected): """Run test for given config.""" assert hass.states.get(ENTITY_ID).state == expected diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 8de22f88eb6..a52f833be1c 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -247,7 +247,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl ], ) @pytest.mark.parametrize( - "config_addon,register_words,expected", + "config_addon,register_words,do_exception,expected", [ ( { @@ -258,11 +258,13 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [0], + False, "0", ), ( {}, [0x8000], + False, "-32768", ), ( @@ -274,6 +276,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [7], + False, "20", ), ( @@ -285,6 +288,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [7], + False, "34", ), ( @@ -296,6 +300,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 4, }, [7], + False, "34.0000", ), ( @@ -307,6 +312,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [1], + False, "2", ), ( @@ -318,6 +324,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: "1", }, [9], + False, "18.5", ), ( @@ -329,6 +336,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 2, }, [1], + False, "2.40", ), ( @@ -340,6 +348,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 1, }, [2], + False, "-8.3", ), ( @@ -351,6 +360,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [0x89AB, 0xCDEF], + False, "-1985229329", ), ( @@ -362,6 +372,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [0x89AB, 0xCDEF], + False, str(0x89ABCDEF), ), ( @@ -373,6 +384,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [0x89AB, 0xCDEF, 0x0123, 0x4567], + False, "9920249030613615975", ), ( @@ -384,6 +396,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [0x0123, 0x4567, 0x89AB, 0xCDEF], + False, "163971058432973793", ), ( @@ -395,6 +408,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [0x0123, 0x4567, 0x89AB, 0xCDEF], + False, "163971058432973792", ), ( @@ -407,6 +421,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [0x89AB, 0xCDEF], + False, str(0x89ABCDEF), ), ( @@ -419,6 +434,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [0x89AB, 0xCDEF], + False, str(0x89ABCDEF), ), ( @@ -431,6 +447,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 5, }, [16286, 1617], + False, "1.23457", ), ( @@ -443,6 +460,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_PRECISION: 0, }, [0x3037, 0x2D30, 0x352D, 0x3230, 0x3230, 0x2031, 0x343A, 0x3335], + False, "07-05-2020 14:35", ), ( @@ -454,7 +472,8 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_OFFSET: 0, CONF_PRECISION: 0, }, - None, + [0x00], + True, STATE_UNAVAILABLE, ), ( @@ -466,7 +485,8 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_OFFSET: 0, CONF_PRECISION: 0, }, - None, + [0x00], + True, STATE_UNAVAILABLE, ), ( @@ -476,6 +496,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_SWAP: CONF_SWAP_NONE, }, [0x0102], + False, str(int(0x0102)), ), ( @@ -485,6 +506,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_SWAP: CONF_SWAP_BYTE, }, [0x0201], + False, str(int(0x0102)), ), ( @@ -494,6 +516,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_SWAP: CONF_SWAP_BYTE, }, [0x0102, 0x0304], + False, str(int(0x02010403)), ), ( @@ -503,6 +526,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_SWAP: CONF_SWAP_WORD, }, [0x0102, 0x0304], + False, str(int(0x03040102)), ), ( @@ -512,11 +536,12 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl CONF_SWAP: CONF_SWAP_WORD_BYTE, }, [0x0102, 0x0304], + False, str(int(0x04030201)), ), ], ) -async def test_all_sensor(hass, mock_modbus, mock_do_cycle, expected): +async def test_all_sensor(hass, mock_do_cycle, expected): """Run test for sensor.""" assert hass.states.get(ENTITY_ID).state == expected @@ -570,7 +595,7 @@ async def test_all_sensor(hass, mock_modbus, mock_do_cycle, expected): ), ], ) -async def test_struct_sensor(hass, mock_modbus, mock_do_cycle, expected): +async def test_struct_sensor(hass, mock_do_cycle, expected): """Run test for sensor struct.""" assert hass.states.get(ENTITY_ID).state == expected diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 53907fe18e5..c14a7169ae0 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -171,36 +171,41 @@ async def test_config_switch(hass, mock_modbus): ], ) @pytest.mark.parametrize( - "register_words,config_addon,expected", + "register_words,do_exception,config_addon,expected", [ ( [0x00], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( [0x01], + False, {CONF_VERIFY: {}}, STATE_ON, ), ( [0xFE], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( - None, + [0x00], + True, {CONF_VERIFY: {}}, STATE_UNAVAILABLE, ), ( - None, + [0x00], + True, None, STATE_OFF, ), ], ) -async def test_all_switch(hass, mock_modbus, mock_do_cycle, expected): +async def test_all_switch(hass, mock_do_cycle, expected): """Run test for given config.""" assert hass.states.get(ENTITY_ID).state == expected From 09b872d51f5f8c7ae427dd774ca58402b7de8591 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 24 Aug 2021 00:20:28 +0200 Subject: [PATCH 684/903] Add `sensor` platform for Tractive integration (#54143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add sensor platform * Add extra_state_attributes * Add more constants * Add sensor.py to .coveragerc file * Use native value * Suggested change * Move SENSOR_TYPES to sensor platform * Add domain as prefix to the signal * Use TractiveEntity class * Add model.py to .coveragerc file * Clean up files * Add entity_class attribute to TractiveSensorEntityDescription class * TractiveEntity inherits from Entity * Suggested change * Define _attr_icon as class attribute Co-authored-by: Daniel Hjelseth Høyer --- .coveragerc | 2 + CODEOWNERS | 2 +- homeassistant/components/tractive/__init__.py | 36 +++- homeassistant/components/tractive/const.py | 4 + .../components/tractive/device_tracker.py | 16 +- homeassistant/components/tractive/entity.py | 22 +++ .../components/tractive/manifest.json | 3 +- homeassistant/components/tractive/sensor.py | 173 ++++++++++++++++++ 8 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/tractive/entity.py create mode 100644 homeassistant/components/tractive/sensor.py diff --git a/.coveragerc b/.coveragerc index 85b36a2d7d4..810b4d92ce1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1096,6 +1096,8 @@ omit = homeassistant/components/trackr/device_tracker.py homeassistant/components/tractive/__init__.py homeassistant/components/tractive/device_tracker.py + homeassistant/components/tractive/entity.py + homeassistant/components/tractive/sensor.py homeassistant/components/tradfri/* homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1a81b981dc5..d9652519ed8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -530,7 +530,7 @@ homeassistant/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti @thegardenmonkey homeassistant/components/traccar/* @ludeeus homeassistant/components/trace/* @home-assistant/core -homeassistant/components/tractive/* @Danielhiversen @zhulik +homeassistant/components/tractive/* @Danielhiversen @zhulik @bieniu homeassistant/components/tradfri/* @janiversen homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 78ee4c7ed97..60014852895 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -7,21 +7,29 @@ import logging import aiotractive from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + CONF_EMAIL, + CONF_PASSWORD, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( + ATTR_DAILY_GOAL, + ATTR_MINUTES_ACTIVE, DOMAIN, RECONNECT_INTERVAL, SERVER_UNAVAILABLE, + TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, ) -PLATFORMS = ["device_tracker"] +PLATFORMS = ["device_tracker", "sensor"] _LOGGER = logging.getLogger(__name__) @@ -112,14 +120,15 @@ class TractiveClient: if server_was_unavailable: _LOGGER.debug("Tractive is back online") server_was_unavailable = False - if event["message"] != "tracker_status": - continue - if "hardware" in event: - self._send_hardware_update(event) + if event["message"] == "activity_update": + self._send_activity_update(event) + else: + if "hardware" in event: + self._send_hardware_update(event) - if "position" in event: - self._send_position_update(event) + if "position" in event: + self._send_position_update(event) except aiotractive.exceptions.TractiveError: _LOGGER.debug( "Tractive is not available. Internet connection is down? Sleeping %i seconds and retrying", @@ -133,11 +142,20 @@ class TractiveClient: continue def _send_hardware_update(self, event): - payload = {"battery_level": event["hardware"]["battery_level"]} + payload = {ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"]} self._dispatch_tracker_event( TRACKER_HARDWARE_STATUS_UPDATED, event["tracker_id"], payload ) + def _send_activity_update(self, event): + payload = { + ATTR_MINUTES_ACTIVE: event["progress"]["achieved_minutes"], + ATTR_DAILY_GOAL: event["progress"]["goal_minutes"], + } + self._dispatch_tracker_event( + TRACKER_ACTIVITY_STATUS_UPDATED, event["pet_id"], payload + ) + def _send_position_update(self, event): payload = { "latitude": event["position"]["latlong"][0], diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index 7587fedfc4c..cb525d538e4 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -6,7 +6,11 @@ DOMAIN = "tractive" RECONNECT_INTERVAL = timedelta(seconds=10) +ATTR_DAILY_GOAL = "daily_goal" +ATTR_MINUTES_ACTIVE = "minutes_active" + TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated" TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated" +TRACKER_ACTIVITY_STATUS_UPDATED = f"{DOMAIN}_tracker_activity_updated" SERVER_UNAVAILABLE = f"{DOMAIN}_server_unavailable" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 82e22139f04..c1652c27b8f 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -14,6 +14,7 @@ from .const import ( TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, ) +from .entity import TractiveEntity _LOGGER = logging.getLogger(__name__) @@ -45,29 +46,22 @@ async def create_trackable_entity(client, trackable): ) -class TractiveDeviceTracker(TrackerEntity): +class TractiveDeviceTracker(TractiveEntity, TrackerEntity): """Tractive device tracker.""" + _attr_icon = "mdi:paw" + def __init__(self, user_id, trackable, tracker_details, hw_info, pos_report): """Initialize tracker entity.""" - self._user_id = user_id + super().__init__(user_id, trackable, tracker_details) self._battery_level = hw_info["battery_level"] self._latitude = pos_report["latlong"][0] self._longitude = pos_report["latlong"][1] self._accuracy = pos_report["pos_uncertainty"] - self._tracker_id = tracker_details["_id"] self._attr_name = f"{self._tracker_id} {trackable['details']['name']}" self._attr_unique_id = trackable["_id"] - self._attr_icon = "mdi:paw" - self._attr_device_info = { - "identifiers": {(DOMAIN, self._tracker_id)}, - "name": f"Tractive ({self._tracker_id})", - "manufacturer": "Tractive GmbH", - "sw_version": tracker_details["fw_version"], - "model": tracker_details["model_number"], - } @property def source_type(self): diff --git a/homeassistant/components/tractive/entity.py b/homeassistant/components/tractive/entity.py new file mode 100644 index 00000000000..4ddc7f7aa35 --- /dev/null +++ b/homeassistant/components/tractive/entity.py @@ -0,0 +1,22 @@ +"""A entity class for Tractive integration.""" + +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class TractiveEntity(Entity): + """Tractive entity class.""" + + def __init__(self, user_id, trackable, tracker_details): + """Initialize tracker entity.""" + self._attr_device_info = { + "identifiers": {(DOMAIN, tracker_details["_id"])}, + "name": f"Tractive ({tracker_details['_id']})", + "manufacturer": "Tractive GmbH", + "sw_version": tracker_details["fw_version"], + "model": tracker_details["model_number"], + } + self._user_id = user_id + self._tracker_id = tracker_details["_id"] + self._trackable = trackable diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index 73ee75a4ac5..b388703e6bd 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -8,7 +8,8 @@ ], "codeowners": [ "@Danielhiversen", - "@zhulik" + "@zhulik", + "@bieniu" ], "iot_class": "cloud_push" } diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py new file mode 100644 index 00000000000..fdc38d8b83a --- /dev/null +++ b/homeassistant/components/tractive/sensor.py @@ -0,0 +1,173 @@ +"""Support for Tractive sensors.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + DEVICE_CLASS_BATTERY, + PERCENTAGE, + TIME_MINUTES, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + ATTR_DAILY_GOAL, + ATTR_MINUTES_ACTIVE, + DOMAIN, + SERVER_UNAVAILABLE, + TRACKER_ACTIVITY_STATUS_UPDATED, + TRACKER_HARDWARE_STATUS_UPDATED, +) +from .entity import TractiveEntity + + +@dataclass +class TractiveSensorEntityDescription(SensorEntityDescription): + """Class describing Tractive sensor entities.""" + + attributes: tuple = () + entity_class: type[TractiveSensor] | None = None + + +class TractiveSensor(TractiveEntity, SensorEntity): + """Tractive sensor.""" + + def __init__(self, user_id, trackable, tracker_details, unique_id, description): + """Initialize sensor entity.""" + super().__init__(user_id, trackable, tracker_details) + + self._attr_unique_id = unique_id + self.entity_description = description + + @callback + def handle_server_unavailable(self): + """Handle server unavailable.""" + self._attr_available = False + self.async_write_ha_state() + + +class TractiveHardwareSensor(TractiveSensor): + """Tractive hardware sensor.""" + + def __init__(self, user_id, trackable, tracker_details, unique_id, description): + """Initialize sensor entity.""" + super().__init__(user_id, trackable, tracker_details, unique_id, description) + self._attr_name = f"{self._tracker_id} {description.name}" + + @callback + def handle_hardware_status_update(self, event): + """Handle hardware status update.""" + self._attr_native_value = event[self.entity_description.key] + self._attr_available = True + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", + self.handle_hardware_status_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self.handle_server_unavailable, + ) + ) + + +class TractiveActivitySensor(TractiveSensor): + """Tractive active sensor.""" + + def __init__(self, user_id, trackable, tracker_details, unique_id, description): + """Initialize sensor entity.""" + super().__init__(user_id, trackable, tracker_details, unique_id, description) + self._attr_name = f"{trackable['details']['name']} {description.name}" + + @callback + def handle_activity_status_update(self, event): + """Handle activity status update.""" + self._attr_native_value = event[self.entity_description.key] + self._attr_extra_state_attributes = { + attr: event[attr] if attr in event else None + for attr in self.entity_description.attributes + } + self._attr_available = True + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_ACTIVITY_STATUS_UPDATED}-{self._trackable['_id']}", + self.handle_activity_status_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self.handle_server_unavailable, + ) + ) + + +SENSOR_TYPES = ( + TractiveSensorEntityDescription( + key=ATTR_BATTERY_LEVEL, + name="Battery Level", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + entity_class=TractiveHardwareSensor, + ), + TractiveSensorEntityDescription( + key=ATTR_MINUTES_ACTIVE, + name="Minutes Active", + icon="mdi:clock-time-eight-outline", + native_unit_of_measurement=TIME_MINUTES, + attributes=(ATTR_DAILY_GOAL,), + entity_class=TractiveActivitySensor, + ), +) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Tractive device trackers.""" + client = hass.data[DOMAIN][entry.entry_id] + + trackables = await client.trackable_objects() + + entities = [] + + async def _prepare_sensor_entity(item): + """Prepare sensor entities.""" + trackable = await item.details() + tracker = client.tracker(trackable["device_id"]) + tracker_details = await tracker.details() + for description in SENSOR_TYPES: + unique_id = f"{trackable['_id']}_{description.key}" + entities.append( + description.entity_class( + client.user_id, + trackable, + tracker_details, + unique_id, + description, + ) + ) + + await asyncio.gather(*(_prepare_sensor_entity(item) for item in trackables)) + + async_add_entities(entities) From 2aed7b94c5758114a0e146b4739e226a382ee260 Mon Sep 17 00:00:00 2001 From: RDFurman Date: Mon, 23 Aug 2021 16:54:55 -0600 Subject: [PATCH 685/903] Add multi device support back to honeywell (#54003) * Add multi device support back to honeywell * Fix device reference in honeywell climate * Use deviceid for unique_id * Add test for multiple thermostats * Reduce recursive jobs * Remove old filter Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/honeywell/__init__.py | 26 +++++++------- homeassistant/components/honeywell/climate.py | 36 +++++++++---------- tests/components/honeywell/conftest.py | 18 +++++++++- tests/components/honeywell/test_init.py | 21 ++++++++++- 4 files changed, 67 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 48f2802e89f..29f0dbb8392 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -41,7 +41,7 @@ async def async_setup_entry(hass, config): _LOGGER.debug("No devices found") return False - data = HoneywellService(hass, client, username, password, devices[0]) + data = HoneywellData(hass, client, username, password, devices) await data.update() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config.entry_id] = data @@ -65,16 +65,16 @@ def get_somecomfort_client(username, password): ) from ex -class HoneywellService: +class HoneywellData: """Get the latest data and update.""" - def __init__(self, hass, client, username, password, device): + def __init__(self, hass, client, username, password, devices): """Initialize the data object.""" self._hass = hass self._client = client self._username = username self._password = password - self.device = device + self.devices = devices async def _retry(self) -> bool: """Recreate a new somecomfort client. @@ -93,23 +93,27 @@ class HoneywellService: device for location in self._client.locations_by_id.values() for device in location.devices_by_id.values() - if device.name == self.device.name ] - if len(devices) != 1: - _LOGGER.error("Failed to find device %s", self.device.name) + if len(devices) == 0: + _LOGGER.error("Failed to find any devices") return False - self.device = devices[0] + self.devices = devices return True + def _refresh_devices(self): + """Refresh each enabled device.""" + for device in self.devices: + device.refresh() + @Throttle(MIN_TIME_BETWEEN_UPDATES) async def update(self) -> None: """Update the state.""" retries = 3 while retries > 0: try: - await self._hass.async_add_executor_job(self.device.refresh) + await self._hass.async_add_executor_job(self._refresh_devices) break except ( somecomfort.client.APIRateLimited, @@ -126,7 +130,3 @@ class HoneywellService: raise exp _LOGGER.error("SomeComfort update failed, Retrying - Error: %s", exp) - - _LOGGER.debug( - "latestData = %s ", self.device._data # pylint: disable=protected-access - ) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 36fe16aeaa2..230aa8ec424 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -41,7 +41,6 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from .const import ( _LOGGER, @@ -116,7 +115,12 @@ async def async_setup_entry(hass, config, async_add_entities, discovery_info=Non data = hass.data[DOMAIN][config.entry_id] - async_add_entities([HoneywellUSThermostat(data, cool_away_temp, heat_away_temp)]) + async_add_entities( + [ + HoneywellUSThermostat(data, device, cool_away_temp, heat_away_temp) + for device in data.devices + ] + ) async def async_setup_platform(hass, config, add_entities, discovery_info=None): @@ -142,25 +146,24 @@ async def async_setup_platform(hass, config, add_entities, discovery_info=None): class HoneywellUSThermostat(ClimateEntity): """Representation of a Honeywell US Thermostat.""" - def __init__(self, data, cool_away_temp, heat_away_temp): + def __init__(self, data, device, cool_away_temp, heat_away_temp): """Initialize the thermostat.""" self._data = data + self._device = device self._cool_away_temp = cool_away_temp self._heat_away_temp = heat_away_temp self._away = False - self._attr_unique_id = dr.format_mac(data.device.mac_address) - self._attr_name = data.device.name + self._attr_unique_id = device.deviceid + self._attr_name = device.name self._attr_temperature_unit = ( - TEMP_CELSIUS if data.device.temperature_unit == "C" else TEMP_FAHRENHEIT + TEMP_CELSIUS if device.temperature_unit == "C" else TEMP_FAHRENHEIT ) self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY] - self._attr_is_aux_heat = data.device.system_mode == "emheat" + self._attr_is_aux_heat = device.system_mode == "emheat" # not all honeywell HVACs support all modes - mappings = [ - v for k, v in HVAC_MODE_TO_HW_MODE.items() if data.device.raw_ui_data[k] - ] + mappings = [v for k, v in HVAC_MODE_TO_HW_MODE.items() if device.raw_ui_data[k]] self._hvac_mode_map = {k: v for d in mappings for k, v in d.items()} self._attr_hvac_modes = list(self._hvac_mode_map) @@ -170,28 +173,23 @@ class HoneywellUSThermostat(ClimateEntity): | SUPPORT_TARGET_TEMPERATURE_RANGE ) - if data.device._data["canControlHumidification"]: + if device._data["canControlHumidification"]: self._attr_supported_features |= SUPPORT_TARGET_HUMIDITY - if data.device.raw_ui_data["SwitchEmergencyHeatAllowed"]: + if device.raw_ui_data["SwitchEmergencyHeatAllowed"]: self._attr_supported_features |= SUPPORT_AUX_HEAT - if not data.device._data["hasFan"]: + if not device._data["hasFan"]: return # not all honeywell fans support all modes - mappings = [v for k, v in FAN_MODE_TO_HW.items() if data.device.raw_fan_data[k]] + mappings = [v for k, v in FAN_MODE_TO_HW.items() if device.raw_fan_data[k]] self._fan_mode_map = {k: v for d in mappings for k, v in d.items()} self._attr_fan_modes = list(self._fan_mode_map) self._attr_supported_features |= SUPPORT_FAN_MODE - @property - def _device(self): - """Shortcut to access the device.""" - return self._data.device - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index 05e3631e08d..ff5ec57e27c 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -31,7 +31,7 @@ def config_entry(config_data): def device(): """Mock a somecomfort.Device.""" mock_device = create_autospec(somecomfort.Device, instance=True) - mock_device.deviceid.return_value = "device1" + mock_device.deviceid = 1234567 mock_device._data = { "canControlHumidification": False, "hasFan": False, @@ -43,6 +43,22 @@ def device(): return mock_device +@pytest.fixture +def another_device(): + """Mock a somecomfort.Device.""" + mock_device = create_autospec(somecomfort.Device, instance=True) + mock_device.deviceid = 7654321 + mock_device._data = { + "canControlHumidification": False, + "hasFan": False, + } + mock_device.system_mode = "off" + mock_device.name = "device2" + mock_device.current_temperature = 20 + mock_device.mac_address = "macaddress1" + return mock_device + + @pytest.fixture def location(device): """Mock a somecomfort.Location.""" diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index d0bdb5ccf2d..7cc6b64cd63 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -1,8 +1,27 @@ """Test honeywell setup process.""" +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant -async def test_setup_entry(hass, config_entry): +from tests.common import MockConfigEntry + + +async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry): """Initialize the config entry.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.async_entity_ids_count() == 1 + + +async def test_setup_multiple_thermostats( + hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device +) -> None: + """Test that the config form is shown.""" + location.devices_by_id[another_device.deviceid] = another_device + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.async_entity_ids_count() == 2 From ff14a1125435d8db02dee6fb42c1ae6a18387827 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 24 Aug 2021 01:55:24 +0000 Subject: [PATCH 686/903] [ci skip] Translation update --- .../components/airtouch4/translations/no.json | 19 ++++++++++ .../alarm_control_panel/translations/nl.json | 36 +++++++++---------- .../alarmdecoder/translations/nl.json | 14 ++++---- .../binary_sensor/translations/he.json | 2 +- .../braviatv/translations/en_GB.json | 9 +++++ .../demo/translations/select.he.json | 7 ++++ .../components/ecobee/translations/en_GB.json | 10 ++++++ .../fjaraskupan/translations/no.json | 13 +++++++ .../home_plus_control/translations/en_GB.json | 7 ++++ .../hyperion/translations/en_GB.json | 11 ++++++ .../components/isy994/translations/he.json | 9 +++++ .../konnected/translations/en_GB.json | 12 +++++++ .../components/light/translations/he.json | 2 +- .../logi_circle/translations/en_GB.json | 7 ++++ .../components/lyric/translations/en_GB.json | 7 ++++ .../motioneye/translations/en_GB.json | 11 ++++++ .../components/neato/translations/en_GB.json | 7 ++++ .../components/nest/translations/en_GB.json | 13 +++++++ .../netatmo/translations/en_GB.json | 7 ++++ .../ondilo_ico/translations/en_GB.json | 7 ++++ .../p1_monitor/translations/no.json | 17 +++++++++ .../components/point/translations/en_GB.json | 8 +++++ .../rainforest_eagle/translations/no.json | 21 +++++++++++ .../components/risco/translations/nl.json | 2 +- .../components/roomba/translations/no.json | 2 +- .../components/roon/translations/en_GB.json | 10 ++++++ .../components/sensor/translations/de.json | 2 ++ .../components/sensor/translations/en.json | 2 ++ .../components/sensor/translations/et.json | 2 ++ .../components/sensor/translations/nl.json | 2 ++ .../components/sensor/translations/no.json | 2 ++ .../components/sensor/translations/ru.json | 2 ++ .../sensor/translations/zh-Hant.json | 2 ++ .../smappee/translations/en_GB.json | 7 ++++ .../smartthings/translations/en_GB.json | 15 ++++++++ .../components/soma/translations/en_GB.json | 7 ++++ .../components/somfy/translations/en_GB.json | 7 ++++ .../spotify/translations/en_GB.json | 7 ++++ .../tellduslive/translations/en_GB.json | 13 +++++++ .../components/toon/translations/en_GB.json | 8 +++++ .../components/tuya/translations/en_GB.json | 14 ++++++++ .../withings/translations/en_GB.json | 7 ++++ .../components/xbox/translations/en_GB.json | 7 ++++ .../components/yeelight/translations/no.json | 2 +- .../components/zha/translations/nl.json | 6 +++- .../components/zha/translations/no.json | 4 +++ .../components/zwave_js/translations/nl.json | 12 +++++-- .../components/zwave_js/translations/no.json | 12 +++++-- 48 files changed, 377 insertions(+), 35 deletions(-) create mode 100644 homeassistant/components/airtouch4/translations/no.json create mode 100644 homeassistant/components/braviatv/translations/en_GB.json create mode 100644 homeassistant/components/demo/translations/select.he.json create mode 100644 homeassistant/components/ecobee/translations/en_GB.json create mode 100644 homeassistant/components/fjaraskupan/translations/no.json create mode 100644 homeassistant/components/home_plus_control/translations/en_GB.json create mode 100644 homeassistant/components/hyperion/translations/en_GB.json create mode 100644 homeassistant/components/konnected/translations/en_GB.json create mode 100644 homeassistant/components/logi_circle/translations/en_GB.json create mode 100644 homeassistant/components/lyric/translations/en_GB.json create mode 100644 homeassistant/components/motioneye/translations/en_GB.json create mode 100644 homeassistant/components/neato/translations/en_GB.json create mode 100644 homeassistant/components/nest/translations/en_GB.json create mode 100644 homeassistant/components/netatmo/translations/en_GB.json create mode 100644 homeassistant/components/ondilo_ico/translations/en_GB.json create mode 100644 homeassistant/components/p1_monitor/translations/no.json create mode 100644 homeassistant/components/point/translations/en_GB.json create mode 100644 homeassistant/components/rainforest_eagle/translations/no.json create mode 100644 homeassistant/components/roon/translations/en_GB.json create mode 100644 homeassistant/components/smappee/translations/en_GB.json create mode 100644 homeassistant/components/smartthings/translations/en_GB.json create mode 100644 homeassistant/components/soma/translations/en_GB.json create mode 100644 homeassistant/components/somfy/translations/en_GB.json create mode 100644 homeassistant/components/spotify/translations/en_GB.json create mode 100644 homeassistant/components/tellduslive/translations/en_GB.json create mode 100644 homeassistant/components/toon/translations/en_GB.json create mode 100644 homeassistant/components/tuya/translations/en_GB.json create mode 100644 homeassistant/components/withings/translations/en_GB.json create mode 100644 homeassistant/components/xbox/translations/en_GB.json diff --git a/homeassistant/components/airtouch4/translations/no.json b/homeassistant/components/airtouch4/translations/no.json new file mode 100644 index 00000000000..66bf4e3b915 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "no_units": "Kan ikke finne noen AirTouch 4 -grupper." + }, + "step": { + "user": { + "data": { + "host": "Vert" + }, + "title": "Konfigurer AirTouch 4 -tilkoblingsdetaljer." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/nl.json b/homeassistant/components/alarm_control_panel/translations/nl.json index 65b7cf1a4b8..0d81ed505f9 100644 --- a/homeassistant/components/alarm_control_panel/translations/nl.json +++ b/homeassistant/components/alarm_control_panel/translations/nl.json @@ -1,43 +1,43 @@ { "device_automation": { "action_type": { - "arm_away": "Inschakelen {entity_name} afwezig", - "arm_home": "Inschakelen {entity_name} thuis", - "arm_night": "Inschakelen {entity_name} nacht", + "arm_away": "Schakel {entity_name} in voor vertrek", + "arm_home": "Schakel {entity_name} in voor thuis", + "arm_night": "Schakel {entity_name} in voor 's nachts", "arm_vacation": "Schakel {entity_name} in op vakantie", - "disarm": "Uitschakelen {entity_name}", - "trigger": "Trigger {entity_name}" + "disarm": "Schakel {entity_name} uit", + "trigger": "Laat {entity_name} afgaan" }, "condition_type": { - "is_armed_away": "{entity_name} afwezig ingeschakeld", - "is_armed_home": "{entity_name} thuis ingeschakeld", - "is_armed_night": "{entity_name} nachtstand ingeschakeld", + "is_armed_away": "{entity_name} ingeschakeld voor vertrek", + "is_armed_home": "{entity_name} ingeschakeld voor thuis", + "is_armed_night": "{entity_name} is ingeschakeld voor 's nachts", "is_armed_vacation": "{entity_name} is in vakantie geschakeld", "is_disarmed": "{entity_name} is uitgeschakeld", - "is_triggered": "{entity_name} wordt geactiveerd" + "is_triggered": "{entity_name} gaat af" }, "trigger_type": { - "armed_away": "{entity_name} afwezig ingeschakeld", - "armed_home": "{entity_name} thuis ingeschakeld", - "armed_night": "{entity_name} nachtstand ingeschakeld", + "armed_away": "{entity_name} ingeschakeld voor vertrek", + "armed_home": "{entity_name} ingeschakeld voor thuis", + "armed_night": "{entity_name} ingeschakeld voor 's nachts", "armed_vacation": "{entity_name} schakelde vakantie in", "disarmed": "{entity_name} uitgeschakeld", - "triggered": "{entity_name} geactiveerd" + "triggered": "{entity_name} afgegaan" } }, "state": { "_": { "armed": "Ingeschakeld", - "armed_away": "Ingeschakeld afwezig", - "armed_custom_bypass": "Ingeschakeld met overbrugging(en)", - "armed_home": "Ingeschakeld thuis", - "armed_night": "Ingeschakeld nacht", + "armed_away": "Ingeschakeld voor vertrek", + "armed_custom_bypass": "Ingeschakeld met overbrugging", + "armed_home": "Ingeschakeld voor thuis", + "armed_night": "Ingeschakeld voor 's nachts", "armed_vacation": "Vakantie ingeschakeld", "arming": "Schakelt in", "disarmed": "Uitgeschakeld", "disarming": "Schakelt uit", "pending": "In wacht", - "triggered": "Geactiveerd" + "triggered": "Gaat af" } }, "title": "Alarm bedieningspaneel" diff --git a/homeassistant/components/alarmdecoder/translations/nl.json b/homeassistant/components/alarmdecoder/translations/nl.json index 1ea9cb98b56..cbab651707a 100644 --- a/homeassistant/components/alarmdecoder/translations/nl.json +++ b/homeassistant/components/alarmdecoder/translations/nl.json @@ -32,7 +32,7 @@ "int": "Het onderstaande veld moet een geheel getal zijn.", "loop_range": "RF Lus moet een geheel getal zijn tussen 1 en 4.", "loop_rfid": "RF Lus kan niet worden gebruikt zonder RF Serieel.", - "relay_inclusive": "Het relais-adres en het relais-kanaal zijn codeafhankelijk en moeten samen worden opgenomen." + "relay_inclusive": "Het relaisadres en het relaiskanaal zijn onderling afhankelijk en moeten samen worden opgenomen." }, "step": { "arm_settings": { @@ -53,18 +53,18 @@ "zone_details": { "data": { "zone_loop": "RF Lus", - "zone_name": "Zone naam", - "zone_relayaddr": "Relais Adres", - "zone_relaychan": "Relais Kanaal", + "zone_name": "Zonenaam", + "zone_relayaddr": "Relaisadres", + "zone_relaychan": "Relaiskanaal", "zone_rfid": "RF Serieel", - "zone_type": "Zone Type" + "zone_type": "Zonetype" }, - "description": "Voer details in voor zone {zone_number}. Om zone {zone_number} te verwijderen, laat u Zone Name leeg.", + "description": "Voer details in voor zone {zone_number}. Om zone {zone_number} te verwijderen, laat u Zonenaam leeg.", "title": "Configureer AlarmDecoder" }, "zone_select": { "data": { - "zone_number": "Zone nummer" + "zone_number": "Zonenummer" }, "description": "Voer het zone nummer in dat u wilt toevoegen, bewerken of verwijderen.", "title": "Configureer AlarmDecoder" diff --git a/homeassistant/components/binary_sensor/translations/he.json b/homeassistant/components/binary_sensor/translations/he.json index 4142759dbec..b0fb2780089 100644 --- a/homeassistant/components/binary_sensor/translations/he.json +++ b/homeassistant/components/binary_sensor/translations/he.json @@ -52,7 +52,7 @@ }, "light": { "off": "\u05d0\u05d9\u05df \u05d0\u05d5\u05e8", - "on": "\u05d6\u05d5\u05d4\u05d4 \u05d0\u05d5\u05e8" + "on": "\u05d6\u05d5\u05d4\u05ea\u05d4 \u05ea\u05d0\u05d5\u05e8\u05d4" }, "lock": { "off": "\u05e0\u05e2\u05d5\u05dc", diff --git a/homeassistant/components/braviatv/translations/en_GB.json b/homeassistant/components/braviatv/translations/en_GB.json new file mode 100644 index 00000000000..af063f30a87 --- /dev/null +++ b/homeassistant/components/braviatv/translations/en_GB.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "authorize": { + "title": "Authorise Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.he.json b/homeassistant/components/demo/translations/select.he.json new file mode 100644 index 00000000000..0264a0021e2 --- /dev/null +++ b/homeassistant/components/demo/translations/select.he.json @@ -0,0 +1,7 @@ +{ + "state": { + "demo__speed": { + "light_speed": "\u05de\u05d4\u05d9\u05e8\u05d5\u05ea \u05d4\u05d0\u05d5\u05e8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/en_GB.json b/homeassistant/components/ecobee/translations/en_GB.json new file mode 100644 index 00000000000..21fc733743c --- /dev/null +++ b/homeassistant/components/ecobee/translations/en_GB.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "authorize": { + "description": "Please authorise this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, press Submit.", + "title": "Authorise app on ecobee.com" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/no.json b/homeassistant/components/fjaraskupan/translations/no.json new file mode 100644 index 00000000000..b05779cbe06 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/no.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "step": { + "confirm": { + "description": "Vil du sette opp Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/home_plus_control/translations/en_GB.json b/homeassistant/components/home_plus_control/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/home_plus_control/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hyperion/translations/en_GB.json b/homeassistant/components/hyperion/translations/en_GB.json new file mode 100644 index 00000000000..1c7cbbfb2f9 --- /dev/null +++ b/homeassistant/components/hyperion/translations/en_GB.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "priority": "Hyperion priority to use for colours and effects" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/isy994/translations/he.json b/homeassistant/components/isy994/translations/he.json index 2485285743f..b1874d2675d 100644 --- a/homeassistant/components/isy994/translations/he.json +++ b/homeassistant/components/isy994/translations/he.json @@ -21,6 +21,15 @@ } } }, + "options": { + "step": { + "init": { + "data": { + "restore_light_state": "\u05e9\u05d7\u05d6\u05d5\u05e8 \u05d1\u05d4\u05d9\u05e8\u05d5\u05ea \u05d4\u05ea\u05d0\u05d5\u05e8\u05d4" + } + } + } + }, "system_health": { "info": { "host_reachable": "\u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05d2\u05d9\u05e2 \u05dc\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/konnected/translations/en_GB.json b/homeassistant/components/konnected/translations/en_GB.json new file mode 100644 index 00000000000..f1597ad3a04 --- /dev/null +++ b/homeassistant/components/konnected/translations/en_GB.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "not_konn_panel": "Not a recognised Konnected.io device" + } + }, + "options": { + "abort": { + "not_konn_panel": "Not a recognised Konnected.io device" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/translations/he.json b/homeassistant/components/light/translations/he.json index a61237ba51e..934212effb2 100644 --- a/homeassistant/components/light/translations/he.json +++ b/homeassistant/components/light/translations/he.json @@ -23,5 +23,5 @@ "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, - "title": "\u05d0\u05d5\u05b9\u05e8" + "title": "\u05ea\u05d0\u05d5\u05e8\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/logi_circle/translations/en_GB.json b/homeassistant/components/logi_circle/translations/en_GB.json new file mode 100644 index 00000000000..e93e0075042 --- /dev/null +++ b/homeassistant/components/logi_circle/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lyric/translations/en_GB.json b/homeassistant/components/lyric/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/lyric/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/en_GB.json b/homeassistant/components/motioneye/translations/en_GB.json new file mode 100644 index 00000000000..d197c3f9026 --- /dev/null +++ b/homeassistant/components/motioneye/translations/en_GB.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "webhook_set_overwrite": "Overwrite unrecognised webhooks" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/translations/en_GB.json b/homeassistant/components/neato/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/neato/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/translations/en_GB.json b/homeassistant/components/nest/translations/en_GB.json new file mode 100644 index 00000000000..f87b814d8cc --- /dev/null +++ b/homeassistant/components/nest/translations/en_GB.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL.", + "unknown_authorize_url_generation": "Unknown error generating an authorise URL." + }, + "step": { + "link": { + "description": "To link your Nest account, [authorise your account]({url}).\n\nAfter authorisation, copy-paste the provided PIN code below." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/en_GB.json b/homeassistant/components/netatmo/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/netatmo/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ondilo_ico/translations/en_GB.json b/homeassistant/components/ondilo_ico/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/ondilo_ico/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/no.json b/homeassistant/components/p1_monitor/translations/no.json new file mode 100644 index 00000000000..a6b967e4a46 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "name": "Navn" + }, + "description": "Sett opp P1 Monitor for \u00e5 integreres med Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/translations/en_GB.json b/homeassistant/components/point/translations/en_GB.json new file mode 100644 index 00000000000..f348959d089 --- /dev/null +++ b/homeassistant/components/point/translations/en_GB.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL.", + "unknown_authorize_url_generation": "Unknown error generating an authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/no.json b/homeassistant/components/rainforest_eagle/translations/no.json new file mode 100644 index 00000000000..35e1a25dcfc --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "cloud_id": "Cloud ID", + "host": "Vert", + "install_code": "Installasjonskode" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/risco/translations/nl.json b/homeassistant/components/risco/translations/nl.json index 5267b164f3f..6eeb5fff2e9 100644 --- a/homeassistant/components/risco/translations/nl.json +++ b/homeassistant/components/risco/translations/nl.json @@ -23,7 +23,7 @@ "ha_to_risco": { "data": { "armed_away": "Ingeschakeld weg", - "armed_custom_bypass": "Ingeschakeld met overbrugging(en)", + "armed_custom_bypass": "Ingeschakeld met overbrugging", "armed_home": "Ingeschakeld thuis", "armed_night": "Ingeschakeld nacht" }, diff --git a/homeassistant/components/roomba/translations/no.json b/homeassistant/components/roomba/translations/no.json index 1cacffdf425..3a5d95eb006 100644 --- a/homeassistant/components/roomba/translations/no.json +++ b/homeassistant/components/roomba/translations/no.json @@ -34,7 +34,7 @@ "blid": "", "host": "Vert" }, - "description": "Ingen Roomba eller Braava har blitt oppdaget i nettverket ditt. BLID er delen av enhetens vertsnavn etter `iRobot-` eller `Roomba-`. F\u00f8lg trinnene som er beskrevet i dokumentasjonen p\u00e5: {auth_help_url}", + "description": "Ingen Roomba eller Braava er oppdaget p\u00e5 nettverket ditt.", "title": "Koble til enheten manuelt" }, "user": { diff --git a/homeassistant/components/roon/translations/en_GB.json b/homeassistant/components/roon/translations/en_GB.json new file mode 100644 index 00000000000..246d2d9a6f1 --- /dev/null +++ b/homeassistant/components/roon/translations/en_GB.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "link": { + "description": "You must authorise Home Assistant in Roon. After you click submit, go to the Roon Core application, open Settings and enable HomeAssistant on the Extensions tab.", + "title": "Authorise HomeAssistant in Roon" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/translations/de.json b/homeassistant/components/sensor/translations/de.json index ed6b678480f..1b041b576fc 100644 --- a/homeassistant/components/sensor/translations/de.json +++ b/homeassistant/components/sensor/translations/de.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "Aktuelle Schwefeldioxid-Konzentration von {entity_name}", "is_temperature": "Aktuelle {entity_name} Temperatur", "is_value": "Aktueller {entity_name} Wert", + "is_volatile_organic_compounds": "Aktuelle Konzentration fl\u00fcchtiger organischer Verbindungen in {entity_name}", "is_voltage": "Aktuelle Spannung von {entity_name}" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "\u00c4nderung der Schwefeldioxidkonzentration bei {entity_name}", "temperature": "{entity_name} Temperatur\u00e4nderungen", "value": "{entity_name} Wert\u00e4nderungen", + "volatile_organic_compounds": "{entity_name} Konzentrations\u00e4nderungen fl\u00fcchtiger organischer Verbindungen", "voltage": "{entity_name} Spannungs\u00e4nderungen" } }, diff --git a/homeassistant/components/sensor/translations/en.json b/homeassistant/components/sensor/translations/en.json index 5fa23a334cb..b5cb2f5a27f 100644 --- a/homeassistant/components/sensor/translations/en.json +++ b/homeassistant/components/sensor/translations/en.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "Current {entity_name} sulphur dioxide concentration level", "is_temperature": "Current {entity_name} temperature", "is_value": "Current {entity_name} value", + "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", "is_voltage": "Current {entity_name} voltage" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "{entity_name} sulphur dioxide concentration changes", "temperature": "{entity_name} temperature changes", "value": "{entity_name} value changes", + "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", "voltage": "{entity_name} voltage changes" } }, diff --git a/homeassistant/components/sensor/translations/et.json b/homeassistant/components/sensor/translations/et.json index f36391e1e44..5cfa6a94852 100644 --- a/homeassistant/components/sensor/translations/et.json +++ b/homeassistant/components/sensor/translations/et.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "Praegune v\u00e4\u00e4veldioksiidi kontsentratsioonitase {entity_name}", "is_temperature": "Praegune {entity_name} temperatuur", "is_value": "Praegune {entity_name} v\u00e4\u00e4rtus", + "is_volatile_organic_compounds": "Praegune {entity_name} lenduvate orgaaniliste \u00fchendite kontsentratsioonitase", "is_voltage": "Praegune {entity_name}pinge" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "{entity_name} v\u00e4\u00e4veldioksiidi kontsentratsiooni muutused", "temperature": "{entity_name} temperatuur muutub", "value": "{entity_name} v\u00e4\u00e4rtus muutub", + "volatile_organic_compounds": "{entity_name} lenduvate orgaaniliste \u00fchendite kontsentratsiooni muutused", "voltage": "{entity_name} pingemuutub" } }, diff --git a/homeassistant/components/sensor/translations/nl.json b/homeassistant/components/sensor/translations/nl.json index c3ab0bf5bfa..c55f1547642 100644 --- a/homeassistant/components/sensor/translations/nl.json +++ b/homeassistant/components/sensor/translations/nl.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "Huidige {entity_name} zwaveldioxideconcentratie", "is_temperature": "Huidige {entity_name} temperatuur", "is_value": "Huidige {entity_name} waarde", + "is_volatile_organic_compounds": "Huidig {entity_name} vluchtige-organische-stoffenconcentratieniveau", "is_voltage": "Huidige {entity_name} spanning" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "{entity_name} zwaveldioxideconcentratieveranderingen", "temperature": "{entity_name} temperatuur gewijzigd", "value": "{entity_name} waarde gewijzigd", + "volatile_organic_compounds": "{entity_name} vluchtige-organische-stoffenconcentratieveranderingen", "voltage": "{entity_name} voltage verandert" } }, diff --git a/homeassistant/components/sensor/translations/no.json b/homeassistant/components/sensor/translations/no.json index 9af00949510..1580a716dee 100644 --- a/homeassistant/components/sensor/translations/no.json +++ b/homeassistant/components/sensor/translations/no.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "Gjeldende konsentrasjonsniv\u00e5 for svoveldioksid for {entity_name}", "is_temperature": "Gjeldende {entity_name} temperatur", "is_value": "Gjeldende {entity_name} verdi", + "is_volatile_organic_compounds": "Gjeldende {entity_name} flyktige organiske forbindelser", "is_voltage": "Gjeldende {entity_name} spenning" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "{entity_name} svoveldioksidkonsentrasjon endres", "temperature": "{entity_name} temperaturendringer", "value": "{entity_name} verdi endringer", + "volatile_organic_compounds": "{entity_name} konsentrasjon av flyktige organiske forbindelser", "voltage": "{entity_name} spenningsendringer" } }, diff --git a/homeassistant/components/sensor/translations/ru.json b/homeassistant/components/sensor/translations/ru.json index 641ec453c51..821622ae20c 100644 --- a/homeassistant/components/sensor/translations/ru.json +++ b/homeassistant/components/sensor/translations/ru.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0441\u0435\u0440\u044b", "is_temperature": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_value": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "is_volatile_organic_compounds": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043b\u0435\u0442\u0443\u0447\u0438\u0445 \u043e\u0440\u0433\u0430\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0445 \u0432\u0435\u0449\u0435\u0441\u0442\u0432", "is_voltage": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0441\u0435\u0440\u044b", "temperature": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "value": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "volatile_organic_compounds": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043b\u0435\u0442\u0443\u0447\u0438\u0445 \u043e\u0440\u0433\u0430\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0445 \u0432\u0435\u0449\u0435\u0441\u0442\u0432", "voltage": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" } }, diff --git a/homeassistant/components/sensor/translations/zh-Hant.json b/homeassistant/components/sensor/translations/zh-Hant.json index 52ab5878ba3..fb15fc70402 100644 --- a/homeassistant/components/sensor/translations/zh-Hant.json +++ b/homeassistant/components/sensor/translations/zh-Hant.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "\u76ee\u524d {entity_name} \u4e8c\u6c27\u5316\u786b\u6fc3\u5ea6\u72c0\u614b", "is_temperature": "\u76ee\u524d{entity_name}\u6eab\u5ea6", "is_value": "\u76ee\u524d{entity_name}\u503c", + "is_volatile_organic_compounds": "\u76ee\u524d {entity_name} \u63ee\u767c\u6027\u6709\u6a5f\u7269\u6fc3\u5ea6\u72c0\u614b", "is_voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "{entity_name} \u4e8c\u6c27\u5316\u786b\u6fc3\u5ea6\u8b8a\u5316", "temperature": "{entity_name}\u6eab\u5ea6\u8b8a\u66f4", "value": "{entity_name}\u503c\u8b8a\u66f4", + "volatile_organic_compounds": "{entity_name} \u63ee\u767c\u6027\u6709\u6a5f\u7269\u6fc3\u5ea6\u8b8a\u5316", "voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3\u8b8a\u66f4" } }, diff --git a/homeassistant/components/smappee/translations/en_GB.json b/homeassistant/components/smappee/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/smappee/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/translations/en_GB.json b/homeassistant/components/smartthings/translations/en_GB.json new file mode 100644 index 00000000000..129d9e2ed29 --- /dev/null +++ b/homeassistant/components/smartthings/translations/en_GB.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "token_unauthorized": "The token is invalid or no longer authorised." + }, + "step": { + "authorize": { + "title": "Authorise Home Assistant" + }, + "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 authorise installation of the Home Assistant integration into the selected location." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/soma/translations/en_GB.json b/homeassistant/components/soma/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/soma/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/en_GB.json b/homeassistant/components/somfy/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/somfy/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/translations/en_GB.json b/homeassistant/components/spotify/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/spotify/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/translations/en_GB.json b/homeassistant/components/tellduslive/translations/en_GB.json new file mode 100644 index 00000000000..4cd830a3ced --- /dev/null +++ b/homeassistant/components/tellduslive/translations/en_GB.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL.", + "unknown_authorize_url_generation": "Unknown error generating an authorise URL." + }, + "step": { + "auth": { + "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorise **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/translations/en_GB.json b/homeassistant/components/toon/translations/en_GB.json new file mode 100644 index 00000000000..f348959d089 --- /dev/null +++ b/homeassistant/components/toon/translations/en_GB.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL.", + "unknown_authorize_url_generation": "Unknown error generating an authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/en_GB.json b/homeassistant/components/tuya/translations/en_GB.json new file mode 100644 index 00000000000..90df003a190 --- /dev/null +++ b/homeassistant/components/tuya/translations/en_GB.json @@ -0,0 +1,14 @@ +{ + "options": { + "step": { + "device": { + "data": { + "max_kelvin": "Max colour temperature supported in Kelvin", + "min_kelvin": "Min colour temperature supported in Kelvin", + "support_color": "Force colour support", + "tuya_max_coltemp": "Max colour temperature reported by device" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/translations/en_GB.json b/homeassistant/components/withings/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/withings/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xbox/translations/en_GB.json b/homeassistant/components/xbox/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/xbox/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yeelight/translations/no.json b/homeassistant/components/yeelight/translations/no.json index bbfe545e919..6814ec518cb 100644 --- a/homeassistant/components/yeelight/translations/no.json +++ b/homeassistant/components/yeelight/translations/no.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Tilkobling mislyktes" }, - "flow_title": "{model} {host}", + "flow_title": "{modell} {id} ({host})", "step": { "discovery_confirm": { "description": "Vil du sette opp {model} ( {host} )?" diff --git a/homeassistant/components/zha/translations/nl.json b/homeassistant/components/zha/translations/nl.json index 9c63b8989cb..9d285499ba1 100644 --- a/homeassistant/components/zha/translations/nl.json +++ b/homeassistant/components/zha/translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "Dit apparaat is niet een zha-apparaat.", "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Wilt u {name} instellen?" + }, "pick_radio": { "data": { "radio_type": "Radio type" @@ -70,7 +74,7 @@ "face_4": "met gezicht 4 geactiveerd", "face_5": "met gezicht 5 geactiveerd", "face_6": "met gezicht 6 geactiveerd", - "face_any": "Met elk/opgegeven gezicht (en) geactiveerd", + "face_any": "Met elk/opgegeven gezicht(en) geactiveerd", "left": "Links", "open": "Open", "right": "Rechts", diff --git a/homeassistant/components/zha/translations/no.json b/homeassistant/components/zha/translations/no.json index efa6c06a067..64986b7f6da 100644 --- a/homeassistant/components/zha/translations/no.json +++ b/homeassistant/components/zha/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "Denne enheten er ikke en zha -enhet", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Vil du konfigurere {name}?" + }, "pick_radio": { "data": { "radio_type": "Radio type" diff --git a/homeassistant/components/zwave_js/translations/nl.json b/homeassistant/components/zwave_js/translations/nl.json index 3696380b43a..23d185f1ded 100644 --- a/homeassistant/components/zwave_js/translations/nl.json +++ b/homeassistant/components/zwave_js/translations/nl.json @@ -8,7 +8,9 @@ "addon_start_failed": "Kan de Z-Wave JS add-on niet starten.", "already_configured": "Apparaat is al geconfigureerd", "already_in_progress": "De configuratiestroom is al aan de gang", - "cannot_connect": "Kan geen verbinding maken" + "cannot_connect": "Kan geen verbinding maken", + "discovery_requires_supervisor": "Ontdekking vereist de Supervisor.", + "not_zwave_device": "Het ontdekte apparaat is niet een Z-Wave apparaat." }, "error": { "addon_start_failed": "Het is niet gelukt om de Z-Wave JS add-on te starten. Controleer de configuratie.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Ongeldige websocket URL", "unknown": "Onverwachte fout" }, + "flow_title": "{name}", "progress": { "install_addon": "Een ogenblik geduld terwijl de installatie van de Z-Wave JS add-on is voltooid. Dit kan enkele minuten duren.", "start_addon": "Wacht alstublieft terwijl de Z-Wave JS add-on start voltooid is. Dit kan enkele seconden duren." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "De add-on Z-Wave JS wordt gestart." + }, + "usb_confirm": { + "description": "Wilt u {naam} instellen met de Z-Wave JS add-on?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Basis CC-evenement op {subtype}", "event.value_notification.central_scene": "Centrale Sc\u00e8ne actie op {subtype}", "event.value_notification.scene_activation": "Sc\u00e8ne-activering op {subtype}", - "state.node_status": "Knooppuntstatus gewijzigd" + "state.node_status": "Knooppuntstatus gewijzigd", + "zwave_js.value_updated.config_parameter": "Waardeverandering op configuratieparameter {subtype}", + "zwave_js.value_updated.value": "Waardeverandering op een Z-Wave JS-waarde" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/no.json b/homeassistant/components/zwave_js/translations/no.json index 34ddeb753b1..b69b1cb4f7a 100644 --- a/homeassistant/components/zwave_js/translations/no.json +++ b/homeassistant/components/zwave_js/translations/no.json @@ -8,7 +8,9 @@ "addon_start_failed": "Kunne ikke starte Z-Wave JS-tillegget.", "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", - "cannot_connect": "Tilkobling mislyktes" + "cannot_connect": "Tilkobling mislyktes", + "discovery_requires_supervisor": "Oppdagelsen krever veilederen.", + "not_zwave_device": "Oppdaget enhet er ikke en Z-Wave-enhet." }, "error": { "addon_start_failed": "Kunne ikke starte Z-Wave JS-tillegg. Sjekk konfigurasjonen.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Ugyldig websocket URL", "unknown": "Uventet feil" }, + "flow_title": "{name}", "progress": { "install_addon": "Vent mens installasjonen av Z-Wave JS-tillegg er ferdig. Dette kan ta flere minutter.", "start_addon": "Vent mens Z-Wave JS-tillegget er ferdig startet. Dette kan ta noen sekunder." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Z-Wave JS-tillegget starter" + }, + "usb_confirm": { + "description": "Vil du konfigurere {name} med Z-Wave JS-tillegget?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Grunnleggende CC -hendelse p\u00e5 {subtype}", "event.value_notification.central_scene": "Sentral scenehandling p\u00e5 {subtype}", "event.value_notification.scene_activation": "Sceneaktivering p\u00e5 {subtype}", - "state.node_status": "Nodestatus endret" + "state.node_status": "Nodestatus endret", + "zwave_js.value_updated.config_parameter": "Verdiendring p\u00e5 konfigurasjonsparameteren {subtype}", + "zwave_js.value_updated.value": "Verdiendring p\u00e5 en Z-Wave JS-verdi" } }, "options": { From 4d7de0fd4cd02b7c76da69df78b54d7e9ac69359 Mon Sep 17 00:00:00 2001 From: Giuseppe Iannello Date: Tue, 24 Aug 2021 05:16:50 +0200 Subject: [PATCH 687/903] Add support for Google Assistant's LocatorTrait for vacuum cleaners (#55015) * Support for LocatorTrait for vacuum cleaners * Handle Locator request with `silence: True` * Update homeassistant/components/google_assistant/trait.py Co-authored-by: Joakim Plate * Black Co-authored-by: Paulus Schoutsen Co-authored-by: Joakim Plate Co-authored-by: Paulus Schoutsen --- .../components/google_assistant/trait.py | 43 +++++++++++++++++++ .../components/google_assistant/test_trait.py | 31 +++++++++++++ 2 files changed, 74 insertions(+) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 06d10c5372b..e89ccaf80c4 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -74,6 +74,7 @@ from .const import ( ERR_ALREADY_DISARMED, ERR_ALREADY_STOPPED, ERR_CHALLENGE_NOT_SETUP, + ERR_FUNCTION_NOT_SUPPORTED, ERR_NO_AVAILABLE_CHANNEL, ERR_NOT_SUPPORTED, ERR_UNSUPPORTED_INPUT, @@ -104,6 +105,7 @@ TRAIT_HUMIDITY_SETTING = f"{PREFIX_TRAITS}HumiditySetting" TRAIT_TRANSPORT_CONTROL = f"{PREFIX_TRAITS}TransportControl" TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState" TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel" +TRAIT_LOCATOR = f"{PREFIX_TRAITS}Locator" PREFIX_COMMANDS = "action.devices.commands." COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff" @@ -145,6 +147,7 @@ COMMAND_MEDIA_STOP = f"{PREFIX_COMMANDS}mediaStop" COMMAND_REVERSE = f"{PREFIX_COMMANDS}Reverse" COMMAND_SET_HUMIDITY = f"{PREFIX_COMMANDS}SetHumidity" COMMAND_SELECT_CHANNEL = f"{PREFIX_COMMANDS}selectChannel" +COMMAND_LOCATE = f"{PREFIX_COMMANDS}Locate" TRAITS = [] @@ -566,6 +569,46 @@ class DockTrait(_Trait): ) +@register_trait +class LocatorTrait(_Trait): + """Trait to offer locate functionality. + + https://developers.google.com/actions/smarthome/traits/locator + """ + + name = TRAIT_LOCATOR + commands = [COMMAND_LOCATE] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + return domain == vacuum.DOMAIN and features & vacuum.SUPPORT_LOCATE + + def sync_attributes(self): + """Return locator attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return locator query attributes.""" + return {} + + async def execute(self, command, data, params, challenge): + """Execute a locate command.""" + if params.get("silence", False): + raise SmartHomeError( + ERR_FUNCTION_NOT_SUPPORTED, + "Silencing a Locate request is not yet supported", + ) + + await self.hass.services.async_call( + self.state.domain, + vacuum.SERVICE_LOCATE, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + + @register_trait class StartStopTrait(_Trait): """Trait to offer StartStop functionality. diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index c57d894c36d..4ee9ee2b035 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -356,6 +356,37 @@ async def test_dock_vacuum(hass): assert calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"} +async def test_locate_vacuum(hass): + """Test locate trait support for vacuum domain.""" + assert helpers.get_google_type(vacuum.DOMAIN, None) is not None + assert trait.LocatorTrait.supported( + vacuum.DOMAIN, vacuum.SUPPORT_LOCATE, None, None + ) + + trt = trait.LocatorTrait( + hass, + State( + "vacuum.bla", + vacuum.STATE_IDLE, + {ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_LOCATE}, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == {} + + calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_LOCATE) + await trt.execute(trait.COMMAND_LOCATE, BASIC_DATA, {"silence": False}, {}) + assert len(calls) == 1 + assert calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"} + + with pytest.raises(helpers.SmartHomeError) as err: + await trt.execute(trait.COMMAND_LOCATE, BASIC_DATA, {"silence": True}, {}) + assert err.value.code == const.ERR_FUNCTION_NOT_SUPPORTED + + async def test_startstop_vacuum(hass): """Test startStop trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None From 6f75a853f82eff0f69c252d26bb4bf6e8bfe2931 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Aug 2021 22:27:53 -0500 Subject: [PATCH 688/903] Bump httpx to 0.19.0 (#55106) * Bump httpx to 0.19.0 * regen constraints --- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 669ad7f7484..1b65c5df9e0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 emoji==1.2.0 hass-nabucasa==0.46.0 home-assistant-frontend==20210818.0 -httpx==0.18.2 +httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 paho-mqtt==1.5.1 diff --git a/requirements.txt b/requirements.txt index dd445b8a7e9..70eeccdae1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2020.12.5 ciso8601==2.1.3 -httpx==0.18.2 +httpx==0.19.0 jinja2==3.0.1 PyJWT==1.7.1 cryptography==3.3.2 diff --git a/setup.py b/setup.py index db4e8a54d72..302eadbfcf6 100755 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ REQUIRES = [ "bcrypt==3.1.7", "certifi>=2020.12.5", "ciso8601==2.1.3", - "httpx==0.18.2", + "httpx==0.19.0", "jinja2==3.0.1", "PyJWT==1.7.1", # PyJWT has loose dependency. We want the latest one. From ce0a42a40795200590bbf9940d1004ea986b780b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Aug 2021 22:36:24 -0500 Subject: [PATCH 689/903] Fix updating device path from discovery in zha (#55124) --- homeassistant/components/zha/config_flow.py | 35 +++--- tests/components/zha/test_config_flow.py | 116 ++++++++++++++++++-- 2 files changed, 124 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 2d8443642e7..b94b620581e 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -102,14 +102,17 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): device = discovery_info["device"] manufacturer = discovery_info["manufacturer"] description = discovery_info["description"] - await self.async_set_unique_id( - f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" - ) - self._abort_if_unique_id_configured( - updates={ - CONF_DEVICE: {CONF_DEVICE_PATH: self._device_path}, - } - ) + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" + if current_entry := await self.async_set_unique_id(unique_id): + self._abort_if_unique_id_configured( + updates={ + CONF_DEVICE: { + **current_entry.data[CONF_DEVICE], + CONF_DEVICE_PATH: dev_path, + }, + } + ) # Check if already configured if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -127,7 +130,6 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if vid == "10C4" and pid == "8A2A" and "ZigBee" not in description: return self.async_abort(reason="not_zha_device") - dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) self._auto_detected_data = await detect_radios(dev_path) if self._auto_detected_data is None: return self.async_abort(reason="not_zha_device") @@ -166,12 +168,15 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): host = discovery_info[CONF_HOST] device_path = f"socket://{host}:6638" - await self.async_set_unique_id(node_name) - self._abort_if_unique_id_configured( - updates={ - CONF_DEVICE: {CONF_DEVICE_PATH: device_path}, - } - ) + if current_entry := await self.async_set_unique_id(node_name): + self._abort_if_unique_id_configured( + updates={ + CONF_DEVICE: { + **current_entry.data[CONF_DEVICE], + CONF_DEVICE_PATH: device_path, + }, + } + ) # Check if already configured if self._async_current_entries(): diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index ed975f77eae..81957f010dd 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest import serial.tools.list_ports import zigpy.config +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import setup from homeassistant.components.ssdp import ( @@ -13,7 +14,13 @@ from homeassistant.components.ssdp import ( ATTR_UPNP_SERIAL, ) from homeassistant.components.zha import config_flow -from homeassistant.components.zha.core.const import CONF_RADIO_TYPE, DOMAIN, RadioType +from homeassistant.components.zha.core.const import ( + CONF_BAUDRATE, + CONF_FLOWCONTROL, + CONF_RADIO_TYPE, + DOMAIN, + RadioType, +) from homeassistant.config_entries import ( SOURCE_SSDP, SOURCE_USB, @@ -21,7 +28,11 @@ from homeassistant.config_entries import ( SOURCE_ZEROCONF, ) from homeassistant.const import CONF_SOURCE -from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) from tests.common import MockConfigEntry @@ -57,15 +68,51 @@ async def test_discovery(detect_mock, hass): assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "socket://192.168.1.200:6638" assert result["data"] == { - "device": { - "baudrate": 115200, - "flow_control": None, - "path": "socket://192.168.1.200:6638", + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + CONF_DEVICE_PATH: "socket://192.168.1.200:6638", }, CONF_RADIO_TYPE: "znp", } +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_zeroconf_ip_change(detect_mock, hass): + """Test zeroconf flow -- radio detected.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="tube_zb_gw_cc2652p2_poe", + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "socket://192.168.1.5:6638", + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + } + }, + ) + entry.add_to_hass(hass) + + service_info = { + "host": "192.168.1.22", + "port": 6053, + "hostname": "tube_zb_gw_cc2652p2_poe.local.", + "properties": {"address": "tube_zb_gw_cc2652p2_poe.local"}, + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_DEVICE] == { + CONF_DEVICE_PATH: "socket://192.168.1.22:6638", + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + } + + @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_via_usb(detect_mock, hass): """Test usb flow -- radio detected.""" @@ -117,7 +164,7 @@ async def test_discovery_via_usb_no_radio(detect_mock, hass): "zha", context={"source": SOURCE_USB}, data=discovery_info ) await hass.async_block_till_done() - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "not_zha_device" @@ -136,7 +183,7 @@ async def test_discovery_via_usb_rejects_nortek_zwave(detect_mock, hass): "zha", context={"source": SOURCE_USB}, data=discovery_info ) await hass.async_block_till_done() - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "not_zha_device" @@ -144,7 +191,9 @@ async def test_discovery_via_usb_rejects_nortek_zwave(detect_mock, hass): async def test_discovery_via_usb_already_setup(detect_mock, hass): """Test usb flow -- already setup.""" await setup.async_setup_component(hass, "persistent_notification", {}) - MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) discovery_info = { "device": "/dev/ttyZIGBEE", @@ -159,10 +208,49 @@ async def test_discovery_via_usb_already_setup(detect_mock, hass): ) await hass.async_block_till_done() - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "single_instance_allowed" +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_discovery_via_usb_path_changes(hass): + """Test usb flow already setup and the path changes.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AAAA:AAAA_1234_test_zigbee radio", + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB1", + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + } + }, + ) + entry.add_to_hass(hass) + + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_DEVICE] == { + CONF_DEVICE_PATH: "/dev/ttyZIGBEE", + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + } + + @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_via_usb_deconz_already_discovered(detect_mock, hass): """Test usb flow -- deconz discovered.""" @@ -204,7 +292,9 @@ async def test_discovery_already_setup(detect_mock, hass): "properties": {"name": "tube_123456"}, } await setup.async_setup_component(hass, "persistent_notification", {}) - MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_ZEROCONF}, data=service_info @@ -310,7 +400,9 @@ async def test_pick_radio_flow(hass, radio_type): async def test_user_flow_existing_config_entry(hass): """Test if config entry already exists.""" - MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} From e06f3a5e95a33ca50d398ad886173cf7dd3fd2ac Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 24 Aug 2021 15:40:01 +1200 Subject: [PATCH 690/903] Bump aioesphomeapi to 7.0.0 (#55129) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index f702b35e4c8..96ac632d990 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==6.1.0"], + "requirements": ["aioesphomeapi==7.0.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index 592d47b198b..bc759d50a5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -167,7 +167,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==6.1.0 +aioesphomeapi==7.0.0 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86a56c1376a..9918212129b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==6.1.0 +aioesphomeapi==7.0.0 # homeassistant.components.flo aioflo==0.4.1 From e92e2065442c1fcd658175da62ded590053f57c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Aug 2021 23:01:21 -0500 Subject: [PATCH 691/903] Fix race that allowed multiple config flows with the same unique id (#55131) - If a config flow set a unique id and then did an await to return control to the event loop, another discovery with the same unique id could start and it would not see the first one because it was still uninitialized. We now check uninitialized flows when setting the unique id --- homeassistant/config_entries.py | 4 +- tests/test_config_entries.py | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 67c718a497d..50d279ec8b0 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1205,7 +1205,7 @@ class ConfigFlow(data_entry_flow.FlowHandler): return None if raise_on_progress: - for progress in self._async_in_progress(): + for progress in self._async_in_progress(include_uninitialized=True): if progress["context"].get("unique_id") == unique_id: raise data_entry_flow.AbortFlow("already_in_progress") @@ -1213,7 +1213,7 @@ class ConfigFlow(data_entry_flow.FlowHandler): # Abort discoveries done using the default discovery unique id if unique_id != DEFAULT_DISCOVERY_UNIQUE_ID: - for progress in self._async_in_progress(): + for progress in self._async_in_progress(include_uninitialized=True): if progress["context"].get("unique_id") == DEFAULT_DISCOVERY_UNIQUE_ID: self.hass.config_entries.flow.async_abort(progress["flow_id"]) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 4c002ad8228..2ae4ad036d4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2585,6 +2585,74 @@ async def test_default_discovery_abort_on_user_flow_complete(hass, manager): assert len(flows) == 0 +async def test_flow_same_device_multiple_sources(hass, manager): + """Test discovery of the same devices from multiple discovery sources.""" + mock_integration( + hass, + MockModule("comp", async_setup_entry=AsyncMock(return_value=True)), + ) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_zeroconf(self, discovery_info=None): + """Test zeroconf step.""" + return await self._async_discovery_handler(discovery_info) + + async def async_step_homekit(self, discovery_info=None): + """Test homekit step.""" + return await self._async_discovery_handler(discovery_info) + + async def _async_discovery_handler(self, discovery_info=None): + """Test any discovery handler.""" + await self.async_set_unique_id("thisid") + self._abort_if_unique_id_configured() + await asyncio.sleep(0.1) + return await self.async_step_link() + + async def async_step_link(self, user_input=None): + """Test a link step.""" + if user_input is None: + return self.async_show_form(step_id="link") + return self.async_create_entry(title="title", data={"token": "supersecret"}) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + # Create one to be in progress + flow1 = manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_ZEROCONF} + ) + flow2 = manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_ZEROCONF} + ) + flow3 = manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_HOMEKIT} + ) + result1, result2, result3 = await asyncio.gather(flow1, flow2, flow3) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["unique_id"] == "thisid" + + # Finish flow + result2 = await manager.flow.async_configure( + flows[0]["flow_id"], user_input={"fake": "data"} + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + assert len(hass.config_entries.flow.async_progress()) == 0 + + entry = hass.config_entries.async_entries("comp")[0] + assert entry.title == "title" + assert entry.source in { + config_entries.SOURCE_ZEROCONF, + config_entries.SOURCE_HOMEKIT, + } + assert entry.unique_id == "thisid" + + async def test_updating_entry_with_and_without_changes(manager): """Test that we can update an entry data.""" entry = MockConfigEntry( From cac486440f904397b54ff3361272a24f4e4b066b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 23 Aug 2021 22:35:26 -0700 Subject: [PATCH 692/903] Mark eagle entities as unavailable if connection with meter losts (#55102) --- homeassistant/components/rainforest_eagle/data.py | 13 +++++++++++++ homeassistant/components/rainforest_eagle/sensor.py | 5 +++++ tests/components/rainforest_eagle/test_sensor.py | 13 ++++++++----- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index 07835212666..f76b809f2a9 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -126,6 +126,14 @@ class EagleDataCoordinator(DataUpdateCoordinator): """Return hardware address of meter.""" return self.entry.data[CONF_HARDWARE_ADDRESS] + @property + def is_connected(self): + """Return if the hub is connected to the electric meter.""" + if self.eagle200_meter: + return self.eagle200_meter.is_connected + + return True + async def _async_update_data_200(self): """Get the latest data from the Eagle-200 device.""" if self.eagle200_meter is None: @@ -139,9 +147,14 @@ class EagleDataCoordinator(DataUpdateCoordinator): hub, self.hardware_address ) + is_connected = self.eagle200_meter.is_connected + async with async_timeout.timeout(30): data = await self.eagle200_meter.get_device_query() + if is_connected and not self.eagle200_meter.is_connected: + _LOGGER.warning("Lost connection with electricity meter") + _LOGGER.debug("API data: %s", data) return {var["Name"]: var["Value"] for var in data.values()} diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index e3250dff30d..4b24a3abdaa 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -132,6 +132,11 @@ class EagleSensor(CoordinatorEntity, SensorEntity): """Return unique ID of entity.""" return f"{self.coordinator.cloud_id}-${self.coordinator.hardware_address}-{self.entity_description.key}" + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.is_connected + @property def native_value(self) -> StateType: """Return native value of the sensor.""" diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py index cf7e4a1d011..a090c6dc318 100644 --- a/tests/components/rainforest_eagle/test_sensor.py +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -1,5 +1,5 @@ """Tests for rainforest eagle sensors.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -65,12 +65,15 @@ async def setup_rainforest_200(hass): }, ).add_to_hass(hass) with patch( - "aioeagle.ElectricMeter.get_device_query", - return_value=MOCK_200_RESPONSE_WITHOUT_PRICE, + "aioeagle.ElectricMeter.create_instance", + return_value=Mock( + get_device_query=AsyncMock(return_value=MOCK_200_RESPONSE_WITHOUT_PRICE) + ), ) as mock_update: + mock_update.return_value.is_connected = True assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - yield mock_update + yield mock_update.return_value @pytest.fixture @@ -126,7 +129,7 @@ async def test_sensors_200(hass, setup_rainforest_200): assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" - setup_rainforest_200.return_value = MOCK_200_RESPONSE_WITH_PRICE + setup_rainforest_200.get_device_query.return_value = MOCK_200_RESPONSE_WITH_PRICE config_entry = hass.config_entries.async_entries(DOMAIN)[0] await hass.config_entries.async_reload(config_entry.entry_id) From c8f584f4efaf3058780cac755442173832dbabdb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 23 Aug 2021 23:51:07 -0700 Subject: [PATCH 693/903] Validate requirements format in hassfest (#55094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- homeassistant/components/gc100/manifest.json | 2 +- requirements_all.txt | 2 +- script/hassfest/__main__.py | 16 +++-- script/hassfest/requirements.py | 44 +++++++++++++ tests/hassfest/test_requirements.py | 68 ++++++++++++++++++++ 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 tests/hassfest/test_requirements.py diff --git a/homeassistant/components/gc100/manifest.json b/homeassistant/components/gc100/manifest.json index 55ea7d94682..8caa2f91204 100644 --- a/homeassistant/components/gc100/manifest.json +++ b/homeassistant/components/gc100/manifest.json @@ -2,7 +2,7 @@ "domain": "gc100", "name": "Global Cach\u00e9 GC-100", "documentation": "https://www.home-assistant.io/integrations/gc100", - "requirements": ["python-gc100==1.0.3a"], + "requirements": ["python-gc100==1.0.3a0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index bc759d50a5b..5aafc1e7046 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1853,7 +1853,7 @@ python-forecastio==1.4.0 # python-gammu==3.1 # homeassistant.components.gc100 -python-gc100==1.0.3a +python-gc100==1.0.3a0 # homeassistant.components.gitlab_ci python-gitlab==1.6.0 diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 1bec328702e..d4935196cc7 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -24,18 +24,19 @@ from . import ( from .model import Config, Integration INTEGRATION_PLUGINS = [ - json, codeowners, config_flow, dependencies, + dhcp, + json, manifest, mqtt, + requirements, services, ssdp, translations, - zeroconf, - dhcp, usb, + zeroconf, ] HASS_PLUGINS = [ coverage, @@ -103,9 +104,6 @@ def main(): plugins = [*INTEGRATION_PLUGINS] - if config.requirements: - plugins.append(requirements) - if config.specific_integrations: integrations = {} @@ -122,7 +120,11 @@ def main(): try: start = monotonic() print(f"Validating {plugin.__name__.split('.')[-1]}...", end="", flush=True) - if plugin is requirements and not config.specific_integrations: + if ( + plugin is requirements + and config.requirements + and not config.specific_integrations + ): print() plugin.validate(integrations, config) print(f" done in {monotonic() - start:.2f}s") diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 5927824b21f..f72562f7f2f 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -9,6 +9,7 @@ import re import subprocess import sys +from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from stdlib_list import stdlib_list from tqdm import tqdm @@ -61,6 +62,12 @@ def normalize_package_name(requirement: str) -> str: def validate(integrations: dict[str, Integration], config: Config): """Handle requirements for integrations.""" + # Check if we are doing format-only validation. + if not config.requirements: + for integration in integrations.values(): + validate_requirements_format(integration) + return + ensure_cache() # check for incompatible requirements @@ -74,8 +81,45 @@ def validate(integrations: dict[str, Integration], config: Config): validate_requirements(integration) +def validate_requirements_format(integration: Integration) -> bool: + """Validate requirements format. + + Returns if valid. + """ + start_errors = len(integration.errors) + + for req in integration.requirements: + if " " in req: + integration.add_error( + "requirements", + f'Requirement "{req}" contains a space', + ) + continue + + pkg, sep, version = req.partition("==") + + if not sep and integration.core: + integration.add_error( + "requirements", + f'Requirement {req} need to be pinned "==".', + ) + continue + + if AwesomeVersion(version).strategy == AwesomeVersionStrategy.UNKNOWN: + integration.add_error( + "requirements", + f"Unable to parse package version ({version}) for {pkg}.", + ) + continue + + return len(integration.errors) == start_errors + + def validate_requirements(integration: Integration): """Validate requirements.""" + if not validate_requirements_format(integration): + return + # Some integrations have not been fixed yet so are allowed to have violations. if integration.domain in IGNORE_VIOLATIONS: return diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py new file mode 100644 index 00000000000..c65716d5d92 --- /dev/null +++ b/tests/hassfest/test_requirements.py @@ -0,0 +1,68 @@ +"""Tests for hassfest requirements.""" +from pathlib import Path + +import pytest + +from script.hassfest.model import Integration +from script.hassfest.requirements import validate_requirements_format + + +@pytest.fixture +def integration(): + """Fixture for hassfest integration model.""" + integration = Integration( + path=Path("homeassistant/components/test"), + manifest={ + "domain": "test", + "documentation": "https://example.com", + "name": "test", + "codeowners": ["@awesome"], + "requirements": [], + }, + ) + yield integration + + +def test_validate_requirements_format_with_space(integration: Integration): + """Test validate requirement with space around separator.""" + integration.manifest["requirements"] = ["test_package == 1"] + assert not validate_requirements_format(integration) + assert len(integration.errors) == 1 + assert 'Requirement "test_package == 1" contains a space' in [ + x.error for x in integration.errors + ] + + +def test_validate_requirements_format_wrongly_pinned(integration: Integration): + """Test requirement with loose pin.""" + integration.manifest["requirements"] = ["test_package>=1"] + assert not validate_requirements_format(integration) + assert len(integration.errors) == 1 + assert 'Requirement test_package>=1 need to be pinned "==".' in [ + x.error for x in integration.errors + ] + + +def test_validate_requirements_format_ignore_pin_for_custom(integration: Integration): + """Test requirement ignore pinning for custom.""" + integration.manifest["requirements"] = ["test_package>=1"] + integration.path = Path("") + assert validate_requirements_format(integration) + assert len(integration.errors) == 0 + + +def test_validate_requirements_format_invalid_version(integration: Integration): + """Test requirement with invalid version.""" + integration.manifest["requirements"] = ["test_package==invalid"] + assert not validate_requirements_format(integration) + assert len(integration.errors) == 1 + assert "Unable to parse package version (invalid) for test_package." in [ + x.error for x in integration.errors + ] + + +def test_validate_requirements_format_successful(integration: Integration): + """Test requirement with successful result.""" + integration.manifest["requirements"] = ["test_package==1.2.3"] + assert validate_requirements_format(integration) + assert len(integration.errors) == 0 From 9fc96818dff7f48fca07993cffc22f8d602e1c11 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 23 Aug 2021 23:56:47 -0700 Subject: [PATCH 694/903] Guard for unparsable date time (#55108) --- homeassistant/components/hue/hue_event.py | 15 +++++++++++---- tests/components/hue/test_device_trigger.py | 7 ++++--- tests/components/hue/test_sensor_base.py | 3 +++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hue/hue_event.py b/homeassistant/components/hue/hue_event.py index 6bd68b106bb..069bb1e58b5 100644 --- a/homeassistant/components/hue/hue_event.py +++ b/homeassistant/components/hue/hue_event.py @@ -60,10 +60,17 @@ class HueEvent(GenericHueDevice): self.sensor.last_event is not None and self.sensor.last_event["type"] != EVENT_BUTTON ) - or - # Filter out old states. Can happen when events fire while refreshing - dt_util.parse_datetime(self.sensor.state["lastupdated"]) - <= dt_util.parse_datetime(self._last_state["lastupdated"]) + ): + return + + # Filter out old states. Can happen when events fire while refreshing + now_updated = dt_util.parse_datetime(self.sensor.state["lastupdated"]) + last_updated = dt_util.parse_datetime(self._last_state["lastupdated"]) + + if ( + now_updated is not None + and last_updated is not None + and now_updated <= last_updated ): return diff --git a/tests/components/hue/test_device_trigger.py b/tests/components/hue/test_device_trigger.py index 28bb989d475..d0c20018c30 100644 --- a/tests/components/hue/test_device_trigger.py +++ b/tests/components/hue/test_device_trigger.py @@ -55,7 +55,7 @@ async def test_get_triggers(hass, mock_bridge, device_reg): "type": t_type, "subtype": t_subtype, } - for t_type, t_subtype in device_trigger.HUE_TAP_REMOTE.keys() + for t_type, t_subtype in device_trigger.HUE_TAP_REMOTE ] assert_lists_same(triggers, expected_triggers) @@ -82,7 +82,7 @@ async def test_get_triggers(hass, mock_bridge, device_reg): "type": t_type, "subtype": t_subtype, } - for t_type, t_subtype in device_trigger.HUE_DIMMER_REMOTE.keys() + for t_type, t_subtype in device_trigger.HUE_DIMMER_REMOTE ), ] assert_lists_same(triggers, expected_triggers) @@ -140,6 +140,7 @@ async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): # Fake that the remote is being pressed. new_sensor_response = dict(REMOTES_RESPONSE) + new_sensor_response["7"] = dict(new_sensor_response["7"]) new_sensor_response["7"]["state"] = { "buttonevent": 18, "lastupdated": "2019-12-28T22:58:02", @@ -156,7 +157,7 @@ async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): assert calls[0].data["some"] == "B4 - 18" # Fake another button press. - new_sensor_response = dict(REMOTES_RESPONSE) + new_sensor_response["7"] = dict(new_sensor_response["7"]) new_sensor_response["7"]["state"] = { "buttonevent": 34, "lastupdated": "2019-12-28T22:58:05", diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py index b8e9c83e47d..5b3b6619efe 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_base.py @@ -450,6 +450,7 @@ async def test_hue_events(hass, mock_bridge): mock_bridge.api.sensors["8"].last_event = {"type": "button"} new_sensor_response = dict(SENSOR_RESPONSE) + new_sensor_response["7"] = dict(new_sensor_response["7"]) new_sensor_response["7"]["state"] = { "buttonevent": 18, "lastupdated": "2019-12-28T22:58:03", @@ -473,6 +474,7 @@ async def test_hue_events(hass, mock_bridge): } new_sensor_response = dict(new_sensor_response) + new_sensor_response["8"] = dict(new_sensor_response["8"]) new_sensor_response["8"]["state"] = { "buttonevent": 3002, "lastupdated": "2019-12-28T22:58:03", @@ -497,6 +499,7 @@ async def test_hue_events(hass, mock_bridge): # Fire old event, it should be ignored new_sensor_response = dict(new_sensor_response) + new_sensor_response["8"] = dict(new_sensor_response["8"]) new_sensor_response["8"]["state"] = { "buttonevent": 18, "lastupdated": "2019-12-28T22:58:02", From 5a58aa99b6bea8d9640dcd113f71584ac2922a1c Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 24 Aug 2021 09:15:57 +0200 Subject: [PATCH 695/903] Bump Forecast Solar to v2.1.0 (#55121) --- homeassistant/components/forecast_solar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 2b57eed84ac..dc4b88d160c 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -3,7 +3,7 @@ "name": "Forecast.Solar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/forecast_solar", - "requirements": ["forecast_solar==2.0.0"], + "requirements": ["forecast_solar==2.1.0"], "codeowners": ["@klaasnicolaas", "@frenck"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 5aafc1e7046..d384d89d7ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -641,7 +641,7 @@ fnvhash==0.1.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==2.0.0 +forecast_solar==2.1.0 # homeassistant.components.fortios fortiosapi==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9918212129b..66217e2bcfa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -358,7 +358,7 @@ fnvhash==0.1.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==2.0.0 +forecast_solar==2.1.0 # homeassistant.components.freebox freebox-api==0.0.10 From 58f170ba453995a7b78af5d07a9861c393100c16 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Aug 2021 00:43:29 -0700 Subject: [PATCH 696/903] Pin google-api-core to avoid new version of grpcio (#55115) --- homeassistant/package_constraints.txt | 9 +++++---- script/gen_requirements_all.py | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1b65c5df9e0..218ba5bb890 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,12 +50,13 @@ httplib2>=0.19.0 # gRPC 1.32+ currently causes issues on ARMv7, see: # https://github.com/home-assistant/core/issues/40148 -grpcio==1.31.0 - -# Newer versions of cloud pubsub pin a higher version of grpcio. This can -# be reverted when the grpcio pin is reverted, see: +# Newer versions of some other libraries pin a higher version of grpcio, +# so those also need to be kept at an old version until the grpcio pin +# is reverted, see: # https://github.com/home-assistant/core/issues/53427 +grpcio==1.31.0 google-cloud-pubsub==2.1.0 +google-api-core<=1.31.2 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 934ea9be90c..c2c98191a85 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -71,12 +71,13 @@ httplib2>=0.19.0 # gRPC 1.32+ currently causes issues on ARMv7, see: # https://github.com/home-assistant/core/issues/40148 -grpcio==1.31.0 - -# Newer versions of cloud pubsub pin a higher version of grpcio. This can -# be reverted when the grpcio pin is reverted, see: +# Newer versions of some other libraries pin a higher version of grpcio, +# so those also need to be kept at an old version until the grpcio pin +# is reverted, see: # https://github.com/home-assistant/core/issues/53427 +grpcio==1.31.0 google-cloud-pubsub==2.1.0 +google-api-core<=1.31.2 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 From a08f42e5162cb388d4b4f7e7549b585c19c37e96 Mon Sep 17 00:00:00 2001 From: Andre Richter Date: Tue, 24 Aug 2021 10:14:34 +0200 Subject: [PATCH 697/903] Use EntityDescription - Vallox (#54891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- homeassistant/components/vallox/sensor.py | 340 ++++++++++------------ 1 file changed, 152 insertions(+), 188 deletions(-) diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index dd669e156cf..4e4dc6cdddf 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -1,9 +1,15 @@ """Support for Vallox ventilation unit sensors.""" +from __future__ import annotations +from dataclasses import dataclass from datetime import datetime, timedelta import logging -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2, @@ -16,175 +22,30 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN, METRIC_KEY_MODE, SIGNAL_VALLOX_STATE_UPDATE +from . import DOMAIN, METRIC_KEY_MODE, SIGNAL_VALLOX_STATE_UPDATE, ValloxStateProxy _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the sensors.""" - if discovery_info is None: - return - - name = hass.data[DOMAIN]["name"] - state_proxy = hass.data[DOMAIN]["state_proxy"] - - sensors = [ - ValloxProfileSensor( - name=f"{name} Current Profile", - state_proxy=state_proxy, - device_class=None, - unit_of_measurement=None, - icon="mdi:gauge", - ), - ValloxFanSpeedSensor( - name=f"{name} Fan Speed", - state_proxy=state_proxy, - metric_key="A_CYC_FAN_SPEED", - device_class=None, - unit_of_measurement=PERCENTAGE, - icon="mdi:fan", - state_class=STATE_CLASS_MEASUREMENT, - ), - ValloxSensor( - name=f"{name} Extract Air", - state_proxy=state_proxy, - metric_key="A_CYC_TEMP_EXTRACT_AIR", - device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, - icon=None, - state_class=STATE_CLASS_MEASUREMENT, - ), - ValloxSensor( - name=f"{name} Exhaust Air", - state_proxy=state_proxy, - metric_key="A_CYC_TEMP_EXHAUST_AIR", - device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, - icon=None, - state_class=STATE_CLASS_MEASUREMENT, - ), - ValloxSensor( - name=f"{name} Outdoor Air", - state_proxy=state_proxy, - metric_key="A_CYC_TEMP_OUTDOOR_AIR", - device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, - icon=None, - state_class=STATE_CLASS_MEASUREMENT, - ), - ValloxSensor( - name=f"{name} Supply Air", - state_proxy=state_proxy, - metric_key="A_CYC_TEMP_SUPPLY_AIR", - device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, - icon=None, - state_class=STATE_CLASS_MEASUREMENT, - ), - ValloxSensor( - name=f"{name} Humidity", - state_proxy=state_proxy, - metric_key="A_CYC_RH_VALUE", - device_class=DEVICE_CLASS_HUMIDITY, - unit_of_measurement=PERCENTAGE, - icon=None, - state_class=STATE_CLASS_MEASUREMENT, - ), - ValloxFilterRemainingSensor( - name=f"{name} Remaining Time For Filter", - state_proxy=state_proxy, - metric_key="A_CYC_REMAINING_TIME_FOR_FILTER", - device_class=DEVICE_CLASS_TIMESTAMP, - unit_of_measurement=None, - icon="mdi:filter", - ), - ValloxSensor( - name=f"{name} Efficiency", - state_proxy=state_proxy, - metric_key="A_CYC_EXTRACT_EFFICIENCY", - device_class=None, - unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", - state_class=STATE_CLASS_MEASUREMENT, - ), - ValloxSensor( - name=f"{name} CO2", - state_proxy=state_proxy, - metric_key="A_CYC_CO2_VALUE", - device_class=DEVICE_CLASS_CO2, - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - icon=None, - state_class=STATE_CLASS_MEASUREMENT, - ), - ] - - async_add_entities(sensors, update_before_add=False) - - class ValloxSensor(SensorEntity): """Representation of a Vallox sensor.""" + _attr_should_poll = False + entity_description: ValloxSensorEntityDescription + def __init__( self, - name, - state_proxy, - metric_key, - device_class, - unit_of_measurement, - icon, - state_class=None, + name: str, + state_proxy: ValloxStateProxy, + description: ValloxSensorEntityDescription, ) -> None: """Initialize the Vallox sensor.""" - self._name = name self._state_proxy = state_proxy - self._metric_key = metric_key - self._device_class = device_class - self._state_class = state_class - self._unit_of_measurement = unit_of_measurement - self._icon = icon - self._available = None - self._state = None - @property - def should_poll(self): - """Do not poll the device.""" - return False + self.entity_description = description - @property - def name(self): - """Return the name.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def state_class(self): - """Return the state class.""" - return self._state_class - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def available(self): - """Return true when state is known.""" - return self._available - - @property - def native_value(self): - """Return the state.""" - return self._state + self._attr_name = f"{name} {description.name}" + self._attr_available = False async def async_added_to_hass(self): """Call to update.""" @@ -202,11 +63,27 @@ class ValloxSensor(SensorEntity): async def async_update(self): """Fetch state from the ventilation unit.""" try: - self._state = self._state_proxy.fetch_metric(self._metric_key) - self._available = True + self._attr_native_value = self._state_proxy.fetch_metric( + self.entity_description.metric_key + ) + self._attr_available = True except (OSError, KeyError) as err: - self._available = False + self._attr_available = False + _LOGGER.error("Error updating sensor: %s", err) + + +class ValloxProfileSensor(ValloxSensor): + """Child class for profile reporting.""" + + async def async_update(self): + """Fetch state from the ventilation unit.""" + try: + self._attr_native_value = self._state_proxy.get_profile() + self._attr_available = True + + except OSError as err: + self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) @@ -228,33 +105,11 @@ class ValloxFanSpeedSensor(ValloxSensor): await super().async_update() else: # Report zero percent otherwise. - self._state = 0 - self._available = True + self._attr_native_value = 0 + self._attr_available = True except (OSError, KeyError) as err: - self._available = False - _LOGGER.error("Error updating sensor: %s", err) - - -class ValloxProfileSensor(ValloxSensor): - """Child class for profile reporting.""" - - def __init__( - self, name, state_proxy, device_class, unit_of_measurement, icon - ) -> None: - """Initialize the Vallox sensor.""" - super().__init__( - name, state_proxy, None, device_class, unit_of_measurement, icon - ) - - async def async_update(self): - """Fetch state from the ventilation unit.""" - try: - self._state = self._state_proxy.get_profile() - self._available = True - - except OSError as err: - self._available = False + self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) @@ -264,16 +119,125 @@ class ValloxFilterRemainingSensor(ValloxSensor): async def async_update(self): """Fetch state from the ventilation unit.""" try: - days_remaining = int(self._state_proxy.fetch_metric(self._metric_key)) + days_remaining = int( + self._state_proxy.fetch_metric(self.entity_description.metric_key) + ) days_remaining_delta = timedelta(days=days_remaining) # Since only a delta of days is received from the device, fix the # time so the timestamp does not change with every update. now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0) - self._state = (now + days_remaining_delta).isoformat() - self._available = True + self._attr_native_value = (now + days_remaining_delta).isoformat() + self._attr_available = True except (OSError, KeyError) as err: - self._available = False + self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) + + +@dataclass +class ValloxSensorEntityDescription(SensorEntityDescription): + """Describes Vallox sensor entity.""" + + metric_key: str | None = None + sensor_type: type[ValloxSensor] = ValloxSensor + + +SENSORS: tuple[ValloxSensorEntityDescription, ...] = ( + ValloxSensorEntityDescription( + key="current_profile", + name="Current Profile", + icon="mdi:gauge", + sensor_type=ValloxProfileSensor, + ), + ValloxSensorEntityDescription( + key="fan_speed", + name="Fan Speed", + metric_key="A_CYC_FAN_SPEED", + icon="mdi:fan", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + sensor_type=ValloxFanSpeedSensor, + ), + ValloxSensorEntityDescription( + key="remaining_time_for_filter", + name="Remaining Time For Filter", + metric_key="A_CYC_REMAINING_TIME_FOR_FILTER", + device_class=DEVICE_CLASS_TIMESTAMP, + sensor_type=ValloxFilterRemainingSensor, + ), + ValloxSensorEntityDescription( + key="extract_air", + name="Extract Air", + metric_key="A_CYC_TEMP_EXTRACT_AIR", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + ValloxSensorEntityDescription( + key="exhaust_air", + name="Exhaust Air", + metric_key="A_CYC_TEMP_EXHAUST_AIR", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + ValloxSensorEntityDescription( + key="outdoor_air", + name="Outdoor Air", + metric_key="A_CYC_TEMP_OUTDOOR_AIR", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + ValloxSensorEntityDescription( + key="supply_air", + name="Supply Air", + metric_key="A_CYC_TEMP_SUPPLY_AIR", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + ValloxSensorEntityDescription( + key="humidity", + name="Humidity", + metric_key="A_CYC_RH_VALUE", + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + ValloxSensorEntityDescription( + key="efficiency", + name="Efficiency", + metric_key="A_CYC_EXTRACT_EFFICIENCY", + icon="mdi:gauge", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + ValloxSensorEntityDescription( + key="co2", + name="CO2", + metric_key="A_CYC_CO2_VALUE", + device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the sensors.""" + if discovery_info is None: + return + + name = hass.data[DOMAIN]["name"] + state_proxy = hass.data[DOMAIN]["state_proxy"] + + async_add_entities( + [ + description.sensor_type(name, state_proxy, description) + for description in SENSORS + ], + update_before_add=False, + ) From 9f4f38dbefc452f6b32ba08525447a85c18a7f3d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Aug 2021 10:22:06 +0200 Subject: [PATCH 698/903] Use switch instead of toggle entity (#55111) --- .../components/thinkingcleaner/switch.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index eb9f37cede6..75cfc51a511 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -8,10 +8,13 @@ from pythinkingcleaner import Discovery, ThinkingCleaner import voluptuous as vol from homeassistant import util -from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.components.switch import ( + PLATFORM_SCHEMA, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) @@ -19,16 +22,16 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) MIN_TIME_TO_WAIT = timedelta(seconds=5) MIN_TIME_TO_LOCK_UPDATE = 5 -SWITCH_TYPES: tuple[ToggleEntityDescription, ...] = ( - ToggleEntityDescription( +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( key="clean", name="Clean", ), - ToggleEntityDescription( + SwitchEntityDescription( key="dock", name="Dock", ), - ToggleEntityDescription( + SwitchEntityDescription( key="find", name="Find", ), @@ -61,10 +64,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(entities) -class ThinkingCleanerSwitch(ToggleEntity): +class ThinkingCleanerSwitch(SwitchEntity): """ThinkingCleaner Switch (dock, clean, find me).""" - def __init__(self, tc_object, update_devices, description: ToggleEntityDescription): + def __init__(self, tc_object, update_devices, description: SwitchEntityDescription): """Initialize the ThinkingCleaner.""" self.entity_description = description From 8f2ea5f3ccd3b63313efefce31d8378153640a1f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Aug 2021 10:24:16 +0200 Subject: [PATCH 699/903] Use EntityDescription - rtorrent (#55067) --- homeassistant/components/rtorrent/sensor.py | 157 +++++++++++--------- 1 file changed, 89 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index 5379cb2ce2e..78dfca92525 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -1,10 +1,16 @@ """Support for monitoring the rtorrent BitTorrent client API.""" +from __future__ import annotations + import logging import xmlrpc.client import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_VARIABLES, CONF_NAME, @@ -28,24 +34,55 @@ SENSOR_TYPE_DOWNLOADING_TORRENTS = "downloading_torrents" SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" DEFAULT_NAME = "rtorrent" -SENSOR_TYPES = { - SENSOR_TYPE_CURRENT_STATUS: ["Status", None], - SENSOR_TYPE_DOWNLOAD_SPEED: ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], - SENSOR_TYPE_UPLOAD_SPEED: ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], - SENSOR_TYPE_ALL_TORRENTS: ["All Torrents", None], - SENSOR_TYPE_STOPPED_TORRENTS: ["Stopped Torrents", None], - SENSOR_TYPE_COMPLETE_TORRENTS: ["Complete Torrents", None], - SENSOR_TYPE_UPLOADING_TORRENTS: ["Uploading Torrents", None], - SENSOR_TYPE_DOWNLOADING_TORRENTS: ["Downloading Torrents", None], - SENSOR_TYPE_ACTIVE_TORRENTS: ["Active Torrents", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TYPE_CURRENT_STATUS, + name="Status", + ), + SensorEntityDescription( + key=SENSOR_TYPE_DOWNLOAD_SPEED, + name="Down Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), + SensorEntityDescription( + key=SENSOR_TYPE_UPLOAD_SPEED, + name="Up Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), + SensorEntityDescription( + key=SENSOR_TYPE_ALL_TORRENTS, + name="All Torrents", + ), + SensorEntityDescription( + key=SENSOR_TYPE_STOPPED_TORRENTS, + name="Stopped Torrents", + ), + SensorEntityDescription( + key=SENSOR_TYPE_COMPLETE_TORRENTS, + name="Complete Torrents", + ), + SensorEntityDescription( + key=SENSOR_TYPE_UPLOADING_TORRENTS, + name="Uploading Torrents", + ), + SensorEntityDescription( + key=SENSOR_TYPE_DOWNLOADING_TORRENTS, + name="Downloading Torrents", + ), + SensorEntityDescription( + key=SENSOR_TYPE_ACTIVE_TORRENTS, + name="Active Torrents", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_URL): cv.url, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_VARIABLES, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Optional(CONF_MONITORED_VARIABLES, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -61,11 +98,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except (xmlrpc.client.ProtocolError, ConnectionRefusedError) as ex: _LOGGER.error("Connection to rtorrent daemon failed") raise PlatformNotReady from ex - dev = [] - for variable in config[CONF_MONITORED_VARIABLES]: - dev.append(RTorrentSensor(variable, rtorrent, name)) + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities = [ + RTorrentSensor(rtorrent, name, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] - add_entities(dev) + add_entities(entities) def format_speed(speed): @@ -77,36 +117,16 @@ def format_speed(speed): class RTorrentSensor(SensorEntity): """Representation of an rtorrent sensor.""" - def __init__(self, sensor_type, rtorrent_client, client_name): + def __init__( + self, rtorrent_client, client_name, description: SensorEntityDescription + ): """Initialize the sensor.""" - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.client = rtorrent_client - self.type = sensor_type - self.client_name = client_name - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self.data = None - self._available = False - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return true if device is available.""" - return self._available - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_name = f"{client_name} {description.name}" + self._attr_available = False def update(self): """Get the latest data from rtorrent and updates the state.""" @@ -121,10 +141,10 @@ class RTorrentSensor(SensorEntity): try: self.data = multicall() - self._available = True + self._attr_available = True except (xmlrpc.client.ProtocolError, OSError) as ex: _LOGGER.error("Connection to rtorrent failed (%s)", ex) - self._available = False + self._attr_available = False return upload = self.data[0] @@ -145,33 +165,34 @@ class RTorrentSensor(SensorEntity): active_torrents = uploading_torrents + downloading_torrents - if self.type == SENSOR_TYPE_CURRENT_STATUS: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TYPE_CURRENT_STATUS: if self.data: if upload > 0 and download > 0: - self._state = "up_down" + self._attr_native_value = "up_down" elif upload > 0 and download == 0: - self._state = "seeding" + self._attr_native_value = "seeding" elif upload == 0 and download > 0: - self._state = "downloading" + self._attr_native_value = "downloading" else: - self._state = STATE_IDLE + self._attr_native_value = STATE_IDLE else: - self._state = None + self._attr_native_value = None if self.data: - if self.type == SENSOR_TYPE_DOWNLOAD_SPEED: - self._state = format_speed(download) - elif self.type == SENSOR_TYPE_UPLOAD_SPEED: - self._state = format_speed(upload) - elif self.type == SENSOR_TYPE_ALL_TORRENTS: - self._state = len(all_torrents) - elif self.type == SENSOR_TYPE_STOPPED_TORRENTS: - self._state = len(stopped_torrents) - elif self.type == SENSOR_TYPE_COMPLETE_TORRENTS: - self._state = len(complete_torrents) - elif self.type == SENSOR_TYPE_UPLOADING_TORRENTS: - self._state = uploading_torrents - elif self.type == SENSOR_TYPE_DOWNLOADING_TORRENTS: - self._state = downloading_torrents - elif self.type == SENSOR_TYPE_ACTIVE_TORRENTS: - self._state = active_torrents + if sensor_type == SENSOR_TYPE_DOWNLOAD_SPEED: + self._attr_native_value = format_speed(download) + elif sensor_type == SENSOR_TYPE_UPLOAD_SPEED: + self._attr_native_value = format_speed(upload) + elif sensor_type == SENSOR_TYPE_ALL_TORRENTS: + self._attr_native_value = len(all_torrents) + elif sensor_type == SENSOR_TYPE_STOPPED_TORRENTS: + self._attr_native_value = len(stopped_torrents) + elif sensor_type == SENSOR_TYPE_COMPLETE_TORRENTS: + self._attr_native_value = len(complete_torrents) + elif sensor_type == SENSOR_TYPE_UPLOADING_TORRENTS: + self._attr_native_value = uploading_torrents + elif sensor_type == SENSOR_TYPE_DOWNLOADING_TORRENTS: + self._attr_native_value = downloading_torrents + elif sensor_type == SENSOR_TYPE_ACTIVE_TORRENTS: + self._attr_native_value = active_torrents From a527872a10fc57223805dea7d597c873275bf8bd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Aug 2021 10:27:26 +0200 Subject: [PATCH 700/903] Use EntityDescription - comed_hourly_pricing (#55066) --- .../components/comed_hourly_pricing/sensor.py | 94 +++++++++---------- 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 48ec0c46536..fc038adc568 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -1,4 +1,6 @@ """Support for ComEd Hourly Pricing data.""" +from __future__ import annotations + import asyncio from datetime import timedelta import json @@ -8,7 +10,11 @@ import aiohttp import async_timeout import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -25,12 +31,22 @@ CONF_FIVE_MINUTE = "five_minute" CONF_MONITORED_FEEDS = "monitored_feeds" CONF_SENSOR_TYPE = "type" -SENSOR_TYPES = { - CONF_FIVE_MINUTE: ["ComEd 5 Minute Price", "c"], - CONF_CURRENT_HOUR_AVERAGE: ["ComEd Current Hour Average Price", "c"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=CONF_FIVE_MINUTE, + name="ComEd 5 Minute Price", + native_unit_of_measurement="c", + ), + SensorEntityDescription( + key=CONF_CURRENT_HOUR_AVERAGE, + name="ComEd Current Hour Average Price", + native_unit_of_measurement="c", + ), +) -TYPES_SCHEMA = vol.In(SENSOR_TYPES) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + +TYPES_SCHEMA = vol.In(SENSOR_KEYS) SENSORS_SCHEMA = vol.Schema( { @@ -48,64 +64,42 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the ComEd Hourly Pricing sensor.""" websession = async_get_clientsession(hass) - dev = [] - for variable in config[CONF_MONITORED_FEEDS]: - dev.append( - ComedHourlyPricingSensor( - hass.loop, - websession, - variable[CONF_SENSOR_TYPE], - variable[CONF_OFFSET], - variable.get(CONF_NAME), - ) + entities = [ + ComedHourlyPricingSensor( + websession, + variable[CONF_OFFSET], + variable.get(CONF_NAME), + description, ) + for variable in config[CONF_MONITORED_FEEDS] + for description in SENSOR_TYPES + if description.key == variable[CONF_SENSOR_TYPE] + ] - async_add_entities(dev, True) + async_add_entities(entities, True) class ComedHourlyPricingSensor(SensorEntity): """Implementation of a ComEd Hourly Pricing sensor.""" - def __init__(self, loop, websession, sensor_type, offset, name): + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + def __init__(self, websession, offset, name, description: SensorEntityDescription): """Initialize the sensor.""" - self.loop = loop + self.entity_description = description self.websession = websession if name: - self._name = name - else: - self._name = SENSOR_TYPES[sensor_type][0] - self.type = sensor_type + self._attr_name = name self.offset = offset - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} async def async_update(self): """Get the ComEd Hourly Pricing data from the web service.""" try: - if self.type == CONF_FIVE_MINUTE or self.type == CONF_CURRENT_HOUR_AVERAGE: + sensor_type = self.entity_description.key + if sensor_type in (CONF_FIVE_MINUTE, CONF_CURRENT_HOUR_AVERAGE): url_string = _RESOURCE - if self.type == CONF_FIVE_MINUTE: + if sensor_type == CONF_FIVE_MINUTE: url_string += "?type=5minutefeed" else: url_string += "?type=currenthouraverage" @@ -115,10 +109,12 @@ class ComedHourlyPricingSensor(SensorEntity): # The API responds with MIME type 'text/html' text = await response.text() data = json.loads(text) - self._state = round(float(data[0]["price"]) + self.offset, 2) + self._attr_native_value = round( + float(data[0]["price"]) + self.offset, 2 + ) else: - self._state = None + self._attr_native_value = None except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Could not get data from ComEd API: %s", err) From 2796f654535041a02b4c1b30f9c50fa3f4eed483 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Aug 2021 10:31:40 +0200 Subject: [PATCH 701/903] Use EntityDescription - broadlink (#55019) --- homeassistant/components/broadlink/sensor.py | 79 ++++++++++++-------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index f708790a5ce..676edb53b9a 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -1,4 +1,6 @@ """Support for Broadlink sensors.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -11,6 +13,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import CONF_HOST, PERCENTAGE, POWER_WATT, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv @@ -21,29 +24,42 @@ from .helpers import import_device _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "temperature": ( - "Temperature", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), - "air_quality": ("Air Quality", None, None, None), - "humidity": ( - "Humidity", - PERCENTAGE, - DEVICE_CLASS_HUMIDITY, - STATE_CLASS_MEASUREMENT, + SensorEntityDescription( + key="air_quality", + name="Air Quality", ), - "light": ("Light", None, DEVICE_CLASS_ILLUMINANCE, None), - "noise": ("Noise", None, None, None), - "power": ( - "Current power", - POWER_WATT, - DEVICE_CLASS_POWER, - STATE_CLASS_MEASUREMENT, + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, ), -} + SensorEntityDescription( + key="light", + name="Light", + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + SensorEntityDescription( + key="noise", + name="Noise", + ), + SensorEntityDescription( + key="power", + name="Current power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_HOST): cv.string}, extra=vol.ALLOW_EXTRA @@ -67,13 +83,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): device = hass.data[DOMAIN].devices[config_entry.entry_id] sensor_data = device.update_manager.coordinator.data sensors = [ - BroadlinkSensor(device, monitored_condition) - for monitored_condition in sensor_data - if monitored_condition in SENSOR_TYPES + BroadlinkSensor(device, description) + for description in SENSOR_TYPES + if description.key in sensor_data and ( # These devices have optional sensors. # We don't create entities if the value is 0. - sensor_data[monitored_condition] != 0 + sensor_data[description.key] != 0 or device.api.type not in {"RM4PRO", "RM4MINI"} ) ] @@ -83,18 +99,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BroadlinkSensor(BroadlinkEntity, SensorEntity): """Representation of a Broadlink sensor.""" - def __init__(self, device, monitored_condition): + def __init__(self, device, description: SensorEntityDescription): """Initialize the sensor.""" super().__init__(device) - self._monitored_condition = monitored_condition + self.entity_description = description - self._attr_device_class = SENSOR_TYPES[monitored_condition][2] - self._attr_name = f"{device.name} {SENSOR_TYPES[monitored_condition][0]}" - self._attr_state_class = SENSOR_TYPES[monitored_condition][3] - self._attr_native_value = self._coordinator.data[monitored_condition] - self._attr_unique_id = f"{device.unique_id}-{monitored_condition}" - self._attr_native_unit_of_measurement = SENSOR_TYPES[monitored_condition][1] + self._attr_name = f"{device.name} {description.name}" + self._attr_native_value = self._coordinator.data[description.key] + self._attr_unique_id = f"{device.unique_id}-{description.key}" def _update_state(self, data): """Update the state of the entity.""" - self._attr_native_value = data[self._monitored_condition] + self._attr_native_value = data[self.entity_description.key] From ccaf0d5c75b71728e57d07b653bfd0215a87e82c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 24 Aug 2021 10:37:59 +0200 Subject: [PATCH 702/903] Use EntityDescription - onewire (#55003) --- .../components/onewire/binary_sensor.py | 169 +++--- homeassistant/components/onewire/const.py | 45 +- homeassistant/components/onewire/model.py | 9 - .../components/onewire/onewire_entities.py | 97 ++- homeassistant/components/onewire/sensor.py | 563 +++++++++++------- homeassistant/components/onewire/switch.py | 300 +++++----- tests/components/onewire/const.py | 343 ++++++----- .../components/onewire/test_binary_sensor.py | 2 +- tests/components/onewire/test_sensor.py | 27 +- tests/components/onewire/test_switch.py | 2 +- 10 files changed, 836 insertions(+), 721 deletions(-) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 5b73a8c8873..ff2ee55d0bd 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -1,9 +1,13 @@ """Support for 1-Wire binary sensors.""" from __future__ import annotations +from dataclasses import dataclass import os -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_IDENTIFIERS, @@ -16,77 +20,83 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_TYPE_OWSERVER, DOMAIN, SENSOR_TYPE_SENSED -from .model import DeviceComponentDescription -from .onewire_entities import OneWireBaseEntity, OneWireProxyEntity +from .const import CONF_TYPE_OWSERVER, DOMAIN, READ_MODE_BOOL +from .onewire_entities import OneWireEntityDescription, OneWireProxyEntity from .onewirehub import OneWireHub -DEVICE_BINARY_SENSORS: dict[str, list[DeviceComponentDescription]] = { - # Family : { path, sensor_type } - "12": [ - { - "path": "sensed.A", - "name": "Sensed A", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.B", - "name": "Sensed B", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - ], - "29": [ - { - "path": "sensed.0", - "name": "Sensed 0", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.1", - "name": "Sensed 1", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.2", - "name": "Sensed 2", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.3", - "name": "Sensed 3", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.4", - "name": "Sensed 4", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.5", - "name": "Sensed 5", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.6", - "name": "Sensed 6", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.7", - "name": "Sensed 7", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - ], + +@dataclass +class OneWireBinarySensorEntityDescription( + OneWireEntityDescription, BinarySensorEntityDescription +): + """Class describing OneWire binary sensor entities.""" + + +DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = { + "12": ( + OneWireBinarySensorEntityDescription( + key="sensed.A", + entity_registry_enabled_default=False, + name="Sensed A", + read_mode=READ_MODE_BOOL, + ), + OneWireBinarySensorEntityDescription( + key="sensed.B", + entity_registry_enabled_default=False, + name="Sensed B", + read_mode=READ_MODE_BOOL, + ), + ), + "29": ( + OneWireBinarySensorEntityDescription( + key="sensed.0", + entity_registry_enabled_default=False, + name="Sensed 0", + read_mode=READ_MODE_BOOL, + ), + OneWireBinarySensorEntityDescription( + key="sensed.1", + entity_registry_enabled_default=False, + name="Sensed 1", + read_mode=READ_MODE_BOOL, + ), + OneWireBinarySensorEntityDescription( + key="sensed.2", + entity_registry_enabled_default=False, + name="Sensed 2", + read_mode=READ_MODE_BOOL, + ), + OneWireBinarySensorEntityDescription( + key="sensed.3", + entity_registry_enabled_default=False, + name="Sensed 3", + read_mode=READ_MODE_BOOL, + ), + OneWireBinarySensorEntityDescription( + key="sensed.4", + entity_registry_enabled_default=False, + name="Sensed 4", + read_mode=READ_MODE_BOOL, + ), + OneWireBinarySensorEntityDescription( + key="sensed.5", + entity_registry_enabled_default=False, + name="Sensed 5", + read_mode=READ_MODE_BOOL, + ), + OneWireBinarySensorEntityDescription( + key="sensed.6", + entity_registry_enabled_default=False, + name="Sensed 6", + read_mode=READ_MODE_BOOL, + ), + OneWireBinarySensorEntityDescription( + key="sensed.7", + entity_registry_enabled_default=False, + name="Sensed 7", + read_mode=READ_MODE_BOOL, + ), + ), } @@ -104,12 +114,12 @@ async def async_setup_entry( async_add_entities(entities, True) -def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: +def get_entities(onewirehub: OneWireHub) -> list[BinarySensorEntity]: """Get a list of entities.""" if not onewirehub.devices: return [] - entities: list[OneWireBaseEntity] = [] + entities: list[BinarySensorEntity] = [] for device in onewirehub.devices: family = device["family"] @@ -124,17 +134,18 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: ATTR_MODEL: device_type, ATTR_NAME: device_id, } - for entity_specs in DEVICE_BINARY_SENSORS[family]: - entity_path = os.path.join( - os.path.split(device["path"])[0], entity_specs["path"] + for description in DEVICE_BINARY_SENSORS[family]: + device_file = os.path.join( + os.path.split(device["path"])[0], description.key ) + name = f"{device_id} {description.name}" entities.append( OneWireProxyBinarySensor( + description=description, device_id=device_id, - device_name=device_id, + device_file=device_file, device_info=device_info, - entity_path=entity_path, - entity_specs=entity_specs, + name=name, owproxy=onewirehub.owproxy, ) ) @@ -145,6 +156,8 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: class OneWireProxyBinarySensor(OneWireProxyEntity, BinarySensorEntity): """Implementation of a 1-Wire binary sensor.""" + entity_description: OneWireBinarySensorEntityDescription + @property def is_on(self) -> bool: """Return true if sensor is on.""" diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index 9112bf5e8f6..4d758146aff 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -4,20 +4,6 @@ from __future__ import annotations from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import ( - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - ELECTRIC_CURRENT_AMPERE, - ELECTRIC_POTENTIAL_VOLT, - LIGHT_LUX, - PERCENTAGE, - PRESSURE_MBAR, - TEMP_CELSIUS, -) CONF_MOUNT_DIR = "mount_dir" CONF_NAMES = "names" @@ -33,34 +19,9 @@ DOMAIN = "onewire" PRESSURE_CBAR = "cbar" -SENSOR_TYPE_COUNT = "count" -SENSOR_TYPE_CURRENT = "current" -SENSOR_TYPE_HUMIDITY = "humidity" -SENSOR_TYPE_ILLUMINANCE = "illuminance" -SENSOR_TYPE_MOISTURE = "moisture" -SENSOR_TYPE_PRESSURE = "pressure" -SENSOR_TYPE_SENSED = "sensed" -SENSOR_TYPE_TEMPERATURE = "temperature" -SENSOR_TYPE_VOLTAGE = "voltage" -SENSOR_TYPE_WETNESS = "wetness" -SWITCH_TYPE_LATCH = "latch" -SWITCH_TYPE_PIO = "pio" - -SENSOR_TYPES: dict[str, list[str | None]] = { - # SensorType: [ Unit, DeviceClass ] - SENSOR_TYPE_TEMPERATURE: [TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - SENSOR_TYPE_HUMIDITY: [PERCENTAGE, DEVICE_CLASS_HUMIDITY], - SENSOR_TYPE_PRESSURE: [PRESSURE_MBAR, DEVICE_CLASS_PRESSURE], - SENSOR_TYPE_ILLUMINANCE: [LIGHT_LUX, DEVICE_CLASS_ILLUMINANCE], - SENSOR_TYPE_WETNESS: [PERCENTAGE, DEVICE_CLASS_HUMIDITY], - SENSOR_TYPE_MOISTURE: [PRESSURE_CBAR, DEVICE_CLASS_PRESSURE], - SENSOR_TYPE_COUNT: ["count", None], - SENSOR_TYPE_VOLTAGE: [ELECTRIC_POTENTIAL_VOLT, DEVICE_CLASS_VOLTAGE], - SENSOR_TYPE_CURRENT: [ELECTRIC_CURRENT_AMPERE, DEVICE_CLASS_CURRENT], - SENSOR_TYPE_SENSED: [None, None], - SWITCH_TYPE_LATCH: [None, None], - SWITCH_TYPE_PIO: [None, None], -} +READ_MODE_BOOL = "bool" +READ_MODE_FLOAT = "float" +READ_MODE_INT = "int" PLATFORMS = [ BINARY_SENSOR_DOMAIN, diff --git a/homeassistant/components/onewire/model.py b/homeassistant/components/onewire/model.py index 8dc841f16ba..2aaef861a50 100644 --- a/homeassistant/components/onewire/model.py +++ b/homeassistant/components/onewire/model.py @@ -4,15 +4,6 @@ from __future__ import annotations from typing import TypedDict -class DeviceComponentDescription(TypedDict, total=False): - """Device component description class.""" - - path: str - name: str - type: str - default_disabled: bool - - class OWServerDeviceDescription(TypedDict): """OWServer device description class.""" diff --git a/homeassistant/components/onewire/onewire_entities.py b/homeassistant/components/onewire/onewire_entities.py index b60d06739e8..e00733ae387 100644 --- a/homeassistant/components/onewire/onewire_entities.py +++ b/homeassistant/components/onewire/onewire_entities.py @@ -1,22 +1,24 @@ """Support for 1-Wire entities.""" from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any from pyownet import protocol -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from homeassistant.helpers.typing import StateType -from .const import ( - SENSOR_TYPE_COUNT, - SENSOR_TYPE_SENSED, - SENSOR_TYPES, - SWITCH_TYPE_LATCH, - SWITCH_TYPE_PIO, -) -from .model import DeviceComponentDescription +from .const import READ_MODE_BOOL, READ_MODE_INT + + +@dataclass +class OneWireEntityDescription(EntityDescription): + """Class describing OneWire entities.""" + + read_mode: str | None = None + _LOGGER = logging.getLogger(__name__) @@ -24,57 +26,32 @@ _LOGGER = logging.getLogger(__name__) class OneWireBaseEntity(Entity): """Implementation of a 1-Wire entity.""" + entity_description: OneWireEntityDescription + def __init__( self, - name: str, - device_file: str, - entity_type: str, - entity_name: str, + description: OneWireEntityDescription, + device_id: str, device_info: DeviceInfo, - default_disabled: bool, - unique_id: str, + device_file: str, + name: str, ) -> None: """Initialize the entity.""" - self._name = f"{name} {entity_name or entity_type.capitalize()}" + self.entity_description = description + self._attr_unique_id = f"/{device_id}/{description.key}" + self._attr_device_info = device_info + self._attr_name = name self._device_file = device_file - self._entity_type = entity_type - self._device_class = SENSOR_TYPES[entity_type][1] - self._unit_of_measurement = SENSOR_TYPES[entity_type][0] - self._device_info = device_info self._state: StateType = None self._value_raw: float | None = None - self._default_disabled = default_disabled - self._unique_id = unique_id - - @property - def name(self) -> str | None: - """Return the name of the entity.""" - return self._name - - @property - def device_class(self) -> str | None: - """Return the class of this device.""" - return self._device_class @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the entity.""" - return {"device_file": self._device_file, "raw_value": self._value_raw} - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo | None: - """Return device specific attributes.""" - return self._device_info - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return not self._default_disabled + return { + "device_file": self._device_file, + "raw_value": self._value_raw, + } class OneWireProxyEntity(OneWireBaseEntity): @@ -82,22 +59,20 @@ class OneWireProxyEntity(OneWireBaseEntity): def __init__( self, + description: OneWireEntityDescription, device_id: str, - device_name: str, device_info: DeviceInfo, - entity_path: str, - entity_specs: DeviceComponentDescription, + device_file: str, + name: str, owproxy: protocol._Proxy, ) -> None: """Initialize the sensor.""" super().__init__( - name=device_name, - device_file=entity_path, - entity_type=entity_specs["type"], - entity_name=entity_specs["name"], + description=description, + device_id=device_id, device_info=device_info, - default_disabled=entity_specs.get("default_disabled", False), - unique_id=f"/{device_id}/{entity_specs['path']}", + device_file=device_file, + name=name, ) self._owproxy = owproxy @@ -118,13 +93,9 @@ class OneWireProxyEntity(OneWireBaseEntity): _LOGGER.error("Owserver failure in read(), got: %s", exc) self._state = None else: - if self._entity_type == SENSOR_TYPE_COUNT: + if self.entity_description.read_mode == READ_MODE_INT: self._state = int(self._value_raw) - elif self._entity_type in [ - SENSOR_TYPE_SENSED, - SWITCH_TYPE_LATCH, - SWITCH_TYPE_PIO, - ]: + elif self.entity_description.read_mode == READ_MODE_BOOL: self._state = int(self._value_raw) == 1 else: self._state = round(self._value_raw, 1) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 215ba6c569b..b1f08b864b1 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +import copy +from dataclasses import dataclass import logging import os from types import MappingProxyType @@ -9,7 +11,12 @@ from typing import Any from pi1wire import InvalidCRCException, OneWireInterface, UnsupportResponseException -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_IDENTIFIERS, @@ -17,6 +24,18 @@ from homeassistant.const import ( ATTR_MODEL, ATTR_NAME, CONF_TYPE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + LIGHT_LUX, + PERCENTAGE, + PRESSURE_MBAR, + TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo @@ -29,122 +48,172 @@ from .const import ( CONF_TYPE_OWSERVER, CONF_TYPE_SYSBUS, DOMAIN, - SENSOR_TYPE_COUNT, - SENSOR_TYPE_CURRENT, - SENSOR_TYPE_HUMIDITY, - SENSOR_TYPE_ILLUMINANCE, - SENSOR_TYPE_MOISTURE, - SENSOR_TYPE_PRESSURE, - SENSOR_TYPE_TEMPERATURE, - SENSOR_TYPE_VOLTAGE, - SENSOR_TYPE_WETNESS, + PRESSURE_CBAR, + READ_MODE_FLOAT, + READ_MODE_INT, +) +from .onewire_entities import ( + OneWireBaseEntity, + OneWireEntityDescription, + OneWireProxyEntity, ) -from .model import DeviceComponentDescription -from .onewire_entities import OneWireBaseEntity, OneWireProxyEntity from .onewirehub import OneWireHub + +@dataclass +class OneWireSensorEntityDescription(OneWireEntityDescription, SensorEntityDescription): + """Class describing OneWire sensor entities.""" + + +SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION = OneWireSensorEntityDescription( + key="temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, +) + _LOGGER = logging.getLogger(__name__) -DEVICE_SENSORS: dict[str, list[DeviceComponentDescription]] = { - # Family : { SensorType: owfs path } - "10": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE} - ], - "12": [ - { - "path": "TAI8570/temperature", - "name": "Temperature", - "type": SENSOR_TYPE_TEMPERATURE, - "default_disabled": True, - }, - { - "path": "TAI8570/pressure", - "name": "Pressure", - "type": SENSOR_TYPE_PRESSURE, - "default_disabled": True, - }, - ], - "22": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE} - ], - "26": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE}, - { - "path": "humidity", - "name": "Humidity", - "type": SENSOR_TYPE_HUMIDITY, - "default_disabled": True, - }, - { - "path": "HIH3600/humidity", - "name": "Humidity HIH3600", - "type": SENSOR_TYPE_HUMIDITY, - "default_disabled": True, - }, - { - "path": "HIH4000/humidity", - "name": "Humidity HIH4000", - "type": SENSOR_TYPE_HUMIDITY, - "default_disabled": True, - }, - { - "path": "HIH5030/humidity", - "name": "Humidity HIH5030", - "type": SENSOR_TYPE_HUMIDITY, - "default_disabled": True, - }, - { - "path": "HTM1735/humidity", - "name": "Humidity HTM1735", - "type": SENSOR_TYPE_HUMIDITY, - "default_disabled": True, - }, - { - "path": "B1-R1-A/pressure", - "name": "Pressure", - "type": SENSOR_TYPE_PRESSURE, - "default_disabled": True, - }, - { - "path": "S3-R1-A/illuminance", - "name": "Illuminance", - "type": SENSOR_TYPE_ILLUMINANCE, - "default_disabled": True, - }, - { - "path": "VAD", - "name": "Voltage VAD", - "type": SENSOR_TYPE_VOLTAGE, - "default_disabled": True, - }, - { - "path": "VDD", - "name": "Voltage VDD", - "type": SENSOR_TYPE_VOLTAGE, - "default_disabled": True, - }, - { - "path": "IAD", - "name": "Current", - "type": SENSOR_TYPE_CURRENT, - "default_disabled": True, - }, - ], - "28": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE} - ], - "3B": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE} - ], - "42": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE} - ], - "1D": [ - {"path": "counter.A", "name": "Counter A", "type": SENSOR_TYPE_COUNT}, - {"path": "counter.B", "name": "Counter B", "type": SENSOR_TYPE_COUNT}, - ], - "EF": [], # "HobbyBoard": special - "7E": [], # "EDS": special + +DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { + "10": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), + "12": ( + OneWireSensorEntityDescription( + key="TAI8570/temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + entity_registry_enabled_default=False, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="TAI8570/pressure", + device_class=DEVICE_CLASS_PRESSURE, + entity_registry_enabled_default=False, + name="Pressure", + native_unit_of_measurement=PRESSURE_MBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), + "26": ( + SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION, + OneWireSensorEntityDescription( + key="humidity", + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="HIH3600/humidity", + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + name="Humidity HIH3600", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="HIH4000/humidity", + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + name="Humidity HIH4000", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="HIH5030/humidity", + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + name="Humidity HIH5030", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="HTM1735/humidity", + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + name="Humidity HTM1735", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="B1-R1-A/pressure", + device_class=DEVICE_CLASS_PRESSURE, + entity_registry_enabled_default=False, + name="Pressure", + native_unit_of_measurement=PRESSURE_MBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="S3-R1-A/illuminance", + device_class=DEVICE_CLASS_ILLUMINANCE, + entity_registry_enabled_default=False, + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="VAD", + device_class=DEVICE_CLASS_VOLTAGE, + entity_registry_enabled_default=False, + name="Voltage VAD", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="VDD", + device_class=DEVICE_CLASS_VOLTAGE, + entity_registry_enabled_default=False, + name="Voltage VDD", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="IAD", + device_class=DEVICE_CLASS_CURRENT, + entity_registry_enabled_default=False, + name="Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + "28": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), + "3B": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), + "42": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), + "1D": ( + OneWireSensorEntityDescription( + key="counter.A", + name="Counter A", + native_unit_of_measurement="count", + read_mode=READ_MODE_INT, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + OneWireSensorEntityDescription( + key="counter.B", + name="Counter B", + native_unit_of_measurement="count", + read_mode=READ_MODE_INT, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + ), + "EF": (), # "HobbyBoard": special + "7E": (), # "EDS": special } DEVICE_SUPPORT_SYSBUS = ["10", "22", "28", "3B", "42"] @@ -153,85 +222,124 @@ DEVICE_SUPPORT_SYSBUS = ["10", "22", "28", "3B", "42"] # These can only be read by OWFS. Currently this driver only supports them # via owserver (network protocol) -HOBBYBOARD_EF: dict[str, list[DeviceComponentDescription]] = { - "HobbyBoards_EF": [ - { - "path": "humidity/humidity_corrected", - "name": "Humidity", - "type": SENSOR_TYPE_HUMIDITY, - }, - { - "path": "humidity/humidity_raw", - "name": "Humidity Raw", - "type": SENSOR_TYPE_HUMIDITY, - }, - { - "path": "humidity/temperature", - "name": "Temperature", - "type": SENSOR_TYPE_TEMPERATURE, - }, - ], - "HB_MOISTURE_METER": [ - { - "path": "moisture/sensor.0", - "name": "Moisture 0", - "type": SENSOR_TYPE_MOISTURE, - }, - { - "path": "moisture/sensor.1", - "name": "Moisture 1", - "type": SENSOR_TYPE_MOISTURE, - }, - { - "path": "moisture/sensor.2", - "name": "Moisture 2", - "type": SENSOR_TYPE_MOISTURE, - }, - { - "path": "moisture/sensor.3", - "name": "Moisture 3", - "type": SENSOR_TYPE_MOISTURE, - }, - ], +HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { + "HobbyBoards_EF": ( + OneWireSensorEntityDescription( + key="humidity/humidity_corrected", + device_class=DEVICE_CLASS_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="humidity/humidity_raw", + device_class=DEVICE_CLASS_HUMIDITY, + name="Humidity Raw", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="humidity/temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + "HB_MOISTURE_METER": ( + OneWireSensorEntityDescription( + key="moisture/sensor.0", + device_class=DEVICE_CLASS_PRESSURE, + name="Moisture 0", + native_unit_of_measurement=PRESSURE_CBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="moisture/sensor.1", + device_class=DEVICE_CLASS_PRESSURE, + name="Moisture 1", + native_unit_of_measurement=PRESSURE_CBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="moisture/sensor.2", + device_class=DEVICE_CLASS_PRESSURE, + name="Moisture 2", + native_unit_of_measurement=PRESSURE_CBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="moisture/sensor.3", + device_class=DEVICE_CLASS_PRESSURE, + name="Moisture 3", + native_unit_of_measurement=PRESSURE_CBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), } # 7E sensors are special sensors by Embedded Data Systems -EDS_SENSORS: dict[str, list[DeviceComponentDescription]] = { - "EDS0066": [ - { - "path": "EDS0066/temperature", - "name": "Temperature", - "type": SENSOR_TYPE_TEMPERATURE, - }, - { - "path": "EDS0066/pressure", - "name": "Pressure", - "type": SENSOR_TYPE_PRESSURE, - }, - ], - "EDS0068": [ - { - "path": "EDS0068/temperature", - "name": "Temperature", - "type": SENSOR_TYPE_TEMPERATURE, - }, - { - "path": "EDS0068/pressure", - "name": "Pressure", - "type": SENSOR_TYPE_PRESSURE, - }, - { - "path": "EDS0068/light", - "name": "Illuminance", - "type": SENSOR_TYPE_ILLUMINANCE, - }, - { - "path": "EDS0068/humidity", - "name": "Humidity", - "type": SENSOR_TYPE_HUMIDITY, - }, - ], +EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { + "EDS0066": ( + OneWireSensorEntityDescription( + key="EDS0066/temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="EDS0066/pressure", + device_class=DEVICE_CLASS_PRESSURE, + name="Pressure", + native_unit_of_measurement=PRESSURE_MBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + "EDS0068": ( + OneWireSensorEntityDescription( + key="EDS0068/temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="EDS0068/pressure", + device_class=DEVICE_CLASS_PRESSURE, + name="Pressure", + native_unit_of_measurement=PRESSURE_MBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="EDS0068/light", + device_class=DEVICE_CLASS_ILLUMINANCE, + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="EDS0068/humidity", + device_class=DEVICE_CLASS_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), } @@ -259,12 +367,12 @@ async def async_setup_entry( def get_entities( onewirehub: OneWireHub, config: MappingProxyType[str, Any] -) -> list[OneWireBaseEntity]: +) -> list[SensorEntity]: """Get a list of entities.""" if not onewirehub.devices: return [] - entities: list[OneWireBaseEntity] = [] + entities: list[SensorEntity] = [] device_names = {} if CONF_NAMES in config and isinstance(config[CONF_NAMES], dict): device_names = config[CONF_NAMES] @@ -299,27 +407,30 @@ def get_entities( ATTR_MODEL: device_type, ATTR_NAME: device_id, } - for entity_specs in get_sensor_types(device_sub_type)[family]: - if entity_specs["type"] == SENSOR_TYPE_MOISTURE: - s_id = entity_specs["path"].split(".")[1] + for description in get_sensor_types(device_sub_type)[family]: + if description.key.startswith("moisture/"): + s_id = description.key.split(".")[1] is_leaf = int( onewirehub.owproxy.read( f"{device_path}moisture/is_leaf.{s_id}" ).decode() ) if is_leaf: - entity_specs["type"] = SENSOR_TYPE_WETNESS - entity_specs["name"] = f"Wetness {s_id}" - entity_path = os.path.join( - os.path.split(device_path)[0], entity_specs["path"] + description = copy.deepcopy(description) + description.device_class = DEVICE_CLASS_HUMIDITY + description.native_unit_of_measurement = PERCENTAGE + description.name = f"Wetness {s_id}" + device_file = os.path.join( + os.path.split(device["path"])[0], description.key ) + name = f"{device_names.get(device_id, device_id)} {description.name}" entities.append( OneWireProxySensor( + description=description, device_id=device_id, - device_name=device_names.get(device_id, device_id), + device_file=device_file, device_info=device_info, - entity_path=entity_path, - entity_specs=entity_specs, + name=name, owproxy=onewirehub.owproxy, ) ) @@ -330,28 +441,32 @@ def get_entities( _LOGGER.debug("Initializing using SysBus %s", base_dir) for p1sensor in onewirehub.devices: family = p1sensor.mac_address[:2] - sensor_id = f"{family}-{p1sensor.mac_address[2:]}" + device_id = f"{family}-{p1sensor.mac_address[2:]}" if family not in DEVICE_SUPPORT_SYSBUS: _LOGGER.warning( "Ignoring unknown family (%s) of sensor found for device: %s", family, - sensor_id, + device_id, ) continue device_info = { - ATTR_IDENTIFIERS: {(DOMAIN, sensor_id)}, + ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, ATTR_MANUFACTURER: "Maxim Integrated", ATTR_MODEL: family, - ATTR_NAME: sensor_id, + ATTR_NAME: device_id, } - device_file = f"/sys/bus/w1/devices/{sensor_id}/w1_slave" + description = SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION + device_file = f"/sys/bus/w1/devices/{device_id}/w1_slave" + name = f"{device_names.get(device_id, device_id)} {description.name}" entities.append( OneWireDirectSensor( - device_names.get(sensor_id, sensor_id), - device_file, - device_info, - p1sensor, + description=description, + device_id=device_id, + device_file=device_file, + device_info=device_info, + name=name, + owsensor=p1sensor, ) ) if not entities: @@ -367,15 +482,14 @@ def get_entities( class OneWireSensor(OneWireBaseEntity, SensorEntity): """Mixin for sensor specific attributes.""" - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._unit_of_measurement + entity_description: OneWireSensorEntityDescription class OneWireProxySensor(OneWireProxyEntity, OneWireSensor): """Implementation of a 1-Wire sensor connected through owserver.""" + entity_description: OneWireSensorEntityDescription + @property def native_value(self) -> StateType: """Return the state of the entity.""" @@ -387,21 +501,22 @@ class OneWireDirectSensor(OneWireSensor): def __init__( self, - name: str, - device_file: str, + description: OneWireSensorEntityDescription, + device_id: str, device_info: DeviceInfo, + device_file: str, + name: str, owsensor: OneWireInterface, ) -> None: """Initialize the sensor.""" super().__init__( - name, - device_file, - "temperature", - "Temperature", - device_info, - False, - device_file, + description=description, + device_id=device_id, + device_info=device_info, + device_file=device_file, + name=name, ) + self._attr_unique_id = device_file self._owsensor = owsensor @property @@ -439,5 +554,9 @@ class OneWireDirectSensor(OneWireSensor): InvalidCRCException, UnsupportResponseException, ) as ex: - _LOGGER.warning("Cannot read from sensor %s: %s", self._device_file, ex) + _LOGGER.warning( + "Cannot read from sensor %s: %s", + self._device_file, + ex, + ) self._state = None diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index b5177bfca15..678f930901f 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -1,11 +1,12 @@ """Support for 1-Wire environment switches.""" from __future__ import annotations +from dataclasses import dataclass import logging import os from typing import Any -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_IDENTIFIERS, @@ -18,145 +19,149 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_TYPE_OWSERVER, DOMAIN, SWITCH_TYPE_LATCH, SWITCH_TYPE_PIO -from .model import DeviceComponentDescription -from .onewire_entities import OneWireBaseEntity, OneWireProxyEntity +from .const import CONF_TYPE_OWSERVER, DOMAIN, READ_MODE_BOOL +from .onewire_entities import OneWireEntityDescription, OneWireProxyEntity from .onewirehub import OneWireHub -DEVICE_SWITCHES: dict[str, list[DeviceComponentDescription]] = { - # Family : { owfs path } - "05": [ - { - "path": "PIO", - "name": "PIO", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - ], - "12": [ - { - "path": "PIO.A", - "name": "PIO A", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.B", - "name": "PIO B", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "latch.A", - "name": "Latch A", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.B", - "name": "Latch B", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - ], - "29": [ - { - "path": "PIO.0", - "name": "PIO 0", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.1", - "name": "PIO 1", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.2", - "name": "PIO 2", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.3", - "name": "PIO 3", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.4", - "name": "PIO 4", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.5", - "name": "PIO 5", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.6", - "name": "PIO 6", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.7", - "name": "PIO 7", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "latch.0", - "name": "Latch 0", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.1", - "name": "Latch 1", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.2", - "name": "Latch 2", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.3", - "name": "Latch 3", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.4", - "name": "Latch 4", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.5", - "name": "Latch 5", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.6", - "name": "Latch 6", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.7", - "name": "Latch 7", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - ], + +@dataclass +class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescription): + """Class describing OneWire switch entities.""" + + +DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { + "05": ( + OneWireSwitchEntityDescription( + key="PIO", + entity_registry_enabled_default=False, + name="PIO", + read_mode=READ_MODE_BOOL, + ), + ), + "12": ( + OneWireSwitchEntityDescription( + key="PIO.A", + entity_registry_enabled_default=False, + name="PIO A", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="PIO.B", + entity_registry_enabled_default=False, + name="PIO B", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="latch.A", + entity_registry_enabled_default=False, + name="Latch A", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="latch.B", + entity_registry_enabled_default=False, + name="Latch B", + read_mode=READ_MODE_BOOL, + ), + ), + "29": ( + OneWireSwitchEntityDescription( + key="PIO.0", + entity_registry_enabled_default=False, + name="PIO 0", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="PIO.1", + entity_registry_enabled_default=False, + name="PIO 1", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="PIO.2", + entity_registry_enabled_default=False, + name="PIO 2", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="PIO.3", + entity_registry_enabled_default=False, + name="PIO 3", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="PIO.4", + entity_registry_enabled_default=False, + name="PIO 4", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="PIO.5", + entity_registry_enabled_default=False, + name="PIO 5", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="PIO.6", + entity_registry_enabled_default=False, + name="PIO 6", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="PIO.7", + entity_registry_enabled_default=False, + name="PIO 7", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="latch.0", + entity_registry_enabled_default=False, + name="Latch 0", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="latch.1", + entity_registry_enabled_default=False, + name="Latch 1", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="latch.2", + entity_registry_enabled_default=False, + name="Latch 2", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="latch.3", + entity_registry_enabled_default=False, + name="Latch 3", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="latch.4", + entity_registry_enabled_default=False, + name="Latch 4", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="latch.5", + entity_registry_enabled_default=False, + name="Latch 5", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="latch.6", + entity_registry_enabled_default=False, + name="Latch 6", + read_mode=READ_MODE_BOOL, + ), + OneWireSwitchEntityDescription( + key="latch.7", + entity_registry_enabled_default=False, + name="Latch 7", + read_mode=READ_MODE_BOOL, + ), + ), } LOGGER = logging.getLogger(__name__) @@ -176,12 +181,12 @@ async def async_setup_entry( async_add_entities(entities, True) -def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: +def get_entities(onewirehub: OneWireHub) -> list[SwitchEntity]: """Get a list of entities.""" if not onewirehub.devices: return [] - entities: list[OneWireBaseEntity] = [] + entities: list[SwitchEntity] = [] for device in onewirehub.devices: family = device["family"] @@ -197,17 +202,18 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: ATTR_MODEL: device_type, ATTR_NAME: device_id, } - for entity_specs in DEVICE_SWITCHES[family]: - entity_path = os.path.join( - os.path.split(device["path"])[0], entity_specs["path"] + for description in DEVICE_SWITCHES[family]: + device_file = os.path.join( + os.path.split(device["path"])[0], description.key ) + name = f"{device_id} {description.name}" entities.append( OneWireProxySwitch( + description=description, device_id=device_id, - device_name=device_id, + device_file=device_file, device_info=device_info, - entity_path=entity_path, - entity_specs=entity_specs, + name=name, owproxy=onewirehub.owproxy, ) ) @@ -218,6 +224,8 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: class OneWireProxySwitch(OneWireProxyEntity, SwitchEntity): """Implementation of a 1-Wire switch.""" + entity_description: OneWireSwitchEntityDescription + @property def is_on(self) -> bool: """Return true if sensor is on.""" diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 8a20d4fb0aa..9c37442e2f7 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -5,13 +5,20 @@ from pyownet.protocol import Error as ProtocolError from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.onewire.const import DOMAIN, PRESSURE_CBAR -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, DEVICE_CLASS_CURRENT, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -53,8 +60,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/05.111111111111/PIO", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, ], @@ -75,8 +82,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/10.111111111111/temperature", "injected_value": b" 25.123", "result": "25.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -96,8 +104,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/sensed.A", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -105,8 +113,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/sensed.B", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, ], @@ -116,18 +124,20 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/TAI8570/temperature", "injected_value": b" 25.123", "result": "25.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.12_111111111111_pressure", "unique_id": "/12.111111111111/TAI8570/pressure", "injected_value": b" 1025.123", "result": "1025.1", - "unit": PRESSURE_MBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], SWITCH_DOMAIN: [ @@ -136,8 +146,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/PIO.A", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -145,8 +155,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/PIO.B", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -154,8 +164,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/latch.A", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -163,8 +173,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/latch.B", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, ], @@ -185,16 +195,18 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/1D.111111111111/counter.A", "injected_value": b" 251123", "result": "251123", - "unit": "count", - "class": None, + ATTR_UNIT_OF_MEASUREMENT: "count", + ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, { "entity_id": "sensor.1d_111111111111_counter_b", "unique_id": "/1D.111111111111/counter.B", "injected_value": b" 248125", "result": "248125", - "unit": "count", - "class": None, + ATTR_UNIT_OF_MEASUREMENT: "count", + ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, ], }, @@ -228,8 +240,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/1D.111111111111/counter.A", "injected_value": b" 251123", "result": "251123", - "unit": "count", - "class": None, + ATTR_UNIT_OF_MEASUREMENT: "count", + ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, { "entity_id": "sensor.1d_111111111111_counter_b", @@ -237,8 +250,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/1D.111111111111/counter.B", "injected_value": b" 248125", "result": "248125", - "unit": "count", - "class": None, + ATTR_UNIT_OF_MEASUREMENT: "count", + ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, ], }, @@ -261,8 +275,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/22.111111111111/temperature", "injected_value": ProtocolError, "result": "unknown", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -282,98 +297,109 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/26.111111111111/temperature", "injected_value": b" 25.123", "result": "25.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_humidity", "unique_id": "/26.111111111111/humidity", "injected_value": b" 72.7563", "result": "72.8", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_humidity_hih3600", "unique_id": "/26.111111111111/HIH3600/humidity", "injected_value": b" 73.7563", "result": "73.8", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_humidity_hih4000", "unique_id": "/26.111111111111/HIH4000/humidity", "injected_value": b" 74.7563", "result": "74.8", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_humidity_hih5030", "unique_id": "/26.111111111111/HIH5030/humidity", "injected_value": b" 75.7563", "result": "75.8", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_humidity_htm1735", "unique_id": "/26.111111111111/HTM1735/humidity", "injected_value": ProtocolError, "result": "unknown", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_pressure", "unique_id": "/26.111111111111/B1-R1-A/pressure", "injected_value": b" 969.265", "result": "969.3", - "unit": PRESSURE_MBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_illuminance", "unique_id": "/26.111111111111/S3-R1-A/illuminance", "injected_value": b" 65.8839", "result": "65.9", - "unit": LIGHT_LUX, - "class": DEVICE_CLASS_ILLUMINANCE, + ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_voltage_vad", "unique_id": "/26.111111111111/VAD", "injected_value": b" 2.97", "result": "3.0", - "unit": ELECTRIC_POTENTIAL_VOLT, - "class": DEVICE_CLASS_VOLTAGE, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_voltage_vdd", "unique_id": "/26.111111111111/VDD", "injected_value": b" 4.74", "result": "4.7", - "unit": ELECTRIC_POTENTIAL_VOLT, - "class": DEVICE_CLASS_VOLTAGE, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_current", "unique_id": "/26.111111111111/IAD", "injected_value": b" 1", "result": "1.0", - "unit": ELECTRIC_CURRENT_AMPERE, - "class": DEVICE_CLASS_CURRENT, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -393,8 +419,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/28.111111111111/temperature", "injected_value": b" 26.984", "result": "27.0", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -414,8 +441,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.0", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -423,8 +450,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.1", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -432,8 +459,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.2", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -441,8 +468,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.3", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -450,8 +477,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.4", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -459,8 +486,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.5", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -468,8 +495,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.6", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -477,8 +504,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.7", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, ], @@ -488,8 +515,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.0", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -497,8 +524,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.1", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -506,8 +533,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.2", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -515,8 +542,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.3", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -524,8 +551,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.4", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -533,8 +560,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.5", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -542,8 +569,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.6", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -551,8 +578,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.7", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -560,8 +587,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.0", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -569,8 +596,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.1", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -578,8 +605,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.2", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -587,8 +614,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.3", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -596,8 +623,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.4", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -605,8 +632,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.5", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -614,8 +641,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.6", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -623,8 +650,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.7", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, ], @@ -645,8 +672,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/3B.111111111111/temperature", "injected_value": b" 28.243", "result": "28.2", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -666,8 +694,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/42.111111111111/temperature", "injected_value": b" 29.123", "result": "29.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -687,24 +716,27 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/EF.111111111111/humidity/humidity_corrected", "injected_value": b" 67.745", "result": "67.7", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.ef_111111111111_humidity_raw", "unique_id": "/EF.111111111111/humidity/humidity_raw", "injected_value": b" 65.541", "result": "65.5", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.ef_111111111111_temperature", "unique_id": "/EF.111111111111/humidity/temperature", "injected_value": b" 25.123", "result": "25.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -728,32 +760,36 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/EF.111111111112/moisture/sensor.0", "injected_value": b" 41.745", "result": "41.7", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.ef_111111111112_wetness_1", "unique_id": "/EF.111111111112/moisture/sensor.1", "injected_value": b" 42.541", "result": "42.5", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.ef_111111111112_moisture_2", "unique_id": "/EF.111111111112/moisture/sensor.2", "injected_value": b" 43.123", "result": "43.1", - "unit": PRESSURE_CBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_CBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.ef_111111111112_moisture_3", "unique_id": "/EF.111111111112/moisture/sensor.3", "injected_value": b" 44.123", "result": "44.1", - "unit": PRESSURE_CBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_CBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -774,32 +810,36 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/7E.111111111111/EDS0068/temperature", "injected_value": b" 13.9375", "result": "13.9", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.7e_111111111111_pressure", "unique_id": "/7E.111111111111/EDS0068/pressure", "injected_value": b" 1012.21", "result": "1012.2", - "unit": PRESSURE_MBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.7e_111111111111_illuminance", "unique_id": "/7E.111111111111/EDS0068/light", "injected_value": b" 65.8839", "result": "65.9", - "unit": LIGHT_LUX, - "class": DEVICE_CLASS_ILLUMINANCE, + ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.7e_111111111111_humidity", "unique_id": "/7E.111111111111/EDS0068/humidity", "injected_value": b" 41.375", "result": "41.4", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -820,16 +860,18 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/7E.222222222222/EDS0066/temperature", "injected_value": b" 13.9375", "result": "13.9", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.7e_222222222222_pressure", "unique_id": "/7E.222222222222/EDS0066/pressure", "injected_value": b" 1012.21", "result": "1012.2", - "unit": PRESSURE_MBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -850,8 +892,9 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/10-111111111111/w1_slave", "injected_value": 25.123, "result": "25.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -870,8 +913,9 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/22-111111111111/w1_slave", "injected_value": FileNotFoundError, "result": "unknown", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -889,8 +933,9 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/28-111111111111/w1_slave", "injected_value": InvalidCRCException, "result": "unknown", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -908,8 +953,9 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/3B-111111111111/w1_slave", "injected_value": 29.993, "result": "30.0", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -926,8 +972,9 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/42-111111111111/w1_slave", "injected_value": UnsupportResponseException, "result": "unknown", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -944,8 +991,9 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/42-111111111112/w1_slave", "injected_value": [UnsupportResponseException] * 9 + [27.993], "result": "28.0", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -962,8 +1010,9 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/42-111111111113/w1_slave", "injected_value": [UnsupportResponseException] * 10 + [27.993], "result": "unknown", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 91ae472278a..c82bc88c3a6 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -38,7 +38,7 @@ async def test_owserver_binary_sensor(owproxy, hass, device_id): # Force enable binary sensors patch_device_binary_sensors = copy.deepcopy(DEVICE_BINARY_SENSORS) for item in patch_device_binary_sensors[device_id[0:2]]: - item["default_disabled"] = False + item.entity_registry_enabled_default = True with patch( "homeassistant.components.onewire.PLATFORMS", [BINARY_SENSOR_DOMAIN] diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index 77570f055b4..eacaa148b45 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -9,8 +9,14 @@ from homeassistant.components.onewire.const import ( DOMAIN, PLATFORMS, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME +from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, +) from homeassistant.setup import async_setup_component from . import ( @@ -116,14 +122,11 @@ async def test_sensors_on_owserver_coupler(owproxy, hass, device_id): registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_sensor["unique_id"] - assert registry_entry.unit_of_measurement == expected_sensor["unit"] - assert registry_entry.device_class == expected_sensor["class"] assert registry_entry.disabled == expected_sensor.get("disabled", False) state = hass.states.get(entity_id) - if registry_entry.disabled: - assert state is None - else: - assert state.state == expected_sensor["result"] + assert state.state == expected_sensor["result"] + for attr in (ATTR_DEVICE_CLASS, ATTR_STATE_CLASS, ATTR_UNIT_OF_MEASUREMENT): + assert state.attributes.get(attr) == expected_sensor[attr] assert state.attributes["device_file"] == expected_sensor["device_file"] @@ -165,14 +168,14 @@ async def test_owserver_setup_valid_device(owproxy, hass, device_id, platform): registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_entity["unique_id"] - assert registry_entry.unit_of_measurement == expected_entity["unit"] - assert registry_entry.device_class == expected_entity["class"] assert registry_entry.disabled == expected_entity.get("disabled", False) state = hass.states.get(entity_id) if registry_entry.disabled: assert state is None else: assert state.state == expected_entity["result"] + for attr in (ATTR_DEVICE_CLASS, ATTR_STATE_CLASS, ATTR_UNIT_OF_MEASUREMENT): + assert state.attributes.get(attr) == expected_entity[attr] assert state.attributes["device_file"] == expected_entity.get( "device_file", registry_entry.unique_id ) @@ -216,7 +219,7 @@ async def test_onewiredirect_setup_valid_device(hass, device_id): registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_sensor["unique_id"] - assert registry_entry.unit_of_measurement == expected_sensor["unit"] - assert registry_entry.device_class == expected_sensor["class"] state = hass.states.get(entity_id) assert state.state == expected_sensor["result"] + for attr in (ATTR_DEVICE_CLASS, ATTR_STATE_CLASS, ATTR_UNIT_OF_MEASUREMENT): + assert state.attributes.get(attr) == expected_sensor[attr] diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 91a9e32e902..bfc4550cdc7 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -39,7 +39,7 @@ async def test_owserver_switch(owproxy, hass, device_id): # Force enable switches patch_device_switches = copy.deepcopy(DEVICE_SWITCHES) for item in patch_device_switches[device_id[0:2]]: - item["default_disabled"] = False + item.entity_registry_enabled_default = True with patch( "homeassistant.components.onewire.PLATFORMS", [SWITCH_DOMAIN] From dc851b9dd50127ae2cc8559c4313f161838151be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Aug 2021 03:44:12 -0500 Subject: [PATCH 703/903] Ensure camera scaling always produces an image of at least the requested width and height (#55033) --- homeassistant/components/camera/img_util.py | 39 ++++++++++++----- tests/components/camera/test_img_util.py | 46 +++++++++++++++++++++ 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index 279bc57672a..3aadc5c454c 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -1,4 +1,5 @@ """Image processing for cameras.""" +from __future__ import annotations import logging from typing import TYPE_CHECKING, cast @@ -15,7 +16,28 @@ if TYPE_CHECKING: from . import Image -def scale_jpeg_camera_image(cam_image: "Image", width: int, height: int) -> bytes: +def find_supported_scaling_factor( + current_width: int, current_height: int, target_width: int, target_height: int +) -> tuple[int, int] | None: + """Find a supported scaling factor to scale the image. + + If there is no exact match, we use one size up to ensure + the image remains crisp. + """ + for idx, supported_sf in enumerate(SUPPORTED_SCALING_FACTORS): + ratio = supported_sf[0] / supported_sf[1] + width_after_scale = current_width * ratio + height_after_scale = current_height * ratio + if width_after_scale == target_width and height_after_scale == target_height: + return supported_sf + if width_after_scale < target_width or height_after_scale < target_height: + return None if idx == 0 else SUPPORTED_SCALING_FACTORS[idx - 1] + + # Giant image, the most we can reduce by is 1/8 + return SUPPORTED_SCALING_FACTORS[-1] + + +def scale_jpeg_camera_image(cam_image: Image, width: int, height: int) -> bytes: """Scale a camera image as close as possible to one of the supported scaling factors.""" turbo_jpeg = TurboJPEGSingleton.instance() if not turbo_jpeg: @@ -28,17 +50,12 @@ def scale_jpeg_camera_image(cam_image: "Image", width: int, height: int) -> byte except OSError: return cam_image.content - if current_width <= width or current_height <= height: + scaling_factor = find_supported_scaling_factor( + current_width, current_height, width, height + ) + if scaling_factor is None: return cam_image.content - ratio = width / current_width - - scaling_factor = SUPPORTED_SCALING_FACTORS[-1] - for supported_sf in SUPPORTED_SCALING_FACTORS: - if ratio >= (supported_sf[0] / supported_sf[1]): - scaling_factor = supported_sf - break - return cast( bytes, turbo_jpeg.scale_with_quality( @@ -61,7 +78,7 @@ class TurboJPEGSingleton: __instance = None @staticmethod - def instance() -> "TurboJPEG": + def instance() -> TurboJPEG: """Singleton for TurboJPEG.""" if TurboJPEGSingleton.__instance is None: TurboJPEGSingleton() diff --git a/tests/components/camera/test_img_util.py b/tests/components/camera/test_img_util.py index 4f32715800e..35670b8f8d6 100644 --- a/tests/components/camera/test_img_util.py +++ b/tests/components/camera/test_img_util.py @@ -1,11 +1,13 @@ """Test img_util module.""" from unittest.mock import patch +import pytest from turbojpeg import TurboJPEG from homeassistant.components.camera import Image from homeassistant.components.camera.img_util import ( TurboJPEGSingleton, + find_supported_scaling_factor, scale_jpeg_camera_image, ) @@ -59,6 +61,15 @@ def test_scale_jpeg_camera_image(): assert jpeg_bytes == EMPTY_8_6_JPEG + turbo_jpeg = mock_turbo_jpeg( + first_width=640, first_height=480, second_width=640, second_height=480 + ) + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): + TurboJPEGSingleton() + jpeg_bytes = scale_jpeg_camera_image(camera_image, 320, 480) + + assert jpeg_bytes == EMPTY_16_12_JPEG + def test_turbojpeg_load_failure(): """Handle libjpegturbo not being installed.""" @@ -70,3 +81,38 @@ def test_turbojpeg_load_failure(): _clear_turbojpeg_singleton() TurboJPEGSingleton() assert TurboJPEGSingleton.instance() is not None + + +SCALE_TEST_EXPECTED = [ + (5782, 3946, 640, 480, (1, 8)), # Maximum scale + (1600, 1200, 640, 480, (1, 2)), # Equal scale for width and height + (1600, 1200, 1400, 1050, (7, 8)), # Equal scale for width and height + (1600, 1200, 1200, 900, (3, 4)), # Equal scale for width and height + (1600, 1200, 1000, 750, (5, 8)), # Equal scale for width and height + (1600, 1200, 600, 450, (3, 8)), # Equal scale for width and height + (1600, 1200, 400, 300, (1, 4)), # Equal scale for width and height + (1600, 1200, 401, 300, (3, 8)), # Width is just a little to big, next size up + (640, 480, 330, 200, (5, 8)), # Preserve width clarity + (640, 480, 300, 260, (5, 8)), # Preserve height clarity + (640, 480, 1200, 480, None), # Request larger width - no scaling + (640, 480, 640, 480, None), # Request same - no scaling + (640, 480, 640, 270, None), # Request smaller height - no scaling + (640, 480, 320, 480, None), # Request smaller width - no scaling +] + + +@pytest.mark.parametrize( + "image_width, image_height, input_width, input_height, scaling_factor", + SCALE_TEST_EXPECTED, +) +def test_find_supported_scaling_factor( + image_width, image_height, input_width, input_height, scaling_factor +): + """Test we always get an image of at least the size we ask if its big enough.""" + + assert ( + find_supported_scaling_factor( + image_width, image_height, input_width, input_height + ) + == scaling_factor + ) From d3f17de072ea4edd2171c3dc6c723ff5d68f4562 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Tue, 24 Aug 2021 10:55:40 +0200 Subject: [PATCH 704/903] Change Solarlog Watt-peak to Watt (#55110) --- homeassistant/components/solarlog/const.py | 8 +++++--- homeassistant/components/solarlog/sensor.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index a339f5c873d..e4e10b3a7e6 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -167,9 +167,10 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( SolarLogSensorEntityDescription( key="total_power", json_key="totalPOWER", - name="total power", + name="installed peak power", icon="mdi:solar-power", - native_unit_of_measurement="Wp", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, ), SolarLogSensorEntityDescription( key="alternator_loss", @@ -185,7 +186,8 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( json_key="CAPACITY", name="capacity", icon="mdi:solar-power", - native_unit_of_measurement="W/Wp", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_POWER_FACTOR, state_class=STATE_CLASS_MEASUREMENT, ), SolarLogSensorEntityDescription( diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index 5df86d64997..e87977f64e5 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -97,7 +97,7 @@ class SolarlogData: self.data["consumptionTOTAL"] = self.api.consumption_total / 1000 self.data["totalPOWER"] = self.api.total_power self.data["alternatorLOSS"] = self.api.alternator_loss - self.data["CAPACITY"] = round(self.api.capacity, 3) + self.data["CAPACITY"] = round(self.api.capacity * 100, 0) self.data["EFFICIENCY"] = round(self.api.efficiency * 100, 0) self.data["powerAVAILABLE"] = self.api.power_available self.data["USAGE"] = round(self.api.usage * 100, 0) From 96056f3fce9908f1936ab165089d76607b1400f5 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 24 Aug 2021 11:08:15 +0200 Subject: [PATCH 705/903] Fix bug removing API key on Forecast Solar (#55119) --- homeassistant/components/forecast_solar/config_flow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 256534da67a..e7f41777062 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -96,7 +96,11 @@ class ForecastSolarOptionFlowHandler(OptionsFlow): { vol.Optional( CONF_API_KEY, - default=self.config_entry.options.get(CONF_API_KEY, ""), + description={ + "suggested_value": self.config_entry.options.get( + CONF_API_KEY + ) + }, ): str, vol.Required( CONF_DECLINATION, From 0624859bf4218738012695bfd34ec63d2d8fb330 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Aug 2021 11:18:59 +0200 Subject: [PATCH 706/903] Set statistics columns to double precision (#55053) --- .../components/recorder/migration.py | 17 +++++++++++++++- homeassistant/components/recorder/models.py | 20 ++++++++++++------- homeassistant/components/sensor/recorder.py | 11 ++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 211e1646cca..4a5c456df28 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -351,7 +351,7 @@ def _drop_foreign_key_constraints(connection, engine, table, columns): ) -def _apply_update(engine, session, new_version, old_version): +def _apply_update(engine, session, new_version, old_version): # noqa: C901 """Perform operations to bring schema up to date.""" connection = session.connection() if new_version == 1: @@ -486,6 +486,21 @@ def _apply_update(engine, session, new_version, old_version): start = now.replace(minute=0, second=0, microsecond=0) start = start - timedelta(hours=1) session.add(StatisticsRuns(start=start)) + elif new_version == 20: + # This changed the precision of statistics from float to double + if engine.dialect.name in ["mysql", "oracle", "postgresql"]: + _modify_columns( + connection, + engine, + "statistics", + [ + "mean DOUBLE PRECISION", + "min DOUBLE PRECISION", + "max DOUBLE PRECISION", + "state DOUBLE PRECISION", + "sum DOUBLE PRECISION", + ], + ) else: raise ValueError(f"No schema migration defined for version {new_version}") diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 1c56e9c8f79..6c532e92292 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -19,7 +19,7 @@ from sqlalchemy import ( Text, distinct, ) -from sqlalchemy.dialects import mysql +from sqlalchemy.dialects import mysql, oracle, postgresql from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session @@ -39,7 +39,7 @@ import homeassistant.util.dt as dt_util # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 19 +SCHEMA_VERSION = 20 _LOGGER = logging.getLogger(__name__) @@ -66,6 +66,12 @@ ALL_TABLES = [ DATETIME_TYPE = DateTime(timezone=True).with_variant( mysql.DATETIME(timezone=True, fsp=6), "mysql" ) +DOUBLE_TYPE = ( + Float() + .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") + .with_variant(oracle.DOUBLE_PRECISION(), "oracle") + .with_variant(postgresql.DOUBLE_PRECISION, "postgresql") +) class Events(Base): # type: ignore @@ -240,11 +246,11 @@ class Statistics(Base): # type: ignore index=True, ) start = Column(DATETIME_TYPE, index=True) - mean = Column(Float()) - min = Column(Float()) - max = Column(Float()) - state = Column(Float()) - sum = Column(Float()) + mean = Column(DOUBLE_TYPE) + min = Column(DOUBLE_TYPE) + max = Column(DOUBLE_TYPE) + state = Column(DOUBLE_TYPE) + sum = Column(DOUBLE_TYPE) @staticmethod def from_stats(metadata_id: str, start: datetime, stats: StatisticData): diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 2cbca09c09d..8722fef0e99 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -293,10 +293,21 @@ def compile_statistics( reset = False if old_state is None: reset = True + _LOGGER.info( + "Compiling initial sum statistics for %s, zero point set to %s", + entity_id, + fstate, + ) elif state_class == STATE_CLASS_TOTAL_INCREASING and ( old_state is None or (new_state is not None and fstate < new_state) ): reset = True + _LOGGER.info( + "Detected new cycle for %s, zero point set to %s (old zero point %s)", + entity_id, + fstate, + new_state, + ) if reset: # The sensor has been reset, update the sum From 6cace8d8a1b93c23f9ef805b81129a1ecd2c6132 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 24 Aug 2021 11:20:45 +0200 Subject: [PATCH 707/903] Update base image for Alpine 3.14 (#55137) --- build.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.json b/build.json index 006d182c99d..bdb59943d72 100644 --- a/build.json +++ b/build.json @@ -2,11 +2,11 @@ "image": "homeassistant/{arch}-homeassistant", "shadow_repository": "ghcr.io/home-assistant", "build_from": { - "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.07.0", - "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.07.0", - "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.07.0", - "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.07.0", - "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.07.0" + "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.08.0", + "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.08.0", + "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.08.0", + "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.08.0", + "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.08.0" }, "labels": { "io.hass.type": "core", From 5bb9aa8f3db04d6d65ab303c6250dd3c335a10b2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 24 Aug 2021 11:21:35 +0200 Subject: [PATCH 708/903] Remove MQTT Fan legacy speeds (#54768) * Remove MQTT Fan legacy speeds * deprecated attibutes are not disruptive --- homeassistant/components/mqtt/fan.py | 182 +-------- tests/components/mqtt/test_fan.py | 551 +-------------------------- 2 files changed, 33 insertions(+), 700 deletions(-) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 552ee8da6d6..ee328c1eda5 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -10,7 +10,6 @@ from homeassistant.components.fan import ( ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE, - ATTR_SPEED, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, @@ -19,7 +18,6 @@ from homeassistant.components.fan import ( SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, - speed_list_without_preset_modes, ) from homeassistant.const import ( CONF_NAME, @@ -34,8 +32,6 @@ from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( int_states_in_range, - ordered_list_item_to_percentage, - percentage_to_ordered_list_item, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -102,23 +98,12 @@ MQTT_FAN_ATTRIBUTES_BLOCKED = frozenset( fan.ATTR_PERCENTAGE, fan.ATTR_PRESET_MODE, fan.ATTR_PRESET_MODES, - fan.ATTR_SPEED_LIST, - fan.ATTR_SPEED, } ) _LOGGER = logging.getLogger(__name__) -def valid_fan_speed_configuration(config): - """Validate that the fan speed configuration is valid, throws if it isn't.""" - if config.get(CONF_SPEED_COMMAND_TOPIC) and not speed_list_without_preset_modes( - config.get(CONF_SPEED_LIST) - ): - raise ValueError("No valid speeds configured") - return config - - def valid_speed_range_configuration(config): """Validate that the fan speed_range configuration is valid, throws if it isn't.""" if config.get(CONF_SPEED_RANGE_MIN) == 0: @@ -138,7 +123,7 @@ def valid_preset_mode_configuration(config): PLATFORM_SCHEMA = vol.All( # CONF_SPEED_COMMAND_TOPIC, CONF_SPEED_LIST, CONF_SPEED_STATE_TOPIC, CONF_SPEED_VALUE_TEMPLATE and # Speeds SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH SPEED_OFF, - # are deprecated, support will be removed after a quarter (2021.7) + # are deprecated, support will be removed with release 2021.9 cv.deprecated(CONF_PAYLOAD_HIGH_SPEED), cv.deprecated(CONF_PAYLOAD_LOW_SPEED), cv.deprecated(CONF_PAYLOAD_MEDIUM_SPEED), @@ -203,7 +188,6 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), - valid_fan_speed_configuration, valid_speed_range_configuration, valid_preset_mode_configuration, ) @@ -240,8 +224,6 @@ class MqttFan(MqttEntity, FanEntity): def __init__(self, hass, config, config_entry, discovery_data): """Initialize the MQTT fan.""" self._state = False - # self._speed will be removed after a quarter (2021.7) - self._speed = None self._percentage = None self._preset_mode = None self._oscillation = None @@ -255,10 +237,6 @@ class MqttFan(MqttEntity, FanEntity): self._optimistic_oscillation = None self._optimistic_percentage = None self._optimistic_preset_mode = None - self._optimistic_speed = None - - self._legacy_speeds_list = [] - self._legacy_speeds_list_no_off = [] MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @@ -282,8 +260,6 @@ class MqttFan(MqttEntity, FanEntity): CONF_PERCENTAGE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, CONF_PRESET_MODE_COMMAND_TOPIC, - CONF_SPEED_STATE_TOPIC, - CONF_SPEED_COMMAND_TOPIC, CONF_OSCILLATION_STATE_TOPIC, CONF_OSCILLATION_COMMAND_TOPIC, ) @@ -292,8 +268,6 @@ class MqttFan(MqttEntity, FanEntity): CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_VALUE_TEMPLATE), ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_VALUE_TEMPLATE), - # ATTR_SPEED is deprecated in the schema, support will be removed after a quarter (2021.7) - ATTR_SPEED: config.get(CONF_SPEED_VALUE_TEMPLATE), ATTR_OSCILLATING: config.get(CONF_OSCILLATION_VALUE_TEMPLATE), } self._command_templates = { @@ -307,21 +281,9 @@ class MqttFan(MqttEntity, FanEntity): "STATE_OFF": config[CONF_PAYLOAD_OFF], "OSCILLATE_ON_PAYLOAD": config[CONF_PAYLOAD_OSCILLATION_ON], "OSCILLATE_OFF_PAYLOAD": config[CONF_PAYLOAD_OSCILLATION_OFF], - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - "SPEED_LOW": config[CONF_PAYLOAD_LOW_SPEED], - "SPEED_MEDIUM": config[CONF_PAYLOAD_MEDIUM_SPEED], - "SPEED_HIGH": config[CONF_PAYLOAD_HIGH_SPEED], - "SPEED_OFF": config[CONF_PAYLOAD_OFF_SPEED], "PERCENTAGE_RESET": config[CONF_PAYLOAD_RESET_PERCENTAGE], "PRESET_MODE_RESET": config[CONF_PAYLOAD_RESET_PRESET_MODE], } - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - self._feature_legacy_speeds = not self._topic[CONF_SPEED_COMMAND_TOPIC] is None - if self._feature_legacy_speeds: - self._legacy_speeds_list = config[CONF_SPEED_LIST] - self._legacy_speeds_list_no_off = speed_list_without_preset_modes( - self._legacy_speeds_list - ) self._feature_percentage = CONF_PERCENTAGE_COMMAND_TOPIC in config self._feature_preset_mode = CONF_PRESET_MODE_COMMAND_TOPIC in config @@ -330,10 +292,11 @@ class MqttFan(MqttEntity, FanEntity): else: self._preset_modes = [] - if self._feature_percentage: - self._speed_count = min(int_states_in_range(self._speed_range), 100) - else: - self._speed_count = len(self._legacy_speeds_list_no_off) or 100 + self._speed_count = ( + min(int_states_in_range(self._speed_range), 100) + if self._feature_percentage + else 100 + ) optimistic = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None @@ -346,16 +309,13 @@ class MqttFan(MqttEntity, FanEntity): self._optimistic_preset_mode = ( optimistic or self._topic[CONF_PRESET_MODE_STATE_TOPIC] is None ) - self._optimistic_speed = ( - optimistic or self._topic[CONF_SPEED_STATE_TOPIC] is None - ) self._supported_features = 0 self._supported_features |= ( self._topic[CONF_OSCILLATION_COMMAND_TOPIC] is not None and SUPPORT_OSCILLATE ) - if self._feature_percentage or self._feature_legacy_speeds: + if self._feature_percentage: self._supported_features |= SUPPORT_SET_SPEED if self._feature_preset_mode: self._supported_features |= SUPPORT_PRESET_MODE @@ -368,7 +328,7 @@ class MqttFan(MqttEntity, FanEntity): tpl.hass = self.hass tpl_dict[key] = tpl.async_render_with_possible_json_value - async def _subscribe_topics(self): # noqa: C901 + async def _subscribe_topics(self): """(Re)Subscribe to topics.""" topics = {} @@ -405,7 +365,6 @@ class MqttFan(MqttEntity, FanEntity): return if rendered_percentage_payload == self._payload["PERCENTAGE_RESET"]: self._percentage = None - self._speed = None self.async_write_ha_state() return try: @@ -471,51 +430,6 @@ class MqttFan(MqttEntity, FanEntity): } self._preset_mode = None - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - @callback - @log_messages(self.hass, self.entity_id) - def speed_received(msg): - """Handle new received MQTT message for the speed.""" - speed_payload = self._value_templates[ATTR_SPEED](msg.payload) - if speed_payload == self._payload["SPEED_LOW"]: - speed = SPEED_LOW - elif speed_payload == self._payload["SPEED_MEDIUM"]: - speed = SPEED_MEDIUM - elif speed_payload == self._payload["SPEED_HIGH"]: - speed = SPEED_HIGH - elif speed_payload == self._payload["SPEED_OFF"]: - speed = SPEED_OFF - else: - speed = None - - if speed and speed in self._legacy_speeds_list: - self._speed = speed - else: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid speed", - msg.payload, - msg.topic, - speed, - ) - return - - if speed in self._legacy_speeds_list_no_off: - self._percentage = ordered_list_item_to_percentage( - self._legacy_speeds_list_no_off, speed - ) - elif speed == SPEED_OFF: - self._percentage = 0 - - self.async_write_ha_state() - - if self._topic[CONF_SPEED_STATE_TOPIC] is not None: - topics[CONF_SPEED_STATE_TOPIC] = { - "topic": self._topic[CONF_SPEED_STATE_TOPIC], - "msg_callback": speed_received, - "qos": self._config[CONF_QOS], - } - self._speed = SPEED_OFF - @callback @log_messages(self.hass, self.entity_id) def oscillation_received(msg): @@ -552,12 +466,6 @@ class MqttFan(MqttEntity, FanEntity): """Return true if device is on.""" return self._state - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - @property - def _implemented_speed(self) -> bool: - """Return true if speed has been implemented.""" - return self._feature_legacy_speeds - @property def percentage(self): """Return the current percentage.""" @@ -573,22 +481,11 @@ class MqttFan(MqttEntity, FanEntity): """Get the list of available preset modes.""" return self._preset_modes - # The speed_list property is deprecated in the schema, support will be removed after a quarter (2021.7) - @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return self._legacy_speeds_list_no_off - @property def supported_features(self) -> int: """Flag supported features.""" return self._supported_features - @property - def speed(self): - """Return the current speed.""" - return self._speed - @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" @@ -623,9 +520,6 @@ class MqttFan(MqttEntity, FanEntity): await self.async_set_percentage(percentage) if preset_mode: await self.async_set_preset_mode(preset_mode) - # The speed attribute deprecated in the schema, support will be removed after a quarter (2021.7) - if speed and not percentage and not preset_mode: - await self.async_set_speed(speed) if self._optimistic: self._state = True self.async_write_ha_state() @@ -656,26 +550,13 @@ class MqttFan(MqttEntity, FanEntity): percentage_to_ranged_value(self._speed_range, percentage) ) mqtt_payload = self._command_templates[ATTR_PERCENTAGE](percentage_payload) - # Legacy are deprecated in the schema, support will be removed after a quarter (2021.7) - if self._feature_legacy_speeds: - if percentage: - await self.async_set_speed( - percentage_to_ordered_list_item( - self._legacy_speeds_list_no_off, - percentage, - ) - ) - elif SPEED_OFF in self._legacy_speeds_list: - await self.async_set_speed(SPEED_OFF) - - if self._feature_percentage: - mqtt.async_publish( - self.hass, - self._topic[CONF_PERCENTAGE_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - ) + mqtt.async_publish( + self.hass, + self._topic[CONF_PERCENTAGE_COMMAND_TOPIC], + mqtt_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) if self._optimistic_percentage: self._percentage = percentage @@ -704,39 +585,6 @@ class MqttFan(MqttEntity, FanEntity): self._preset_mode = preset_mode self.async_write_ha_state() - # async_set_speed is deprecated, support will be removed after a quarter (2021.7) - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan. - - This method is a coroutine. - """ - speed_payload = None - if speed in self._legacy_speeds_list: - if speed == SPEED_LOW: - speed_payload = self._payload["SPEED_LOW"] - elif speed == SPEED_MEDIUM: - speed_payload = self._payload["SPEED_MEDIUM"] - elif speed == SPEED_HIGH: - speed_payload = self._payload["SPEED_HIGH"] - else: - speed_payload = self._payload["SPEED_OFF"] - else: - _LOGGER.warning("'%s' is not a valid speed", speed) - return - - if speed_payload: - mqtt.async_publish( - self.hass, - self._topic[CONF_SPEED_COMMAND_TOPIC], - speed_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - ) - - if self._optimistic_speed and speed_payload: - self._speed = speed - self.async_write_ha_state() - async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation. diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 438ae0978c6..0501927d003 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -77,9 +77,6 @@ async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): "payload_on": "StAtE_On", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", "payload_oscillation_off": "OsC_OfF", "payload_oscillation_on": "OsC_On", "percentage_state_topic": "percentage-state-topic", @@ -96,12 +93,6 @@ async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): ], "speed_range_min": 1, "speed_range_max": 200, - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speeds": ["off", "low"], - "payload_off_speed": "speed_OfF", - "payload_low_speed": "speed_lOw", - "payload_medium_speed": "speed_mEdium", - "payload_high_speed": "speed_High", "payload_reset_percentage": "rEset_percentage", "payload_reset_preset_mode": "rEset_preset_mode", } @@ -180,34 +171,11 @@ async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): assert "not a valid preset mode" in caplog.text caplog.clear() - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get("speed") == fan.SPEED_OFF - - async_fire_mqtt_message(hass, "speed-state-topic", "speed_lOw") - state = hass.states.get("fan.test") - assert state.attributes.get("speed") == fan.SPEED_LOW - - async_fire_mqtt_message(hass, "speed-state-topic", "speed_mEdium") - assert "not a valid speed" in caplog.text - caplog.clear() - - async_fire_mqtt_message(hass, "speed-state-topic", "speed_High") - assert "not a valid speed" in caplog.text - caplog.clear() - - async_fire_mqtt_message(hass, "speed-state-topic", "speed_OfF") - state = hass.states.get("fan.test") - assert state.attributes.get("speed") == fan.SPEED_OFF - async_fire_mqtt_message(hass, "percentage-state-topic", "rEset_percentage") state = hass.states.get("fan.test") assert state.attributes.get(fan.ATTR_PERCENTAGE) is None assert state.attributes.get(fan.ATTR_SPEED) is None - async_fire_mqtt_message(hass, "speed-state-topic", "speed_very_high") - assert "not a valid speed" in caplog.text - caplog.clear() - async def test_controlling_state_via_topic_with_different_speed_range( hass, mqtt_mock, caplog @@ -284,9 +252,6 @@ async def test_controlling_state_via_topic_no_percentage_topics( "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", "preset_mode_state_topic": "preset-mode-state-topic", "preset_mode_command_topic": "preset-mode-command-topic", "preset_modes": [ @@ -296,8 +261,6 @@ async def test_controlling_state_via_topic_no_percentage_topics( "eco", "breeze", ], - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speeds": ["off", "low", "medium"], } }, ) @@ -311,22 +274,16 @@ async def test_controlling_state_via_topic_no_percentage_topics( state = hass.states.get("fan.test") assert state.attributes.get("preset_mode") == "smart" assert state.attributes.get(fan.ATTR_PERCENTAGE) is None - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get("speed") == fan.SPEED_OFF async_fire_mqtt_message(hass, "preset-mode-state-topic", "auto") state = hass.states.get("fan.test") assert state.attributes.get("preset_mode") == "auto" assert state.attributes.get(fan.ATTR_PERCENTAGE) is None - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get("speed") == fan.SPEED_OFF async_fire_mqtt_message(hass, "preset-mode-state-topic", "whoosh") state = hass.states.get("fan.test") assert state.attributes.get("preset_mode") == "whoosh" assert state.attributes.get(fan.ATTR_PERCENTAGE) is None - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get("speed") == fan.SPEED_OFF async_fire_mqtt_message(hass, "preset-mode-state-topic", "medium") assert "not a valid preset mode" in caplog.text @@ -336,25 +293,6 @@ async def test_controlling_state_via_topic_no_percentage_topics( assert "not a valid preset mode" in caplog.text caplog.clear() - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - async_fire_mqtt_message(hass, "speed-state-topic", "medium") - state = hass.states.get("fan.test") - assert state.attributes.get("preset_mode") == "whoosh" - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 - assert state.attributes.get("speed") == fan.SPEED_MEDIUM - - async_fire_mqtt_message(hass, "speed-state-topic", "low") - state = hass.states.get("fan.test") - assert state.attributes.get("preset_mode") == "whoosh" - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 50 - assert state.attributes.get("speed") == fan.SPEED_LOW - - async_fire_mqtt_message(hass, "speed-state-topic", "off") - state = hass.states.get("fan.test") - assert state.attributes.get("preset_mode") == "whoosh" - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - assert state.attributes.get("speed") == fan.SPEED_OFF - async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, caplog): """Test the controlling state via topic and JSON message (percentage mode).""" @@ -558,22 +496,13 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): "oscillation_command_topic": "oscillation-command-topic", "payload_oscillation_off": "OsC_OfF", "payload_oscillation_on": "OsC_On", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", "percentage_command_topic": "percentage-command-topic", "preset_mode_command_topic": "preset-mode-command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speeds": ["off", "low", "medium"], "preset_modes": [ "whoosh", "breeze", "silent", ], - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "payload_off_speed": "speed_OfF", - "payload_low_speed": "speed_lOw", - "payload_medium_speed": "speed_mEdium", - "payload_high_speed": "speed_High", } }, ) @@ -625,10 +554,8 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): await common.async_set_percentage(hass, "fan.test", 101) await common.async_set_percentage(hass, "fan.test", 100) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "100", 0, False) - mqtt_mock.async_publish.assert_any_call( - "speed-command-topic", "speed_mEdium", 0, False + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "100", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") @@ -636,29 +563,18 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_percentage(hass, "fan.test", 0) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "0", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call( - "speed-command-topic", "speed_OfF", 0, False + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "0", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get(fan.ATTR_SPEED) == fan.SPEED_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) await common.async_set_preset_mode(hass, "fan.test", "low") assert "not a valid preset mode" in caplog.text caplog.clear() - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_preset_mode(hass, "fan.test", "medium") - assert "not a valid preset mode" in caplog.text - caplog.clear() - await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( "preset-mode-command-topic", "whoosh", 0, False @@ -686,41 +602,6 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): assert state.attributes.get(fan.ATTR_PRESET_MODE) == "silent" assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "speed_lOw", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "speed_mEdium", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) - assert "not a valid speed" in caplog.text - caplog.clear() - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "speed_OfF", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - async def test_sending_mqtt_commands_with_alternate_speed_range(hass, mqtt_mock): """Test the controlling state via topic using an alternate speed range.""" @@ -888,7 +769,6 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy(hass, mqtt_mock, c mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_preset_mode(hass, "fan.test", "low") @@ -1028,7 +908,6 @@ async def test_sending_mqtt_command_templates_(hass, mqtt_mock, caplog): mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_preset_mode(hass, "fan.test", "low") @@ -1111,13 +990,8 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( "platform": "mqtt", "name": "test", "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", "preset_mode_command_topic": "preset-mode-command-topic", "preset_mode_state_topic": "preset-mode-state-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speeds": ["off", "low", "medium"], "preset_modes": [ "whoosh", "breeze", @@ -1133,32 +1007,6 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(MultipleInvalid): - await common.async_set_percentage(hass, "fan.test", -1) - - with pytest.raises(MultipleInvalid): - await common.async_set_percentage(hass, "fan.test", 101) - - await common.async_set_percentage(hass, "fan.test", 100) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_percentage(hass, "fan.test", 0) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - assert state.attributes.get(ATTR_ASSUMED_STATE) - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_preset_mode(hass, "fan.test", "low") - assert "not a valid preset mode" in caplog.text - caplog.clear() - await common.async_set_preset_mode(hass, "fan.test", "medium") assert "not a valid preset mode" in caplog.text caplog.clear() @@ -1190,185 +1038,6 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.attributes.get(fan.ATTR_PRESET_MODE) is None assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "low", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "medium", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) - assert "not a valid speed" in caplog.text - caplog.clear() - await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) - - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_on(hass, "fan.test", speed="medium") - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_off(hass, "fan.test") - mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_turn_on(hass, "fan.test", speed="high") - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - mqtt_mock.async_publish.assert_any_call( - "preset-mode-command-topic", "high", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_off(hass, "fan.test") - mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - -# use of speeds is deprecated, support will be removed after a quarter (2021.7) -async def test_sending_mqtt_commands_and_optimistic_legacy_speeds_only( - hass, mqtt_mock, caplog -): - """Test optimistic mode without state topics with legacy speeds.""" - assert await async_setup_component( - hass, - fan.DOMAIN, - { - fan.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "command-topic", - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", - "speeds": ["off", "low", "medium", "high"], - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_percentage(hass, "fan.test", 100) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "high", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 - assert state.attributes.get(fan.ATTR_SPEED) == "off" - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_percentage(hass, "fan.test", 0) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_preset_mode(hass, "fan.test", "low") - assert "not a valid preset mode" in caplog.text - caplog.clear() - - await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "low", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "medium", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "high", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "off", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_on(hass, "fan.test", speed="medium") - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_off(hass, "fan.test") - mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_on(hass, "fan.test", speed="off") - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_off(hass, "fan.test") - mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, caplog): """Test optimistic mode with state topic and turn on attributes.""" @@ -1381,17 +1050,12 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", "percentage_state_topic": "percentage-state-topic", "percentage_command_topic": "percentage-command-topic", "preset_mode_command_topic": "preset-mode-command-topic", "preset_mode_state_topic": "preset-mode-state-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speeds": ["off", "low", "medium"], "preset_modes": [ "whoosh", "breeze", @@ -1421,30 +1085,10 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - await common.async_turn_on(hass, "fan.test", speed=fan.SPEED_MEDIUM) - assert mqtt_mock.async_publish.call_count == 3 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "100", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_off(hass, "fan.test") - mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - await common.async_turn_on(hass, "fan.test", percentage=25) - assert mqtt_mock.async_publish.call_count == 3 + assert mqtt_mock.async_publish.call_count == 2 mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "25", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_ON @@ -1457,7 +1101,6 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) with pytest.raises(NotValidPresetModeError): await common.async_turn_on(hass, "fan.test", preset_mode="auto") @@ -1525,11 +1168,9 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_turn_on(hass, "fan.test", percentage=50) - assert mqtt_mock.async_publish.call_count == 3 + assert mqtt_mock.async_publish.call_count == 2 mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "50", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_ON @@ -1552,40 +1193,36 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_percentage(hass, "fan.test", 33) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "33", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "33", 0, False + ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_percentage(hass, "fan.test", 50) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "50", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "50", 0, False + ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_percentage(hass, "fan.test", 100) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "100", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "100", 0, False + ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_percentage(hass, "fan.test", 0) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "0", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "0", 0, False + ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_OFF @@ -1629,33 +1266,6 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "medium", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) - assert "not a valid speed" in caplog.text - caplog.clear() - - await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "off", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", "cUsToM") - assert "not a valid speed" in caplog.text - caplog.clear() - async def test_attributes(hass, mqtt_mock, caplog): """Test attributes.""" @@ -1668,8 +1278,6 @@ async def test_attributes(hass, mqtt_mock, caplog): "name": "test", "command_topic": "command-topic", "oscillation_command_topic": "oscillation-command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", "preset_mode_command_topic": "preset-mode-command-topic", "percentage_command_topic": "percentage-command-topic", "preset_modes": [ @@ -1683,104 +1291,31 @@ async def test_attributes(hass, mqtt_mock, caplog): state = hass.states.get("fan.test") assert state.state == STATE_OFF - assert state.attributes.get(fan.ATTR_SPEED_LIST) == [ - "low", - "medium", - "high", - ] await common.async_turn_on(hass, "fan.test") state = hass.states.get("fan.test") assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is None await common.async_turn_off(hass, "fan.test") state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is None await common.async_oscillate(hass, "fan.test", True) state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is True await common.async_oscillate(hass, "fan.test", False) state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is False - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes.get(fan.ATTR_SPEED) == "low" - assert state.attributes.get(fan.ATTR_OSCILLATING) is False - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes.get(fan.ATTR_SPEED) == "medium" - assert state.attributes.get(fan.ATTR_OSCILLATING) is False - - await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes.get(fan.ATTR_SPEED) == "high" - assert state.attributes.get(fan.ATTR_OSCILLATING) is False - - await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes.get(fan.ATTR_SPEED) == "off" - assert state.attributes.get(fan.ATTR_OSCILLATING) is False - - await common.async_set_speed(hass, "fan.test", "cUsToM") - assert "not a valid speed" in caplog.text - caplog.clear() - - -# use of speeds is deprecated, support will be removed after a quarter (2021.7) -async def test_custom_speed_list(hass, mqtt_mock): - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - fan.DOMAIN, - { - fan.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - "oscillation_state_topic": "oscillation-state-topic", - "speed_command_topic": "speed-command-topic", - "speed_state_topic": "speed-state-topic", - "speeds": ["off", "high"], - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(fan.ATTR_SPEED_LIST) == ["high"] - async def test_supported_features(hass, mqtt_mock): """Test optimistic mode without state topic.""" @@ -1800,29 +1335,6 @@ async def test_supported_features(hass, mqtt_mock): "command_topic": "command-topic", "oscillation_command_topic": "oscillation-command-topic", }, - { - "platform": "mqtt", - "name": "test3a1", - "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", - }, - { - "platform": "mqtt", - "name": "test3a2", - "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", - "speeds": ["low"], - }, - { - "platform": "mqtt", - "name": "test3a3", - "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", - "speeds": ["off"], - }, { "platform": "mqtt", "name": "test3b", @@ -1849,14 +1361,6 @@ async def test_supported_features(hass, mqtt_mock): "preset_mode_command_topic": "preset-mode-command-topic", "preset_modes": ["eco", "smart", "auto"], }, - { - "platform": "mqtt", - "name": "test4", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", - }, { "platform": "mqtt", "name": "test4pcta", @@ -1941,19 +1445,6 @@ async def test_supported_features(hass, mqtt_mock): state = hass.states.get("fan.test2") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_OSCILLATE - state = hass.states.get("fan.test3a1") - assert ( - state.attributes.get(ATTR_SUPPORTED_FEATURES) - and fan.SUPPORT_SET_SPEED == fan.SUPPORT_SET_SPEED - ) - state = hass.states.get("fan.test3a2") - assert ( - state.attributes.get(ATTR_SUPPORTED_FEATURES) - and fan.SUPPORT_SET_SPEED == fan.SUPPORT_SET_SPEED - ) - state = hass.states.get("fan.test3a3") - assert state is None - state = hass.states.get("fan.test3b") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_SET_SPEED @@ -1965,12 +1456,6 @@ async def test_supported_features(hass, mqtt_mock): state = hass.states.get("fan.test3c3") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_PRESET_MODE - state = hass.states.get("fan.test4") - assert ( - state.attributes.get(ATTR_SUPPORTED_FEATURES) - == fan.SUPPORT_OSCILLATE | fan.SUPPORT_SET_SPEED - ) - state = hass.states.get("fan.test4pcta") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_SET_SPEED state = hass.states.get("fan.test4pctb") From 0ab99fc8bfdfb91d7f8274cfb9456a1594a3db82 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 24 Aug 2021 11:21:57 +0200 Subject: [PATCH 709/903] Activate mypy for surepetcare (#55079) --- .coveragerc | 1 + homeassistant/components/surepetcare/__init__.py | 2 +- homeassistant/components/surepetcare/binary_sensor.py | 8 ++++---- homeassistant/components/surepetcare/sensor.py | 7 +++++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.coveragerc b/.coveragerc index 810b4d92ce1..c98d56d7d5e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1002,6 +1002,7 @@ omit = homeassistant/components/suez_water/* homeassistant/components/supervisord/sensor.py homeassistant/components/surepetcare/__init__.py + homeassistant/components/surepetcare/binary_sensor.py homeassistant/components/surepetcare/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py homeassistant/components/swiss_public_transport/sensor.py diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 4ac27ce25a0..58890090d57 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -138,7 +138,7 @@ class SurePetcareAPI: """Initialize the Sure Petcare object.""" self.hass = hass self.surepy = surepy - self.states = {} + self.states: dict[int, Any] = {} async def async_update(self, _: Any = None) -> None: """Get the latest data from Sure Petcare.""" diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 8f2b77c3c7a..0f536d6135d 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -28,7 +28,7 @@ async def async_setup_platform( if discovery_info is None: return - entities: list[SurepyEntity] = [] + entities: list[SurepyEntity | Pet | Hub | DeviceConnectivity] = [] spc: SurePetcareAPI = hass.data[DOMAIN][SPC] @@ -112,7 +112,7 @@ class Hub(SurePetcareBinarySensor): ), } else: - self._attr_extra_state_attributes = None + self._attr_extra_state_attributes = {} _LOGGER.debug("%s -> state: %s", self.name, state) self.async_write_ha_state() @@ -139,7 +139,7 @@ class Pet(SurePetcareBinarySensor): "where": state.where, } else: - self._attr_extra_state_attributes = None + self._attr_extra_state_attributes = {} _LOGGER.debug("%s -> state: %s", self.name, state) self.async_write_ha_state() @@ -171,6 +171,6 @@ class DeviceConnectivity(SurePetcareBinarySensor): "hub_rssi": f'{state["signal"]["hub_rssi"]:.2f}', } else: - self._attr_extra_state_attributes = None + self._attr_extra_state_attributes = {} _LOGGER.debug("%s -> state: %s", self.name, state) self.async_write_ha_state() diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 922bfa84515..35d35e9be1f 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -59,7 +59,10 @@ class SureBattery(SensorEntity): surepy_entity: SurepyEntity = self._spc.states[_id] self._attr_device_class = DEVICE_CLASS_BATTERY - self._attr_name = f"{surepy_entity.type.name.capitalize()} {surepy_entity.name.capitalize()} Battery Level" + if surepy_entity.name: + self._attr_name = f"{surepy_entity.type.name.capitalize()} {surepy_entity.name.capitalize()} Battery Level" + else: + self._attr_name = f"{surepy_entity.type.name.capitalize()} Battery Level" self._attr_native_unit_of_measurement = PERCENTAGE self._attr_unique_id = ( f"{surepy_entity.household_id}-{surepy_entity.id}-battery" @@ -88,7 +91,7 @@ class SureBattery(SensorEntity): f"{ATTR_VOLTAGE}_per_battery": f"{voltage_per_battery:.2f}", } else: - self._attr_extra_state_attributes = None + self._attr_extra_state_attributes = {} self.async_write_ha_state() _LOGGER.debug("%s -> state: %s", self.name, state) diff --git a/mypy.ini b/mypy.ini index 0db7853177e..052e564a5f8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1601,9 +1601,6 @@ ignore_errors = true [mypy-homeassistant.components.stt.*] ignore_errors = true -[mypy-homeassistant.components.surepetcare.*] -ignore_errors = true - [mypy-homeassistant.components.switchbot.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index b8b80ce3afd..811c11692f8 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -126,7 +126,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.sonos.*", "homeassistant.components.spotify.*", "homeassistant.components.stt.*", - "homeassistant.components.surepetcare.*", "homeassistant.components.switchbot.*", "homeassistant.components.system_health.*", "homeassistant.components.system_log.*", From 336aa74317ca44d3bdadeabc126e45015aa89e5b Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 24 Aug 2021 11:23:33 +0200 Subject: [PATCH 710/903] Activate mypy for todoist (#55096) --- homeassistant/components/todoist/calendar.py | 13 ++++++++++--- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 86aeff7c554..51f4e859a1f 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -1,4 +1,6 @@ """Support for Todoist task management (https://todoist.com).""" +from __future__ import annotations + from datetime import datetime, timedelta import logging @@ -226,14 +228,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ) -def _parse_due_date(data: dict, gmt_string) -> datetime: +def _parse_due_date(data: dict, gmt_string) -> datetime | None: """Parse the due date dict into a datetime object.""" # Add time information to date only strings. if len(data["date"]) == 10: return datetime.fromisoformat(data["date"]).replace(tzinfo=dt.UTC) - if dt.parse_datetime(data["date"]).tzinfo is None: + nowtime = dt.parse_datetime(data["date"]) + if not nowtime: + return None + if nowtime.tzinfo is None: data["date"] += gmt_string - return dt.as_utc(dt.parse_datetime(data["date"])) + return dt.as_utc(nowtime) class TodoistProjectDevice(CalendarEventDevice): @@ -533,6 +538,8 @@ class TodoistProjectData: due_date = _parse_due_date( task["due"], self._api.state["user"]["tz_info"]["gmt_string"] ) + if not due_date: + continue midnight = dt.as_utc( dt.parse_datetime( due_date.strftime("%Y-%m-%d") diff --git a/mypy.ini b/mypy.ini index 052e564a5f8..e566b7f1898 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1622,9 +1622,6 @@ ignore_errors = true [mypy-homeassistant.components.tesla.*] ignore_errors = true -[mypy-homeassistant.components.todoist.*] -ignore_errors = true - [mypy-homeassistant.components.toon.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 811c11692f8..bb2cf72b72e 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -133,7 +133,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.telegram_bot.*", "homeassistant.components.template.*", "homeassistant.components.tesla.*", - "homeassistant.components.todoist.*", "homeassistant.components.toon.*", "homeassistant.components.tplink.*", "homeassistant.components.unifi.*", From e2ce1d8b240c59cfd1c0e4bc888cdef833a2400a Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 24 Aug 2021 11:28:45 +0200 Subject: [PATCH 711/903] Please mypy in gtfs and implement needed changes (#54328) --- homeassistant/components/gtfs/sensor.py | 10 +++++----- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index f8f89b1ea36..138fdd96b89 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -255,7 +255,7 @@ WHEELCHAIR_BOARDING_DEFAULT = STATE_UNKNOWN WHEELCHAIR_BOARDING_OPTIONS = {1: True, 2: False} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { # type: ignore + { vol.Required(CONF_ORIGIN): cv.string, vol.Required(CONF_DESTINATION): cv.string, vol.Required(CONF_DATA): cv.string, @@ -490,7 +490,7 @@ def setup_platform( origin = config.get(CONF_ORIGIN) destination = config.get(CONF_DESTINATION) name = config.get(CONF_NAME) - offset = config.get(CONF_OFFSET) + offset = datetime.timedelta(seconds=float(config.get(CONF_OFFSET, 0))) include_tomorrow = config[CONF_TOMORROW] if not os.path.exists(gtfs_dir): @@ -541,10 +541,10 @@ class GTFSDepartureSensor(SensorEntity): self._icon = ICON self._name = "" self._state: str | None = None - self._attributes = {} + self._attributes: dict[str, float | str] = {} self._agency = None - self._departure = {} + self._departure: dict[str, Any] = {} self._destination = None self._origin = None self._route = None @@ -559,7 +559,7 @@ class GTFSDepartureSensor(SensorEntity): return self._name @property - def native_value(self) -> str | None: # type: ignore + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._state diff --git a/mypy.ini b/mypy.ini index e566b7f1898..82ed7d6ae9d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1355,9 +1355,6 @@ ignore_errors = true [mypy-homeassistant.components.growatt_server.*] ignore_errors = true -[mypy-homeassistant.components.gtfs.*] -ignore_errors = true - [mypy-homeassistant.components.habitica.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index bb2cf72b72e..581b4865f7c 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -44,7 +44,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.gpmdp.*", "homeassistant.components.gree.*", "homeassistant.components.growatt_server.*", - "homeassistant.components.gtfs.*", "homeassistant.components.habitica.*", "homeassistant.components.harmony.*", "homeassistant.components.hassio.*", From 5aa6f9dbb2403eadb77daefc2cefdc45712fa18b Mon Sep 17 00:00:00 2001 From: Jasper Smulders Date: Tue, 24 Aug 2021 11:33:19 +0200 Subject: [PATCH 712/903] Add deCONZ support for Sonoff SNZB-01 switches (#54919) --- homeassistant/components/deconz/device_trigger.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 5beaba2c5a5..8234ed81aed 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -516,6 +516,14 @@ BUSCH_JAEGER_REMOTE = { (CONF_LONG_RELEASE, CONF_BUTTON_8): {CONF_EVENT: 8003}, } +SONOFF_SNZB_01_1_MODEL = "WB01" +SONOFF_SNZB_01_2_MODEL = "WB-01" +SONOFF_SNZB_01_SWITCH = { + (CONF_SHORT_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1002}, + (CONF_LONG_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1003}, + (CONF_DOUBLE_PRESS, CONF_BUTTON_1): {CONF_EVENT: 1004}, +} + TRUST_ZYCT_202_MODEL = "ZYCT-202" TRUST_ZYCT_202_ZLL_MODEL = "ZLL-NonColorController" TRUST_ZYCT_202 = { @@ -595,6 +603,8 @@ REMOTES = { TRUST_ZYCT_202_ZLL_MODEL: TRUST_ZYCT_202, UBISYS_POWER_SWITCH_S2_MODEL: UBISYS_POWER_SWITCH_S2, UBISYS_CONTROL_UNIT_C4_MODEL: UBISYS_CONTROL_UNIT_C4, + SONOFF_SNZB_01_1_MODEL: SONOFF_SNZB_01_SWITCH, + SONOFF_SNZB_01_2_MODEL: SONOFF_SNZB_01_SWITCH, } TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( From ede916f42faef2d34aa615d90a2698409af60fd3 Mon Sep 17 00:00:00 2001 From: cdheiser <10488026+cdheiser@users.noreply.github.com> Date: Tue, 24 Aug 2021 02:50:32 -0700 Subject: [PATCH 713/903] Provide unique IDs for Lutron Entities (#51395) Co-authored-by: cdheiser --- homeassistant/components/lutron/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 8382194ab46..de8ff228bc4 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -128,6 +128,11 @@ class LutronDevice(Entity): """No polling needed.""" return False + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._controller.guid}_{self._lutron_device.uuid}" + class LutronButton: """Representation of a button on a Lutron keypad. From 19d81af4c1db712051bcf7e9cd58ab8b17a81eef Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 24 Aug 2021 12:00:37 +0200 Subject: [PATCH 714/903] Test KNX fan (#53621) --- tests/components/knx/test_fan.py | 147 +++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/components/knx/test_fan.py diff --git a/tests/components/knx/test_fan.py b/tests/components/knx/test_fan.py new file mode 100644 index 00000000000..cc2365888f0 --- /dev/null +++ b/tests/components/knx/test_fan.py @@ -0,0 +1,147 @@ +"""Test KNX fan.""" +from homeassistant.components.knx.const import KNX_ADDRESS +from homeassistant.components.knx.schema import FanSchema +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + + +async def test_fan_percent(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX fan with percentage speed.""" + await knx.setup_integration( + { + FanSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + } + } + ) + assert len(hass.states.async_all()) == 1 + + # turn on fan with default speed (50%) + await hass.services.async_call( + "fan", "turn_on", {"entity_id": "fan.test"}, blocking=True + ) + await knx.assert_write("1/2/3", (128,)) + + # turn off fan + await hass.services.async_call( + "fan", "turn_off", {"entity_id": "fan.test"}, blocking=True + ) + await knx.assert_write("1/2/3", (0,)) + + # receive 100% telegram + await knx.receive_write("1/2/3", (0xFF,)) + state = hass.states.get("fan.test") + assert state.state is STATE_ON + + # receive 80% telegram + await knx.receive_write("1/2/3", (0xCC,)) + state = hass.states.get("fan.test") + assert state.state is STATE_ON + assert state.attributes.get("percentage") == 80 + + # receive 0% telegram + await knx.receive_write("1/2/3", (0,)) + state = hass.states.get("fan.test") + assert state.state is STATE_OFF + + # fan does not respond to read + await knx.receive_read("1/2/3") + await knx.assert_telegram_count(0) + + +async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX fan with speed steps.""" + await knx.setup_integration( + { + FanSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + FanSchema.CONF_MAX_STEP: 4, + } + } + ) + assert len(hass.states.async_all()) == 1 + + # turn on fan with default speed (50% - step 2) + await hass.services.async_call( + "fan", "turn_on", {"entity_id": "fan.test"}, blocking=True + ) + await knx.assert_write("1/2/3", (2,)) + + # turn up speed to 75% - step 3 + await hass.services.async_call( + "fan", "turn_on", {"entity_id": "fan.test", "percentage": 75}, blocking=True + ) + await knx.assert_write("1/2/3", (3,)) + + # turn off fan + await hass.services.async_call( + "fan", "turn_off", {"entity_id": "fan.test"}, blocking=True + ) + await knx.assert_write("1/2/3", (0,)) + + # receive step 4 (100%) telegram + await knx.receive_write("1/2/3", (4,)) + state = hass.states.get("fan.test") + assert state.state is STATE_ON + assert state.attributes.get("percentage") == 100 + + # receive step 1 (25%) telegram + await knx.receive_write("1/2/3", (1,)) + state = hass.states.get("fan.test") + assert state.state is STATE_ON + assert state.attributes.get("percentage") == 25 + + # receive step 0 (off) telegram + await knx.receive_write("1/2/3", (0,)) + state = hass.states.get("fan.test") + assert state.state is STATE_OFF + + # fan does not respond to read + await knx.receive_read("1/2/3") + await knx.assert_telegram_count(0) + + +async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX fan oscillation.""" + await knx.setup_integration( + { + FanSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: "1/1/1", + FanSchema.CONF_OSCILLATION_ADDRESS: "2/2/2", + } + } + ) + assert len(hass.states.async_all()) == 1 + + # turn on oscillation + await hass.services.async_call( + "fan", + "oscillate", + {"entity_id": "fan.test", "oscillating": True}, + blocking=True, + ) + await knx.assert_write("2/2/2", True) + + # turn off oscillation + await hass.services.async_call( + "fan", + "oscillate", + {"entity_id": "fan.test", "oscillating": False}, + blocking=True, + ) + await knx.assert_write("2/2/2", False) + + # receive oscillation on + await knx.receive_write("2/2/2", True) + state = hass.states.get("fan.test") + assert state.attributes.get("oscillating") is True + + # receive oscillation off + await knx.receive_write("2/2/2", False) + state = hass.states.get("fan.test") + assert state.attributes.get("oscillating") is False From d5fe7e0e5a096077a8e8b08445d449cb0129954f Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 24 Aug 2021 05:05:24 -0500 Subject: [PATCH 715/903] Fallback to try all known Plex servers if none marked present (#53643) --- homeassistant/components/plex/server.py | 8 ++-- tests/components/plex/conftest.py | 2 +- tests/components/plex/test_config_flow.py | 48 +++++++++++++++++-- tests/fixtures/plex/plextv_resources_base.xml | 2 +- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index e667a8a77ac..12398edfd59 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -144,11 +144,11 @@ class PlexServer: config_entry_update_needed = False def _connect_with_token(): - available_servers = [ - (x.name, x.clientIdentifier) - for x in self.account.resources() - if "server" in x.provides and x.presence + all_servers = [ + x for x in self.account.resources() if "server" in x.provides ] + servers = [x for x in all_servers if x.presence] or all_servers + available_servers = [(x.name, x.clientIdentifier) for x in servers] if not available_servers: raise NoServersFound diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index bd2fc6a7fa8..6ed8eaaa94a 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -239,7 +239,7 @@ def plextv_resources_base_fixture(): @pytest.fixture(name="plextv_resources", scope="session") def plextv_resources_fixture(plextv_resources_base): """Load default payload for plex.tv resources and return it.""" - return plextv_resources_base.format(second_server_enabled=0) + return plextv_resources_base.format(first_server_enabled=1, second_server_enabled=0) @pytest.fixture(name="plextv_shared_users", scope="session") diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 72958fc10c0..45904588a10 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -210,7 +210,9 @@ async def test_multiple_servers_with_selection( requests_mock.get( "https://plex.tv/api/resources", - text=plextv_resources_base.format(second_server_enabled=1), + text=plextv_resources_base.format( + first_server_enabled=1, second_server_enabled=1 + ), ) with patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN @@ -248,6 +250,42 @@ async def test_multiple_servers_with_selection( assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN +async def test_only_non_present_servers( + hass, + mock_plex_calls, + requests_mock, + plextv_resources_base, + current_request_with_host, +): + """Test creating an entry with one server available.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + + requests_mock.get( + "https://plex.tv/api/resources", + text=plextv_resources_base.format( + first_server_enabled=0, second_server_enabled=0 + ), + ) + with patch("plexauth.PlexAuth.initiate_auth"), patch( + "plexauth.PlexAuth.token", return_value=MOCK_TOKEN + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "external" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == "external_done" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == "form" + assert result["step_id"] == "select_server" + + async def test_adding_last_unconfigured_server( hass, mock_plex_calls, @@ -272,7 +310,9 @@ async def test_adding_last_unconfigured_server( requests_mock.get( "https://plex.tv/api/resources", - text=plextv_resources_base.format(second_server_enabled=1), + text=plextv_resources_base.format( + first_server_enabled=1, second_server_enabled=1 + ), ) with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -332,7 +372,9 @@ async def test_all_available_servers_configured( requests_mock.get("https://plex.tv/users/account", text=plextv_account) requests_mock.get( "https://plex.tv/api/resources", - text=plextv_resources_base.format(second_server_enabled=1), + text=plextv_resources_base.format( + first_server_enabled=1, second_server_enabled=1 + ), ) with patch("plexauth.PlexAuth.initiate_auth"), patch( diff --git a/tests/fixtures/plex/plextv_resources_base.xml b/tests/fixtures/plex/plextv_resources_base.xml index 41e61711d36..5802c58d4d4 100644 --- a/tests/fixtures/plex/plextv_resources_base.xml +++ b/tests/fixtures/plex/plextv_resources_base.xml @@ -1,5 +1,5 @@ - + From 0fdea8ec8fdc0f1ce4be4e85b9cb259900356bc2 Mon Sep 17 00:00:00 2001 From: Andre Richter Date: Tue, 24 Aug 2021 12:16:31 +0200 Subject: [PATCH 716/903] SMA: Add statistics support for power sensors (#54422) --- homeassistant/components/sma/sensor.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 8808272ad75..f0a10a5d5e1 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) @@ -21,7 +22,9 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, + POWER_WATT, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -166,6 +169,9 @@ class SMAsensor(CoordinatorEntity, SensorEntity): if self.unit_of_measurement == ENERGY_KILO_WATT_HOUR: self._attr_state_class = STATE_CLASS_TOTAL_INCREASING self._attr_device_class = DEVICE_CLASS_ENERGY + if self.unit_of_measurement == POWER_WATT: + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_device_class = DEVICE_CLASS_POWER # Set sensor enabled to False. # Will be enabled by async_added_to_hass if actually used. From 17a7f7adeb24ad048afdd789d0959eb8f817a389 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 24 Aug 2021 12:53:11 +0200 Subject: [PATCH 717/903] Configurable default `hvac_mode` for KNX climate (#54289) --- homeassistant/components/knx/climate.py | 12 +++++------- homeassistant/components/knx/schema.py | 5 +++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 803ded55441..91342cca839 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, - HVAC_MODE_HEAT, HVAC_MODE_OFF, PRESET_AWAY, SUPPORT_PRESET_MODE, @@ -187,6 +186,7 @@ class KNXClimate(KnxEntity, ClimateEntity): f"{self._device.target_temperature.group_address}_" f"{self._device._setpoint_shift.group_address}" ) + self.default_hvac_mode: str = config[ClimateSchema.CONF_DEFAULT_CONTROLLER_MODE] async def async_update(self) -> None: """Request a state update from KNX bus.""" @@ -231,10 +231,9 @@ class KNXClimate(KnxEntity, ClimateEntity): return HVAC_MODE_OFF if self._device.mode is not None and self._device.mode.supports_controller_mode: return CONTROLLER_MODES.get( - self._device.mode.controller_mode.value, HVAC_MODE_HEAT + self._device.mode.controller_mode.value, self.default_hvac_mode ) - # default to "heat" - return HVAC_MODE_HEAT + return self.default_hvac_mode @property def hvac_modes(self) -> list[str]: @@ -248,12 +247,11 @@ class KNXClimate(KnxEntity, ClimateEntity): if self._device.supports_on_off: if not ha_controller_modes: - ha_controller_modes.append(HVAC_MODE_HEAT) + ha_controller_modes.append(self.default_hvac_mode) ha_controller_modes.append(HVAC_MODE_OFF) hvac_modes = list(set(filter(None, ha_controller_modes))) - # default to ["heat"] - return hvac_modes if hvac_modes else [HVAC_MODE_HEAT] + return hvac_modes if hvac_modes else [self.default_hvac_mode] @property def hvac_action(self) -> str | None: diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 196c171c9b5..65ff6b3b8fa 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -16,6 +16,7 @@ from xknx.telegram.address import IndividualAddress, parse_device_group_address from homeassistant.components.binary_sensor import ( DEVICE_CLASSES as BINARY_SENSOR_DEVICE_CLASSES, ) +from homeassistant.components.climate.const import HVAC_MODE_HEAT, HVAC_MODES from homeassistant.components.cover import DEVICE_CLASSES as COVER_DEVICE_CLASSES from homeassistant.components.sensor import STATE_CLASSES_SCHEMA from homeassistant.const import ( @@ -287,6 +288,7 @@ class ClimateSchema(KNXPlatformSchema): CONF_OPERATION_MODE_STANDBY_ADDRESS = "operation_mode_standby_address" CONF_OPERATION_MODES = "operation_modes" CONF_CONTROLLER_MODES = "controller_modes" + CONF_DEFAULT_CONTROLLER_MODE = "default_controller_mode" CONF_ON_OFF_ADDRESS = "on_off_address" CONF_ON_OFF_STATE_ADDRESS = "on_off_state_address" CONF_ON_OFF_INVERT = "on_off_invert" @@ -362,6 +364,9 @@ class ClimateSchema(KNXPlatformSchema): vol.Optional(CONF_CONTROLLER_MODES): vol.All( cv.ensure_list, [vol.In(CONTROLLER_MODES)] ), + vol.Optional( + CONF_DEFAULT_CONTROLLER_MODE, default=HVAC_MODE_HEAT + ): vol.In(HVAC_MODES), vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), } From a5e498207d0b0c50c7f645764bbaef4b7896c68b Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 24 Aug 2021 11:53:32 +0100 Subject: [PATCH 718/903] OVO Energy - Sensor Entity Descriptions (#54952) --- .../components/ovo_energy/__init__.py | 27 +- homeassistant/components/ovo_energy/sensor.py | 296 +++++++----------- 2 files changed, 118 insertions(+), 205 deletions(-) diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index 79a8e6138eb..aa05c83ae76 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -100,36 +100,11 @@ class OVOEnergyEntity(CoordinatorEntity): coordinator: DataUpdateCoordinator, client: OVOEnergy, key: str, - name: str, - icon: str, ) -> None: """Initialize the OVO Energy entity.""" super().__init__(coordinator) self._client = client - self._key = key - self._name = name - self._icon = icon - self._available = True - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._key - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.coordinator.last_update_success and self._available + self._attr_unique_id = key class OVOEnergyDeviceEntity(OVOEnergyEntity): diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index e1130ca36a5..cd84fa5a5d6 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -1,16 +1,30 @@ """Support for OVO Energy sensors.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta +from typing import Callable, Final from ovoenergy import OVODailyUsage from ovoenergy.ovoenergy import OVOEnergy -from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_CLASS_ENERGY, DEVICE_CLASS_MONETARY +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_TIMESTAMP, + ENERGY_KILO_WATT_HOUR, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util from . import OVOEnergyDeviceEntity from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN @@ -18,9 +32,87 @@ from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN SCAN_INTERVAL = timedelta(seconds=300) PARALLEL_UPDATES = 4 +KEY_LAST_ELECTRICITY_COST: Final = "last_electricity_cost" +KEY_LAST_GAS_COST: Final = "last_gas_cost" + + +@dataclass +class OVOEnergySensorEntityDescription(SensorEntityDescription): + """Class describing System Bridge sensor entities.""" + + value: Callable[[OVODailyUsage], StateType] = round + + +SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( + OVOEnergySensorEntityDescription( + key="last_electricity_reading", + name="OVO Last Electricity Reading", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value=lambda usage: usage.electricity[-1].consumption, + ), + OVOEnergySensorEntityDescription( + key=KEY_LAST_ELECTRICITY_COST, + name="OVO Last Electricity Cost", + device_class=DEVICE_CLASS_MONETARY, + state_class=STATE_CLASS_TOTAL_INCREASING, + icon="mdi:cash-multiple", + value=lambda usage: usage.electricity[-1].consumption, + ), + OVOEnergySensorEntityDescription( + key="last_electricity_start_time", + name="OVO Last Electricity Start Time", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + value=lambda usage: dt_util.as_utc(usage.electricity[-1].interval.start), + ), + OVOEnergySensorEntityDescription( + key="last_electricity_end_time", + name="OVO Last Electricity End Time", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + value=lambda usage: dt_util.as_utc(usage.electricity[-1].interval.end), + ), +) + +SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( + OVOEnergySensorEntityDescription( + key="last_gas_reading", + name="OVO Last Gas Reading", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + icon="mdi:gas-cylinder", + value=lambda usage: usage.gas[-1].consumption, + ), + OVOEnergySensorEntityDescription( + key=KEY_LAST_GAS_COST, + name="OVO Last Gas Cost", + device_class=DEVICE_CLASS_MONETARY, + state_class=STATE_CLASS_TOTAL_INCREASING, + icon="mdi:cash-multiple", + value=lambda usage: usage.gas[-1].consumption, + ), + OVOEnergySensorEntityDescription( + key="last_gas_start_time", + name="OVO Last Gas Start Time", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + value=lambda usage: dt_util.as_utc(usage.gas[-1].interval.start), + ), + OVOEnergySensorEntityDescription( + key="last_gas_end_time", + name="OVO Last Gas End Time", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + value=lambda usage: dt_util.as_utc(usage.gas[-1].interval.end), + ), +) + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up OVO Energy sensor based on a config entry.""" coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ @@ -32,199 +124,45 @@ async def async_setup_entry( if coordinator.data: if coordinator.data.electricity: - entities.append(OVOEnergyLastElectricityReading(coordinator, client)) - entities.append( - OVOEnergyLastElectricityCost( - coordinator, - client, - coordinator.data.electricity[ - len(coordinator.data.electricity) - 1 - ].cost.currency_unit, - ) - ) + for description in SENSOR_TYPES_ELECTRICITY: + if description.key == KEY_LAST_ELECTRICITY_COST: + description.native_unit_of_measurement = ( + coordinator.data.electricity[-1].cost.currency_unit + ) + entities.append(OVOEnergySensor(coordinator, description, client)) if coordinator.data.gas: - entities.append(OVOEnergyLastGasReading(coordinator, client)) - entities.append( - OVOEnergyLastGasCost( - coordinator, - client, - coordinator.data.gas[ - len(coordinator.data.gas) - 1 - ].cost.currency_unit, - ) - ) + for description in SENSOR_TYPES_GAS: + if description.key == KEY_LAST_GAS_COST: + description.native_unit_of_measurement = coordinator.data.gas[ + -1 + ].cost.currency_unit + entities.append(OVOEnergySensor(coordinator, description, client)) async_add_entities(entities, True) class OVOEnergySensor(OVOEnergyDeviceEntity, SensorEntity): - """Defines a OVO Energy sensor.""" + """Define a OVO Energy sensor.""" - _attr_state_class = STATE_CLASS_TOTAL_INCREASING + coordinator: DataUpdateCoordinator + entity_description: OVOEnergySensorEntityDescription def __init__( self, coordinator: DataUpdateCoordinator, + description: OVOEnergySensorEntityDescription, client: OVOEnergy, - key: str, - name: str, - icon: str, - device_class: str | None, - unit_of_measurement: str | None, ) -> None: - """Initialize OVO Energy sensor.""" - self._attr_device_class = device_class - self._unit_of_measurement = unit_of_measurement - - super().__init__(coordinator, client, key, name, icon) - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - -class OVOEnergyLastElectricityReading(OVOEnergySensor): - """Defines a OVO Energy last reading sensor.""" - - def __init__(self, coordinator: DataUpdateCoordinator, client: OVOEnergy) -> None: - """Initialize OVO Energy sensor.""" - + """Initialize.""" super().__init__( coordinator, client, - f"{client.account_id}_last_electricity_reading", - "OVO Last Electricity Reading", - "mdi:flash", - DEVICE_CLASS_ENERGY, - "kWh", + f"{DOMAIN}_{client.account_id}_{description.key}", ) + self.entity_description = description @property - def native_value(self) -> str: - """Return the state of the sensor.""" + def native_value(self) -> StateType: + """Return the state.""" usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.electricity: - return None - return usage.electricity[-1].consumption - - @property - def extra_state_attributes(self) -> object: - """Return the attributes of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.electricity: - return None - return { - "start_time": usage.electricity[-1].interval.start, - "end_time": usage.electricity[-1].interval.end, - } - - -class OVOEnergyLastGasReading(OVOEnergySensor): - """Defines a OVO Energy last reading sensor.""" - - def __init__(self, coordinator: DataUpdateCoordinator, client: OVOEnergy) -> None: - """Initialize OVO Energy sensor.""" - - super().__init__( - coordinator, - client, - f"{DOMAIN}_{client.account_id}_last_gas_reading", - "OVO Last Gas Reading", - "mdi:gas-cylinder", - DEVICE_CLASS_ENERGY, - "kWh", - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.gas: - return None - return usage.gas[-1].consumption - - @property - def extra_state_attributes(self) -> object: - """Return the attributes of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.gas: - return None - return { - "start_time": usage.gas[-1].interval.start, - "end_time": usage.gas[-1].interval.end, - } - - -class OVOEnergyLastElectricityCost(OVOEnergySensor): - """Defines a OVO Energy last cost sensor.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, client: OVOEnergy, currency: str - ) -> None: - """Initialize OVO Energy sensor.""" - super().__init__( - coordinator, - client, - f"{DOMAIN}_{client.account_id}_last_electricity_cost", - "OVO Last Electricity Cost", - "mdi:cash-multiple", - DEVICE_CLASS_MONETARY, - currency, - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.electricity: - return None - return usage.electricity[-1].cost.amount - - @property - def extra_state_attributes(self) -> object: - """Return the attributes of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.electricity: - return None - return { - "start_time": usage.electricity[-1].interval.start, - "end_time": usage.electricity[-1].interval.end, - } - - -class OVOEnergyLastGasCost(OVOEnergySensor): - """Defines a OVO Energy last cost sensor.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, client: OVOEnergy, currency: str - ) -> None: - """Initialize OVO Energy sensor.""" - super().__init__( - coordinator, - client, - f"{DOMAIN}_{client.account_id}_last_gas_cost", - "OVO Last Gas Cost", - "mdi:cash-multiple", - DEVICE_CLASS_MONETARY, - currency, - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.gas: - return None - return usage.gas[-1].cost.amount - - @property - def extra_state_attributes(self) -> object: - """Return the attributes of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.gas: - return None - return { - "start_time": usage.gas[-1].interval.start, - "end_time": usage.gas[-1].interval.end, - } + return self.entity_description.value(usage) From 547ede1e9136080f1709d7a4d1f1ef84043a555d Mon Sep 17 00:00:00 2001 From: posixx <2280400+posixx@users.noreply.github.com> Date: Tue, 24 Aug 2021 13:22:49 +0200 Subject: [PATCH 719/903] Implementation of new Vacation mode for MQTT-based alarm panels (#53561) * Impelentation of new Vacation Mode for MQTT-based alarm panels * Fixed typo * another typo fix * Split integrations: remove manual_mqtt * added newline * Impelentation of new Vacation Mode for MQTT-based alarm panels * Fixed typo * another typo fix * Split integrations: remove manual_mqtt * added newline * missing abbreviation * Fix tests Co-authored-by: Erik Montnemery --- .../components/mqtt/abbreviations.py | 1 + .../components/mqtt/alarm_control_panel.py | 20 +++++++ .../components/alarm_control_panel/common.py | 14 +++++ .../mqtt/test_alarm_control_panel.py | 60 +++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 6bb7a92e8af..dd2f631848e 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -113,6 +113,7 @@ ABBREVIATIONS = { "pl_arm_away": "payload_arm_away", "pl_arm_home": "payload_arm_home", "pl_arm_nite": "payload_arm_night", + "pl_arm_vacation": "payload_arm_vacation", "pl_arm_custom_b": "payload_arm_custom_bypass", "pl_avail": "payload_available", "pl_cln_sp": "payload_clean_spot", diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index aa98a48dc10..f3e8e112f1a 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -11,6 +11,7 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_CUSTOM_BYPASS, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, ) from homeassistant.const import ( CONF_CODE, @@ -20,6 +21,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_DISARMING, @@ -52,6 +54,7 @@ CONF_PAYLOAD_DISARM = "payload_disarm" CONF_PAYLOAD_ARM_HOME = "payload_arm_home" CONF_PAYLOAD_ARM_AWAY = "payload_arm_away" CONF_PAYLOAD_ARM_NIGHT = "payload_arm_night" +CONF_PAYLOAD_ARM_VACATION = "payload_arm_vacation" CONF_PAYLOAD_ARM_CUSTOM_BYPASS = "payload_arm_custom_bypass" CONF_COMMAND_TEMPLATE = "command_template" @@ -65,6 +68,7 @@ MQTT_ALARM_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_COMMAND_TEMPLATE = "{{action}}" DEFAULT_ARM_NIGHT = "ARM_NIGHT" +DEFAULT_ARM_VACATION = "ARM_VACATION" DEFAULT_ARM_AWAY = "ARM_AWAY" DEFAULT_ARM_HOME = "ARM_HOME" DEFAULT_ARM_CUSTOM_BYPASS = "ARM_CUSTOM_BYPASS" @@ -83,6 +87,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, + vol.Optional( + CONF_PAYLOAD_ARM_VACATION, default=DEFAULT_ARM_VACATION + ): cv.string, vol.Optional( CONF_PAYLOAD_ARM_CUSTOM_BYPASS, default=DEFAULT_ARM_CUSTOM_BYPASS ): cv.string, @@ -158,6 +165,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_PENDING, STATE_ALARM_ARMING, @@ -193,6 +201,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + | SUPPORT_ALARM_ARM_VACATION | SUPPORT_ALARM_ARM_CUSTOM_BYPASS ) @@ -256,6 +265,17 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_NIGHT] self._publish(code, action) + async def async_alarm_arm_vacation(self, code=None): + """Send arm vacation command. + + This method is a coroutine. + """ + code_required = self._config[CONF_CODE_ARM_REQUIRED] + if code_required and not self._validate_code(code, "arming vacation"): + return + action = self._config[CONF_PAYLOAD_ARM_VACATION] + self._publish(code, action) + async def async_alarm_arm_custom_bypass(self, code=None): """Send arm custom bypass command. diff --git a/tests/components/alarm_control_panel/common.py b/tests/components/alarm_control_panel/common.py index fa50a1aab41..e46bac2fc1f 100644 --- a/tests/components/alarm_control_panel/common.py +++ b/tests/components/alarm_control_panel/common.py @@ -12,6 +12,7 @@ from homeassistant.const import ( SERVICE_ALARM_ARM_CUSTOM_BYPASS, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, ) @@ -61,6 +62,19 @@ async def async_alarm_arm_night(hass, code=None, entity_id=ENTITY_MATCH_ALL): await hass.services.async_call(DOMAIN, SERVICE_ALARM_ARM_NIGHT, data, blocking=True) +async def async_alarm_arm_vacation(hass, code=None, entity_id=ENTITY_MATCH_ALL): + """Send the alarm the command for vacation mode.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call( + DOMAIN, SERVICE_ALARM_ARM_VACATION, data, blocking=True + ) + + async def async_alarm_trigger(hass, code=None, entity_id=ENTITY_MATCH_ALL): """Send the alarm the command for disarm.""" data = {} diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index c9d06ef343e..c05aa052b5a 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_DISARMING, @@ -124,6 +125,7 @@ async def test_update_state_via_state_topic(hass, mqtt_mock): STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_PENDING, STATE_ALARM_ARMING, @@ -176,6 +178,7 @@ async def test_arm_home_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) + await hass.async_block_till_done() call_count = mqtt_mock.async_publish.call_count await common.async_alarm_arm_home(hass, "abcd") @@ -227,6 +230,7 @@ async def test_arm_away_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) + await hass.async_block_till_done() call_count = mqtt_mock.async_publish.call_count await common.async_alarm_arm_away(hass, "abcd") @@ -278,6 +282,7 @@ async def test_arm_night_not_publishes_mqtt_with_invalid_code_when_req(hass, mqt alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) + await hass.async_block_till_done() call_count = mqtt_mock.async_publish.call_count await common.async_alarm_arm_night(hass, "abcd") @@ -304,6 +309,60 @@ async def test_arm_night_publishes_mqtt_when_code_not_req(hass, mqtt_mock): ) +async def test_arm_vacation_publishes_mqtt(hass, mqtt_mock): + """Test publishing of MQTT messages while armed.""" + assert await async_setup_component( + hass, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ) + await hass.async_block_till_done() + + await common.async_alarm_arm_vacation(hass) + mqtt_mock.async_publish.assert_called_once_with( + "alarm/command", "ARM_VACATION", 0, False + ) + + +async def test_arm_vacation_not_publishes_mqtt_with_invalid_code_when_req( + hass, mqtt_mock +): + """Test not publishing of MQTT messages with invalid code. + + When code_arm_required = True + """ + assert await async_setup_component( + hass, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ) + await hass.async_block_till_done() + + call_count = mqtt_mock.async_publish.call_count + await common.async_alarm_arm_vacation(hass, "abcd") + assert mqtt_mock.async_publish.call_count == call_count + + +async def test_arm_vacation_publishes_mqtt_when_code_not_req(hass, mqtt_mock): + """Test publishing of MQTT messages. + + When code_arm_required = False + """ + config = copy.deepcopy(DEFAULT_CONFIG_CODE) + config[alarm_control_panel.DOMAIN]["code_arm_required"] = False + assert await async_setup_component( + hass, + alarm_control_panel.DOMAIN, + config, + ) + await hass.async_block_till_done() + + await common.async_alarm_arm_vacation(hass) + mqtt_mock.async_publish.assert_called_once_with( + "alarm/command", "ARM_VACATION", 0, False + ) + + async def test_arm_custom_bypass_publishes_mqtt(hass, mqtt_mock): """Test publishing of MQTT messages while armed.""" assert await async_setup_component( @@ -446,6 +505,7 @@ async def test_disarm_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_m alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) + await hass.async_block_till_done() call_count = mqtt_mock.async_publish.call_count await common.async_alarm_disarm(hass, "abcd") From 828d8623396410157120a163af7fe901e04c7f65 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Aug 2021 13:23:26 +0200 Subject: [PATCH 720/903] Fix Tasmota MQTT discovery flow (#55140) --- .../components/tasmota/config_flow.py | 6 ++--- tests/components/tasmota/test_config_flow.py | 23 +++++++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py index e1621f2c126..85959ef0674 100644 --- a/homeassistant/components/tasmota/config_flow.py +++ b/homeassistant/components/tasmota/config_flow.py @@ -1,12 +1,12 @@ """Config flow for Tasmota.""" from __future__ import annotations -from typing import Any, cast +from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.mqtt import ReceiveMessage, valid_subscribe_topic +from homeassistant.components.mqtt import valid_subscribe_topic from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.typing import DiscoveryInfoType @@ -30,7 +30,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DOMAIN) # Validate the topic, will throw if it fails - prefix = cast(ReceiveMessage, discovery_info).subscribed_topic + prefix = discovery_info["subscribed_topic"] if prefix.endswith("/#"): prefix = prefix[:-2] try: diff --git a/tests/components/tasmota/test_config_flow.py b/tests/components/tasmota/test_config_flow.py index 767d6b9cfcf..c97ffb9edb8 100644 --- a/tests/components/tasmota/test_config_flow.py +++ b/tests/components/tasmota/test_config_flow.py @@ -1,6 +1,5 @@ """Test config flow.""" from homeassistant import config_entries -from homeassistant.components.mqtt.models import ReceiveMessage from tests.common import MockConfigEntry @@ -19,9 +18,14 @@ async def test_mqtt_abort_if_existing_entry(hass, mqtt_mock): async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): """Check MQTT flow aborts if discovery topic is invalid.""" - discovery_info = ReceiveMessage( - "", "", 0, False, subscribed_topic="custom_prefix/##" - ) + discovery_info = { + "topic": "", + "payload": "", + "qos": 0, + "retain": False, + "subscribed_topic": "custom_prefix/##", + "timestamp": None, + } result = await hass.config_entries.flow.async_init( "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info ) @@ -31,9 +35,14 @@ async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): async def test_mqtt_setup(hass, mqtt_mock) -> None: """Test we can finish a config flow through MQTT with custom prefix.""" - discovery_info = ReceiveMessage( - "", "", 0, False, subscribed_topic="custom_prefix/123/#" - ) + discovery_info = { + "topic": "", + "payload": "", + "qos": 0, + "retain": False, + "subscribed_topic": "custom_prefix/123/#", + "timestamp": None, + } result = await hass.config_entries.flow.async_init( "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info ) From 5cbb2173182f666b1bbb19ca85a2c44bb12ebb99 Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Tue, 24 Aug 2021 08:06:19 -0400 Subject: [PATCH 721/903] Update amcrest to use binary sensor entity description (#55092) --- homeassistant/components/amcrest/__init__.py | 14 +- .../components/amcrest/binary_sensor.py | 187 ++++++++++-------- homeassistant/components/amcrest/const.py | 4 - 3 files changed, 117 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index ca99524f611..d248a3d8f7c 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -34,7 +34,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_s from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.service import async_extract_entity_ids -from .binary_sensor import BINARY_POLLED_SENSORS, BINARY_SENSORS, check_binary_sensors +from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST from .const import ( CAMERAS, @@ -43,7 +43,6 @@ from .const import ( DATA_AMCREST, DEVICES, DOMAIN, - SENSOR_EVENT_CODE, SERVICE_EVENT, SERVICE_UPDATE, ) @@ -99,7 +98,10 @@ AMCREST_SCHEMA = vol.Schema( vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [vol.In(BINARY_SENSORS)], vol.Unique(), check_binary_sensors + cv.ensure_list, + [vol.In(BINARY_SENSOR_KEYS)], + vol.Unique(), + check_binary_sensors, ), vol.Optional(CONF_SENSORS): vol.All( cv.ensure_list, [vol.In(SENSOR_KEYS)], vol.Unique() @@ -276,9 +278,9 @@ def setup(hass, config): config, ) event_codes = [ - BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE] - for sensor_type in binary_sensors - if sensor_type not in BINARY_POLLED_SENSORS + sensor.event_code + for sensor in BINARY_SENSORS + if sensor.key in binary_sensors and not sensor.should_poll ] _start_event_monitor(hass, name, api, event_codes) diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index 98e0be73ef4..fcbadc73147 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -1,5 +1,8 @@ """Support for Amcrest IP camera binary sensors.""" +from __future__ import annotations + from contextlib import suppress +from dataclasses import dataclass from datetime import timedelta import logging @@ -11,6 +14,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_SOUND, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME from homeassistant.core import callback @@ -21,54 +25,93 @@ from .const import ( BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST, DEVICES, - SENSOR_DEVICE_CLASS, - SENSOR_EVENT_CODE, - SENSOR_NAME, SERVICE_EVENT, SERVICE_UPDATE, ) from .helpers import log_update_error, service_signal + +@dataclass +class AmcrestSensorEntityDescription(BinarySensorEntityDescription): + """Describe Amcrest sensor entity.""" + + event_code: str | None = None + should_poll: bool = False + + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS) _ONLINE_SCAN_INTERVAL = timedelta(seconds=60 - BINARY_SENSOR_SCAN_INTERVAL_SECS) -BINARY_SENSOR_AUDIO_DETECTED = "audio_detected" -BINARY_SENSOR_AUDIO_DETECTED_POLLED = "audio_detected_polled" -BINARY_SENSOR_MOTION_DETECTED = "motion_detected" -BINARY_SENSOR_MOTION_DETECTED_POLLED = "motion_detected_polled" -BINARY_SENSOR_ONLINE = "online" -BINARY_SENSOR_CROSSLINE_DETECTED = "crossline_detected" -BINARY_SENSOR_CROSSLINE_DETECTED_POLLED = "crossline_detected_polled" -BINARY_POLLED_SENSORS = [ - BINARY_SENSOR_AUDIO_DETECTED_POLLED, - BINARY_SENSOR_MOTION_DETECTED_POLLED, - BINARY_SENSOR_ONLINE, -] -_AUDIO_DETECTED_PARAMS = ("Audio Detected", DEVICE_CLASS_SOUND, "AudioMutation") -_MOTION_DETECTED_PARAMS = ("Motion Detected", DEVICE_CLASS_MOTION, "VideoMotion") -_CROSSLINE_DETECTED_PARAMS = ( - "CrossLine Detected", - DEVICE_CLASS_MOTION, - "CrossLineDetection", +_AUDIO_DETECTED_KEY = "audio_detected" +_AUDIO_DETECTED_POLLED_KEY = "audio_detected_polled" +_AUDIO_DETECTED_NAME = "Audio Detected" +_AUDIO_DETECTED_EVENT_CODE = "AudioMutation" + +_CROSSLINE_DETECTED_KEY = "crossline_detected" +_CROSSLINE_DETECTED_POLLED_KEY = "crossline_detected_polled" +_CROSSLINE_DETECTED_NAME = "CrossLine Detected" +_CROSSLINE_DETECTED_EVENT_CODE = "CrossLineDetection" + +_MOTION_DETECTED_KEY = "motion_detected" +_MOTION_DETECTED_POLLED_KEY = "motion_detected_polled" +_MOTION_DETECTED_NAME = "Motion Detected" +_MOTION_DETECTED_EVENT_CODE = "VideoMotion" + +_ONLINE_KEY = "online" + +BINARY_SENSORS: tuple[AmcrestSensorEntityDescription, ...] = ( + AmcrestSensorEntityDescription( + key=_AUDIO_DETECTED_KEY, + name=_AUDIO_DETECTED_NAME, + device_class=DEVICE_CLASS_SOUND, + event_code=_AUDIO_DETECTED_EVENT_CODE, + ), + AmcrestSensorEntityDescription( + key=_AUDIO_DETECTED_POLLED_KEY, + name=_AUDIO_DETECTED_NAME, + device_class=DEVICE_CLASS_SOUND, + event_code=_AUDIO_DETECTED_EVENT_CODE, + should_poll=True, + ), + AmcrestSensorEntityDescription( + key=_CROSSLINE_DETECTED_KEY, + name=_CROSSLINE_DETECTED_NAME, + device_class=DEVICE_CLASS_MOTION, + event_code=_CROSSLINE_DETECTED_EVENT_CODE, + ), + AmcrestSensorEntityDescription( + key=_CROSSLINE_DETECTED_POLLED_KEY, + name=_CROSSLINE_DETECTED_NAME, + device_class=DEVICE_CLASS_MOTION, + event_code=_CROSSLINE_DETECTED_EVENT_CODE, + should_poll=True, + ), + AmcrestSensorEntityDescription( + key=_MOTION_DETECTED_KEY, + name=_MOTION_DETECTED_NAME, + device_class=DEVICE_CLASS_MOTION, + event_code=_MOTION_DETECTED_EVENT_CODE, + ), + AmcrestSensorEntityDescription( + key=_MOTION_DETECTED_POLLED_KEY, + name=_MOTION_DETECTED_NAME, + device_class=DEVICE_CLASS_MOTION, + event_code=_MOTION_DETECTED_EVENT_CODE, + should_poll=True, + ), + AmcrestSensorEntityDescription( + key=_ONLINE_KEY, + name="Online", + device_class=DEVICE_CLASS_CONNECTIVITY, + ), ) -RAW_BINARY_SENSORS = { - BINARY_SENSOR_AUDIO_DETECTED: _AUDIO_DETECTED_PARAMS, - BINARY_SENSOR_AUDIO_DETECTED_POLLED: _AUDIO_DETECTED_PARAMS, - BINARY_SENSOR_MOTION_DETECTED: _MOTION_DETECTED_PARAMS, - BINARY_SENSOR_MOTION_DETECTED_POLLED: _MOTION_DETECTED_PARAMS, - BINARY_SENSOR_CROSSLINE_DETECTED: _CROSSLINE_DETECTED_PARAMS, - BINARY_SENSOR_CROSSLINE_DETECTED_POLLED: _CROSSLINE_DETECTED_PARAMS, - BINARY_SENSOR_ONLINE: ("Online", DEVICE_CLASS_CONNECTIVITY, None), -} -BINARY_SENSORS = { - k: dict(zip((SENSOR_NAME, SENSOR_DEVICE_CLASS, SENSOR_EVENT_CODE), v)) - for k, v in RAW_BINARY_SENSORS.items() -} +BINARY_SENSOR_KEYS = [description.key for description in BINARY_SENSORS] _EXCLUSIVE_OPTIONS = [ - {BINARY_SENSOR_MOTION_DETECTED, BINARY_SENSOR_MOTION_DETECTED_POLLED}, - {BINARY_SENSOR_CROSSLINE_DETECTED, BINARY_SENSOR_CROSSLINE_DETECTED_POLLED}, + {_AUDIO_DETECTED_KEY, _AUDIO_DETECTED_POLLED_KEY}, + {_MOTION_DETECTED_KEY, _MOTION_DETECTED_POLLED_KEY}, + {_CROSSLINE_DETECTED_KEY, _CROSSLINE_DETECTED_POLLED_KEY}, ] _UPDATE_MSG = "Updating %s binary sensor" @@ -91,10 +134,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= name = discovery_info[CONF_NAME] device = hass.data[DATA_AMCREST][DEVICES][name] + binary_sensors = discovery_info[CONF_BINARY_SENSORS] async_add_entities( [ - AmcrestBinarySensor(name, device, sensor_type) - for sensor_type in discovery_info[CONF_BINARY_SENSORS] + AmcrestBinarySensor(name, device, entity_description) + for entity_description in BINARY_SENSORS + if entity_description.key in binary_sensors ], True, ) @@ -103,45 +148,23 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AmcrestBinarySensor(BinarySensorEntity): """Binary sensor for Amcrest camera.""" - def __init__(self, name, device, sensor_type): + def __init__(self, name, device, entity_description): """Initialize entity.""" - self._name = f"{name} {BINARY_SENSORS[sensor_type][SENSOR_NAME]}" self._signal_name = name self._api = device.api - self._sensor_type = sensor_type - self._state = None - self._device_class = BINARY_SENSORS[sensor_type][SENSOR_DEVICE_CLASS] - self._event_code = BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE] + self.entity_description = entity_description + self._attr_name = f"{name} {entity_description.name}" + self._attr_should_poll = entity_description.should_poll self._unsub_dispatcher = [] - @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return self._sensor_type in BINARY_POLLED_SENSORS - - @property - def name(self): - """Return entity name.""" - return self._name - - @property - def is_on(self): - """Return if entity is on.""" - return self._state - - @property - def device_class(self): - """Return device class.""" - return self._device_class - @property def available(self): """Return True if entity is available.""" - return self._sensor_type == BINARY_SENSOR_ONLINE or self._api.available + return self.entity_description.key == _ONLINE_KEY or self._api.available def update(self): """Update entity.""" - if self._sensor_type == BINARY_SENSOR_ONLINE: + if self.entity_description.key == _ONLINE_KEY: self._update_online() else: self._update_others() @@ -150,32 +173,33 @@ class AmcrestBinarySensor(BinarySensorEntity): def _update_online(self): if not (self._api.available or self.is_on): return - _LOGGER.debug(_UPDATE_MSG, self._name) + _LOGGER.debug(_UPDATE_MSG, self.name) if self._api.available: # Send a command to the camera to test if we can still communicate with it. # Override of Http.command() in __init__.py will set self._api.available # accordingly. with suppress(AmcrestError): self._api.current_time # pylint: disable=pointless-statement - self._state = self._api.available + self._attr_is_on = self._api.available def _update_others(self): if not self.available: return - _LOGGER.debug(_UPDATE_MSG, self._name) + _LOGGER.debug(_UPDATE_MSG, self.name) + event_code = self.entity_description.event_code try: - self._state = "channels" in self._api.event_channels_happened( - self._event_code + self._attr_is_on = "channels" in self._api.event_channels_happened( + event_code ) except AmcrestError as error: log_update_error(_LOGGER, "update", self.name, "binary sensor", error) async def async_on_demand_update(self): """Update state.""" - if self._sensor_type == BINARY_SENSOR_ONLINE: - _LOGGER.debug(_UPDATE_MSG, self._name) - self._state = self._api.available + if self.entity_description.key == _ONLINE_KEY: + _LOGGER.debug(_UPDATE_MSG, self.name) + self._attr_is_on = self._api.available self.async_write_ha_state() return self.async_schedule_update_ha_state(True) @@ -183,8 +207,8 @@ class AmcrestBinarySensor(BinarySensorEntity): @callback def async_event_received(self, start): """Update state from received event.""" - _LOGGER.debug(_UPDATE_MSG, self._name) - self._state = start + _LOGGER.debug(_UPDATE_MSG, self.name) + self._attr_is_on = start self.async_write_ha_state() async def async_added_to_hass(self): @@ -196,11 +220,18 @@ class AmcrestBinarySensor(BinarySensorEntity): self.async_on_demand_update, ) ) - if self._event_code and self._sensor_type not in BINARY_POLLED_SENSORS: + if ( + self.entity_description.event_code + and not self.entity_description.should_poll + ): self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, - service_signal(SERVICE_EVENT, self._signal_name, self._event_code), + service_signal( + SERVICE_EVENT, + self._signal_name, + self.entity_description.event_code, + ), self.async_event_received, ) ) diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py index ba7597d61af..89cde63a08a 100644 --- a/homeassistant/components/amcrest/const.py +++ b/homeassistant/components/amcrest/const.py @@ -13,7 +13,3 @@ SNAPSHOT_TIMEOUT = 20 SERVICE_EVENT = "event" SERVICE_UPDATE = "update" - -SENSOR_DEVICE_CLASS = "class" -SENSOR_EVENT_CODE = "code" -SENSOR_NAME = "name" From de7352dbde08d73862addf0db7d69fae5d7270ad Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 24 Aug 2021 14:11:40 +0200 Subject: [PATCH 722/903] Convert template/vacuum to pytest with fixtures (#54841) --- tests/components/template/conftest.py | 27 +- tests/components/template/test_vacuum.py | 537 ++++++++++------------- 2 files changed, 262 insertions(+), 302 deletions(-) diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index 0848200b35d..e2168d0925e 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -1,2 +1,27 @@ """template conftest.""" -from tests.components.light.conftest import mock_light_profiles # noqa: F401 +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component, async_mock_service + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +@pytest.fixture +async def start_ha(hass, count, domain, config, caplog): + """Do setup of integration.""" + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index e18f8ecc059..6e0252845d1 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -12,7 +12,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN -from tests.common import assert_setup_component, async_mock_service +from tests.common import assert_setup_component from tests.components.vacuum import common _TEST_VACUUM = "vacuum.test_vacuum" @@ -23,19 +23,13 @@ _FAN_SPEED_INPUT_SELECT = "input_select.fan_speed" _BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -# Configuration tests # -async def test_missing_optional_config(hass, calls): - """Test: missing optional template is ok.""" - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", +@pytest.mark.parametrize("count,domain", [(1, "vacuum")]) +@pytest.mark.parametrize( + "parm1,parm2,config", + [ + ( + STATE_UNKNOWN, + None, { "vacuum": { "platform": "template", @@ -44,66 +38,90 @@ async def test_missing_optional_config(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_UNKNOWN, None) - - -async def test_missing_start_config(hass, calls): - """Test: missing 'start' will fail.""" - with assert_setup_component(0, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", + ), + ( + STATE_CLEANING, + 100, { "vacuum": { "platform": "template", - "vacuums": {"test_vacuum": {"value_template": "{{ 'on' }}"}}, + "vacuums": { + "test_vacuum": { + "value_template": "{{ 'cleaning' }}", + "battery_level_template": "{{ 100 }}", + "start": {"service": "script.vacuum_start"}, + } + }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_config(hass, calls): - """Test: invalid config structure will fail.""" - with assert_setup_component(0, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", + ), + ( + STATE_UNKNOWN, + None, { - "platform": "template", - "vacuums": { - "test_vacuum": {"start": {"service": "script.vacuum_start"}} - }, + "vacuum": { + "platform": "template", + "vacuums": { + "test_vacuum": { + "value_template": "{{ 'abc' }}", + "battery_level_template": "{{ 101 }}", + "start": {"service": "script.vacuum_start"}, + } + }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] + ), + ( + STATE_UNKNOWN, + None, + { + "vacuum": { + "platform": "template", + "vacuums": { + "test_vacuum": { + "value_template": "{{ this_function_does_not_exist() }}", + "battery_level_template": "{{ this_function_does_not_exist() }}", + "fan_speed_template": "{{ this_function_does_not_exist() }}", + "start": {"service": "script.vacuum_start"}, + } + }, + } + }, + ), + ], +) +async def test_valid_configs(hass, count, parm1, parm2, start_ha): + """Test: configs.""" + assert len(hass.states.async_all()) == count + _verify(hass, parm1, parm2) -# End of configuration tests # +@pytest.mark.parametrize("count,domain", [(0, "vacuum")]) +@pytest.mark.parametrize( + "config", + [ + { + "vacuum": { + "platform": "template", + "vacuums": {"test_vacuum": {"value_template": "{{ 'on' }}"}}, + } + }, + { + "platform": "template", + "vacuums": {"test_vacuum": {"start": {"service": "script.vacuum_start"}}}, + }, + ], +) +async def test_invalid_configs(hass, count, start_ha): + """Test: configs.""" + assert len(hass.states.async_all()) == count -# Template tests # -async def test_templates_with_entities(hass, calls): - """Test templates with values from other entities.""" - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, "vacuum", { "vacuum": { @@ -118,125 +136,41 @@ async def test_templates_with_entities(hass, calls): } }, ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_templates_with_entities(hass, start_ha): + """Test templates with values from other entities.""" _verify(hass, STATE_UNKNOWN, None) hass.states.async_set(_STATE_INPUT_SELECT, STATE_CLEANING) hass.states.async_set(_BATTERY_LEVEL_INPUT_NUMBER, 100) await hass.async_block_till_done() - _verify(hass, STATE_CLEANING, 100) -async def test_templates_with_valid_values(hass, calls): - """Test templates with valid values.""" - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, "vacuum", { "vacuum": { "platform": "template", "vacuums": { - "test_vacuum": { - "value_template": "{{ 'cleaning' }}", - "battery_level_template": "{{ 100 }}", + "test_template_vacuum": { + "availability_template": "{{ is_state('availability_state.state', 'on') }}", "start": {"service": "script.vacuum_start"}, } }, } }, ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_CLEANING, 100) - - -async def test_templates_invalid_values(hass, calls): - """Test templates with invalid values.""" - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ 'abc' }}", - "battery_level_template": "{{ 101 }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_UNKNOWN, None) - - -async def test_invalid_templates(hass, calls): - """Test invalid templates.""" - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ this_function_does_not_exist() }}", - "battery_level_template": "{{ this_function_does_not_exist() }}", - "fan_speed_template": "{{ this_function_does_not_exist() }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_UNKNOWN, None) - - -async def test_available_template_with_entities(hass, calls): + ], +) +async def test_available_template_with_entities(hass, start_ha): """Test availability templates with values from other entities.""" - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "availability_template": "{{ is_state('availability_state.state', 'on') }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - # When template returns true.. hass.states.async_set("availability_state.state", STATE_ON) await hass.async_block_till_done() @@ -252,57 +186,60 @@ async def test_available_template_with_entities(hass, calls): assert hass.states.get("vacuum.test_template_vacuum").state == STATE_UNAVAILABLE -async def test_invalid_availability_template_keeps_component_available(hass, caplog): +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, + "vacuum", + { + "vacuum": { + "platform": "template", + "vacuums": { + "test_template_vacuum": { + "availability_template": "{{ x - 12 }}", + "start": {"service": "script.vacuum_start"}, + } + }, + } + }, + ) + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, caplog, start_ha +): """Test that an invalid availability keeps the device available.""" - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "availability_template": "{{ x - 12 }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.get("vacuum.test_template_vacuum") != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + text = str([x.getMessage() for x in caplog.get_records("setup")]) + assert ("UndefinedError: \\'x\\' is undefined") in text -async def test_attribute_templates(hass, calls): +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, + "vacuum", + { + "vacuum": { + "platform": "template", + "vacuums": { + "test_template_vacuum": { + "value_template": "{{ 'cleaning' }}", + "start": {"service": "script.vacuum_start"}, + "attribute_templates": { + "test_attribute": "It {{ states.sensor.test_state.state }}." + }, + } + }, + } + }, + ) + ], +) +async def test_attribute_templates(hass, start_ha): """Test attribute_templates template.""" - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "value_template": "{{ 'cleaning' }}", - "start": {"service": "script.vacuum_start"}, - "attribute_templates": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("vacuum.test_template_vacuum") assert state.attributes["test_attribute"] == "It ." @@ -315,41 +252,101 @@ async def test_attribute_templates(hass, calls): assert state.attributes["test_attribute"] == "It Works." -async def test_invalid_attribute_template(hass, caplog): +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, + "vacuum", + { + "vacuum": { + "platform": "template", + "vacuums": { + "invalid_template": { + "value_template": "{{ states('input_select.state') }}", + "start": {"service": "script.vacuum_start"}, + "attribute_templates": { + "test_attribute": "{{ this_function_does_not_exist() }}" + }, + } + }, + } + }, + ) + ], +) +async def test_invalid_attribute_template(hass, caplog, start_ha): """Test that errors are logged if rendering template fails.""" - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "invalid_template": { - "value_template": "{{ states('input_select.state') }}", - "start": {"service": "script.vacuum_start"}, - "attribute_templates": { - "test_attribute": "{{ this_function_does_not_exist() }}" - }, - } - }, - } - }, - ) - await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 - await hass.async_start() + text = str([x.getMessage() for x in caplog.get_records("setup")]) + assert "test_attribute" in text + assert "TemplateError" in text + + +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, + "vacuum", + { + "vacuum": { + "platform": "template", + "vacuums": { + "test_template_vacuum_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "start": {"service": "script.vacuum_start"}, + }, + "test_template_vacuum_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "start": {"service": "script.vacuum_start"}, + }, + }, + } + }, + ), + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one vacuum per id.""" + assert len(hass.states.async_all()) == 1 + + +async def test_unused_services(hass): + """Test calling unused services should not crash.""" + await _register_basic_vacuum(hass) + + # Pause vacuum + await common.async_pause(hass, _TEST_VACUUM) await hass.async_block_till_done() - assert "test_attribute" in caplog.text - assert "TemplateError" in caplog.text + # Stop vacuum + await common.async_stop(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + # Return vacuum to base + await common.async_return_to_base(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + # Spot cleaning + await common.async_clean_spot(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + # Locate vacuum + await common.async_locate(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + # Set fan's speed + await common.async_set_fan_speed(hass, "medium", _TEST_VACUUM) + await hass.async_block_till_done() + + _verify(hass, STATE_UNKNOWN, None) -# End of template tests # - - -# Function tests # -async def test_state_services(hass, calls): +async def test_state_services(hass): """Test state services.""" await _register_components(hass) @@ -386,38 +383,7 @@ async def test_state_services(hass, calls): _verify(hass, STATE_RETURNING, None) -async def test_unused_services(hass, calls): - """Test calling unused services should not crash.""" - await _register_basic_vacuum(hass) - - # Pause vacuum - await common.async_pause(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Stop vacuum - await common.async_stop(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Return vacuum to base - await common.async_return_to_base(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Spot cleaning - await common.async_clean_spot(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Locate vacuum - await common.async_locate(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Set fan's speed - await common.async_set_fan_speed(hass, "medium", _TEST_VACUUM) - await hass.async_block_till_done() - - _verify(hass, STATE_UNKNOWN, None) - - -async def test_clean_spot_service(hass, calls): +async def test_clean_spot_service(hass): """Test clean spot service.""" await _register_components(hass) @@ -429,7 +395,7 @@ async def test_clean_spot_service(hass, calls): assert hass.states.get(_SPOT_CLEANING_INPUT_BOOLEAN).state == STATE_ON -async def test_locate_service(hass, calls): +async def test_locate_service(hass): """Test locate service.""" await _register_components(hass) @@ -441,7 +407,7 @@ async def test_locate_service(hass, calls): assert hass.states.get(_LOCATING_INPUT_BOOLEAN).state == STATE_ON -async def test_set_fan_speed(hass, calls): +async def test_set_fan_speed(hass): """Test set valid fan speed.""" await _register_components(hass) @@ -460,7 +426,7 @@ async def test_set_fan_speed(hass, calls): assert hass.states.get(_FAN_SPEED_INPUT_SELECT).state == "medium" -async def test_set_invalid_fan_speed(hass, calls): +async def test_set_invalid_fan_speed(hass): """Test set invalid fan speed when fan has valid speed.""" await _register_components(hass) @@ -611,34 +577,3 @@ async def _register_components(hass): await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - - -async def test_unique_id(hass): - """Test unique_id option only creates one vacuum per id.""" - await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - "start": {"service": "script.vacuum_start"}, - }, - "test_template_vacuum_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - "start": {"service": "script.vacuum_start"}, - }, - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 1 From 98a3ad6fd4b0b92f48e019eafdf1f76badaad29c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 24 Aug 2021 14:29:03 +0200 Subject: [PATCH 723/903] Revert "Please mypy in gtfs and implement needed changes (#54328)" (#55148) --- homeassistant/components/gtfs/sensor.py | 10 +++++----- mypy.ini | 3 +++ script/hassfest/mypy_config.py | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 138fdd96b89..f8f89b1ea36 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -255,7 +255,7 @@ WHEELCHAIR_BOARDING_DEFAULT = STATE_UNKNOWN WHEELCHAIR_BOARDING_OPTIONS = {1: True, 2: False} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { + { # type: ignore vol.Required(CONF_ORIGIN): cv.string, vol.Required(CONF_DESTINATION): cv.string, vol.Required(CONF_DATA): cv.string, @@ -490,7 +490,7 @@ def setup_platform( origin = config.get(CONF_ORIGIN) destination = config.get(CONF_DESTINATION) name = config.get(CONF_NAME) - offset = datetime.timedelta(seconds=float(config.get(CONF_OFFSET, 0))) + offset = config.get(CONF_OFFSET) include_tomorrow = config[CONF_TOMORROW] if not os.path.exists(gtfs_dir): @@ -541,10 +541,10 @@ class GTFSDepartureSensor(SensorEntity): self._icon = ICON self._name = "" self._state: str | None = None - self._attributes: dict[str, float | str] = {} + self._attributes = {} self._agency = None - self._departure: dict[str, Any] = {} + self._departure = {} self._destination = None self._origin = None self._route = None @@ -559,7 +559,7 @@ class GTFSDepartureSensor(SensorEntity): return self._name @property - def native_value(self) -> str | None: + def native_value(self) -> str | None: # type: ignore """Return the state of the sensor.""" return self._state diff --git a/mypy.ini b/mypy.ini index 82ed7d6ae9d..e566b7f1898 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1355,6 +1355,9 @@ ignore_errors = true [mypy-homeassistant.components.growatt_server.*] ignore_errors = true +[mypy-homeassistant.components.gtfs.*] +ignore_errors = true + [mypy-homeassistant.components.habitica.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 581b4865f7c..bb2cf72b72e 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -44,6 +44,7 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.gpmdp.*", "homeassistant.components.gree.*", "homeassistant.components.growatt_server.*", + "homeassistant.components.gtfs.*", "homeassistant.components.habitica.*", "homeassistant.components.harmony.*", "homeassistant.components.hassio.*", From ca245f8e93cb76915ffb7e84d094278b1dad633c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 24 Aug 2021 14:40:14 +0200 Subject: [PATCH 724/903] Fix min value for Xiaomi Miio volume entity (#55139) --- homeassistant/components/xiaomi_miio/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index d68d845cc5a..2d18aa3dedd 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -93,7 +93,7 @@ NUMBER_TYPES = { key=ATTR_VOLUME, name="Volume", icon="mdi:volume-high", - min_value=1, + min_value=0, max_value=100, step=1, method="async_set_volume", From 8d3ccad38ead4aab6524b686353b65616f6aa0bc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 24 Aug 2021 15:19:01 +0200 Subject: [PATCH 725/903] Convert number value to int in Xiaomi Miio (#55145) --- homeassistant/components/xiaomi_miio/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 2d18aa3dedd..e2043be4886 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -183,7 +183,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): async def async_set_value(self, value): """Set an option of the miio device.""" method = getattr(self, self.entity_description.method) - if await method(value): + if await method(int(value)): self._attr_value = value self.async_write_ha_state() From 8877f37da01a13ef6d12a48f9bd8339b5a4c2403 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Aug 2021 17:02:34 +0200 Subject: [PATCH 726/903] Fix statistics for sensors setting last_reset (#55136) * Re-add state_class total to sensor * Make energy cost sensor enforce state_class total_increasing * Drop state_class total * Only report energy sensor issues once --- homeassistant/components/energy/sensor.py | 26 ++++- homeassistant/components/recorder/models.py | 2 + .../components/recorder/statistics.py | 2 + homeassistant/components/sensor/recorder.py | 27 ++++- tests/components/energy/test_sensor.py | 98 ++++++++++++++++--- tests/components/history/test_init.py | 1 + tests/components/recorder/test_statistics.py | 3 + tests/components/sensor/test_recorder.py | 48 ++++++--- 8 files changed, 177 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 497c762add9..5d14d50cfe2 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -6,6 +6,7 @@ import logging from typing import Any, Final, Literal, TypeVar, cast from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, DEVICE_CLASS_MONETARY, STATE_CLASS_TOTAL_INCREASING, SensorEntity, @@ -188,6 +189,9 @@ class EnergyCostSensor(SensorEntity): utility. """ + _wrong_state_class_reported = False + _wrong_unit_reported = False + def __init__( self, adapter: SourceAdapter, @@ -223,6 +227,18 @@ class EnergyCostSensor(SensorEntity): if energy_state is None: return + if ( + state_class := energy_state.attributes.get(ATTR_STATE_CLASS) + ) != STATE_CLASS_TOTAL_INCREASING: + if not self._wrong_state_class_reported: + self._wrong_state_class_reported = True + _LOGGER.warning( + "Found unexpected state_class %s for %s", + state_class, + energy_state.entity_id, + ) + return + try: energy = float(energy_state.state) except ValueError: @@ -272,9 +288,13 @@ class EnergyCostSensor(SensorEntity): energy_unit = None if energy_unit is None: - _LOGGER.warning( - "Found unexpected unit %s for %s", energy_unit, energy_state.entity_id - ) + if not self._wrong_unit_reported: + self._wrong_unit_reported = True + _LOGGER.warning( + "Found unexpected unit %s for %s", + energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), + energy_state.entity_id, + ) return if energy < float(self._last_energy_sensor_state): diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 6c532e92292..017c65cd75f 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -226,6 +226,7 @@ class StatisticData(TypedDict, total=False): mean: float min: float max: float + last_reset: datetime | None state: float sum: float @@ -249,6 +250,7 @@ class Statistics(Base): # type: ignore mean = Column(DOUBLE_TYPE) min = Column(DOUBLE_TYPE) max = Column(DOUBLE_TYPE) + last_reset = Column(DATETIME_TYPE) state = Column(DOUBLE_TYPE) sum = Column(DOUBLE_TYPE) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index f8a9e3a6c89..34112fcc059 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -44,6 +44,7 @@ QUERY_STATISTICS = [ Statistics.mean, Statistics.min, Statistics.max, + Statistics.last_reset, Statistics.state, Statistics.sum, ] @@ -382,6 +383,7 @@ def _sorted_statistics_to_dict( "mean": convert(db_state.mean, units), "min": convert(db_state.min, units), "max": convert(db_state.max, units), + "last_reset": _process_timestamp_to_utc_isoformat(db_state.last_reset), "state": convert(db_state.state, units), "sum": convert(db_state.sum, units), } diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 8722fef0e99..b5c00f17141 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -42,6 +42,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, State +import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util import homeassistant.util.temperature as temperature_util import homeassistant.util.volume as volume_util @@ -273,11 +274,13 @@ def compile_statistics( stat["mean"] = _time_weighted_average(fstates, start, end) if "sum" in wanted_statistics: + last_reset = old_last_reset = None new_state = old_state = None _sum = 0 last_stats = statistics.get_last_statistics(hass, 1, entity_id) if entity_id in last_stats: # We have compiled history for this sensor before, use that as a starting point + last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] new_state = old_state = last_stats[entity_id][0]["state"] _sum = last_stats[entity_id][0]["sum"] @@ -291,7 +294,13 @@ def compile_statistics( continue reset = False - if old_state is None: + if ( + state_class != STATE_CLASS_TOTAL_INCREASING + and (last_reset := state.attributes.get("last_reset")) + != old_last_reset + ): + reset = True + elif old_state is None and last_reset is None: reset = True _LOGGER.info( "Compiling initial sum statistics for %s, zero point set to %s", @@ -315,14 +324,24 @@ def compile_statistics( _sum += new_state - old_state # ..and update the starting point new_state = fstate - # Force a new cycle to start at 0 - if old_state is not None: + old_last_reset = last_reset + # Force a new cycle for STATE_CLASS_TOTAL_INCREASING to start at 0 + if ( + state_class == STATE_CLASS_TOTAL_INCREASING + and old_state is not None + ): old_state = 0.0 else: old_state = new_state else: new_state = fstate + # Deprecated, will be removed in Home Assistant 2021.11 + if last_reset is None and state_class == STATE_CLASS_MEASUREMENT: + # No valid updates + result.pop(entity_id) + continue + if new_state is None or old_state is None: # No valid updates result.pop(entity_id) @@ -330,6 +349,8 @@ def compile_statistics( # Update the sum with the last state _sum += new_state - old_state + if last_reset is not None: + stat["last_reset"] = dt_util.parse_datetime(last_reset) stat["sum"] = _sum stat["state"] = new_state diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index ea183ec52f4..7cb2640d3d2 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( DEVICE_CLASS_MONETARY, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, + STATE_UNKNOWN, VOLUME_CUBIC_METERS, ) from homeassistant.setup import async_setup_component @@ -93,6 +94,11 @@ async def test_cost_sensor_price_entity( def _compile_statistics(_): return compile_statistics(hass, now, now + timedelta(seconds=1)) + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + } + await async_init_recorder_component(hass) energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( @@ -136,7 +142,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, initial_energy, - {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + energy_attributes, ) hass.states.async_set("sensor.energy_price", "1") @@ -155,9 +161,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "0", - { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - }, + energy_attributes, ) await hass.async_block_till_done() @@ -176,7 +180,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "10", - {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) @@ -200,7 +204,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "14.5", - {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) @@ -216,7 +220,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "4", - {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) @@ -226,7 +230,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "10", - {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) @@ -241,6 +245,10 @@ async def test_cost_sensor_price_entity( async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: """Test energy cost price from sensor entity.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + } energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( { @@ -269,7 +277,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: hass.states.async_set( "sensor.energy_consumption", 10000, - {"unit_of_measurement": ENERGY_WATT_HOUR}, + energy_attributes, ) with patch("homeassistant.util.dt.utcnow", return_value=now): @@ -282,7 +290,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: hass.states.async_set( "sensor.energy_consumption", 20000, - {"unit_of_measurement": ENERGY_WATT_HOUR}, + energy_attributes, ) await hass.async_block_till_done() @@ -292,6 +300,10 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: """Test gas cost price from sensor entity.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + } energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( { @@ -314,7 +326,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: hass.states.async_set( "sensor.gas_consumption", 100, - {"unit_of_measurement": VOLUME_CUBIC_METERS}, + energy_attributes, ) with patch("homeassistant.util.dt.utcnow", return_value=now): @@ -327,9 +339,71 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: hass.states.async_set( "sensor.gas_consumption", 200, - {"unit_of_measurement": VOLUME_CUBIC_METERS}, + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get("sensor.gas_consumption_cost") assert state.state == "50.0" + + +@pytest.mark.parametrize("state_class", [None]) +async def test_cost_sensor_wrong_state_class( + hass, hass_storage, caplog, state_class +) -> None: + """Test energy sensor rejects wrong state_class.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: state_class, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + + hass.states.async_set( + "sensor.energy_consumption", + 10000, + energy_attributes, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN + assert ( + f"Found unexpected state_class {state_class} for sensor.energy_consumption" + in caplog.text + ) + + # Energy use bumped to 10 kWh + hass.states.async_set( + "sensor.energy_consumption", + 20000, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 8de44843626..7909d8f0239 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -911,6 +911,7 @@ async def test_statistics_during_period( "mean": approx(value), "min": approx(value), "max": approx(value), + "last_reset": None, "state": None, "sum": None, } diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 995ad537ab4..318d82422d7 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -44,6 +44,7 @@ def test_compile_hourly_statistics(hass_recorder): "mean": approx(14.915254237288135), "min": approx(10.0), "max": approx(20.0), + "last_reset": None, "state": None, "sum": None, } @@ -53,6 +54,7 @@ def test_compile_hourly_statistics(hass_recorder): "mean": approx(20.0), "min": approx(20.0), "max": approx(20.0), + "last_reset": None, "state": None, "sum": None, } @@ -125,6 +127,7 @@ def test_rename_entity(hass_recorder): "mean": approx(14.915254237288135), "min": approx(10.0), "max": approx(20.0), + "last_reset": None, "state": None, "sum": None, } diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 3a2572f8141..660c63de599 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -95,6 +95,7 @@ def test_compile_hourly_statistics( "mean": approx(mean), "min": approx(min), "max": approx(max), + "last_reset": None, "state": None, "sum": None, } @@ -144,6 +145,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "mean": approx(16.440677966101696), "min": approx(10.0), "max": approx(30.0), + "last_reset": None, "state": None, "sum": None, } @@ -152,6 +154,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize("state_class", ["measurement"]) @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -164,7 +167,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes ], ) def test_compile_hourly_sum_statistics_amount( - hass_recorder, caplog, device_class, unit, native_unit, factor + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor ): """Test compiling hourly statistics.""" zero = dt_util.utcnow() @@ -173,7 +176,7 @@ def test_compile_hourly_sum_statistics_amount( setup_component(hass, "sensor", {}) attributes = { "device_class": device_class, - "state_class": "measurement", + "state_class": state_class, "unit_of_measurement": unit, "last_reset": None, } @@ -206,6 +209,7 @@ def test_compile_hourly_sum_statistics_amount( "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), }, @@ -215,8 +219,9 @@ def test_compile_hourly_sum_statistics_amount( "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[5]), - "sum": approx(factor * 30.0), + "sum": approx(factor * 10.0), }, { "statistic_id": "sensor.test1", @@ -224,8 +229,9 @@ def test_compile_hourly_sum_statistics_amount( "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[8]), - "sum": approx(factor * 60.0), + "sum": approx(factor * 40.0), }, ] } @@ -283,6 +289,7 @@ def test_compile_hourly_sum_statistics_total_increasing( "max": None, "mean": None, "min": None, + "last_reset": None, "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), }, @@ -292,6 +299,7 @@ def test_compile_hourly_sum_statistics_total_increasing( "max": None, "mean": None, "min": None, + "last_reset": None, "state": approx(factor * seq[5]), "sum": approx(factor * 50.0), }, @@ -301,6 +309,7 @@ def test_compile_hourly_sum_statistics_total_increasing( "max": None, "mean": None, "min": None, + "last_reset": None, "state": approx(factor * seq[8]), "sum": approx(factor * 80.0), }, @@ -367,6 +376,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(20.0), "sum": approx(10.0), }, @@ -376,8 +386,9 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(30.0), + "sum": approx(10.0), }, { "statistic_id": "sensor.test1", @@ -385,8 +396,9 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(60.0), + "sum": approx(40.0), }, ] } @@ -447,6 +459,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(20.0), "sum": approx(10.0), }, @@ -456,8 +469,9 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(30.0), + "sum": approx(10.0), }, { "statistic_id": "sensor.test1", @@ -465,8 +479,9 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(60.0), + "sum": approx(40.0), }, ], "sensor.test2": [ @@ -476,6 +491,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(130.0), "sum": approx(20.0), }, @@ -485,8 +501,9 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(45.0), - "sum": approx(-65.0), + "sum": approx(-95.0), }, { "statistic_id": "sensor.test2", @@ -494,8 +511,9 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(75.0), - "sum": approx(-35.0), + "sum": approx(-65.0), }, ], "sensor.test3": [ @@ -505,6 +523,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(zero), "state": approx(5.0 / 1000), "sum": approx(5.0 / 1000), }, @@ -514,8 +533,9 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(50.0 / 1000), - "sum": approx(50.0 / 1000), + "sum": approx(30.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -523,8 +543,9 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "max": None, "mean": None, "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(90.0 / 1000), - "sum": approx(90.0 / 1000), + "sum": approx(70.0 / 1000), }, ], } @@ -575,6 +596,7 @@ def test_compile_hourly_statistics_unchanged( "mean": approx(value), "min": approx(value), "max": approx(value), + "last_reset": None, "state": None, "sum": None, } @@ -606,6 +628,7 @@ def test_compile_hourly_statistics_partially_unavailable(hass_recorder, caplog): "mean": approx(21.1864406779661), "min": approx(10.0), "max": approx(25.0), + "last_reset": None, "state": None, "sum": None, } @@ -662,6 +685,7 @@ def test_compile_hourly_statistics_unavailable( "mean": approx(value), "min": approx(value), "max": approx(value), + "last_reset": None, "state": None, "sum": None, } From fa9f91325ca8e398b706f3a6248c72b3661daec5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Aug 2021 17:23:55 +0200 Subject: [PATCH 727/903] Allow small dip in total_increasing sensor without detecting a reset (#55153) --- homeassistant/components/energy/sensor.py | 3 +- homeassistant/components/sensor/recorder.py | 7 +- tests/components/energy/test_sensor.py | 12 +++- tests/components/sensor/test_recorder.py | 72 +++++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 5d14d50cfe2..099ea8df0ab 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) +from homeassistant.components.sensor.recorder import reset_detected from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, ENERGY_KILO_WATT_HOUR, @@ -297,7 +298,7 @@ class EnergyCostSensor(SensorEntity): ) return - if energy < float(self._last_energy_sensor_state): + if reset_detected(energy, float(self._last_energy_sensor_state)): # Energy meter was reset, reset cost sensor too self._reset(0) # Update with newly incurred cost diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index b5c00f17141..6dc91b52c9a 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -226,6 +226,11 @@ def _normalize_states( return DEVICE_CLASS_UNITS[key], fstates +def reset_detected(state: float, previous_state: float | None) -> bool: + """Test if a total_increasing sensor has been reset.""" + return previous_state is not None and state < 0.9 * previous_state + + def compile_statistics( hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> dict: @@ -308,7 +313,7 @@ def compile_statistics( fstate, ) elif state_class == STATE_CLASS_TOTAL_INCREASING and ( - old_state is None or (new_state is not None and fstate < new_state) + old_state is None or reset_detected(fstate, new_state) ): reset = True _LOGGER.info( diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 7cb2640d3d2..1375a1c292c 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -216,6 +216,16 @@ async def test_cost_sensor_price_entity( assert cost_sensor_entity_id in statistics assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 + # Energy sensor has a small dip, no reset should be detected + hass.states.async_set( + usage_sensor_entity_id, + "14", + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point hass.states.async_set( usage_sensor_entity_id, @@ -240,7 +250,7 @@ async def test_cost_sensor_price_entity( await async_wait_recording_done_without_instance(hass) statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) assert cost_sensor_entity_id in statistics - assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 39.0 + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 38.0 async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 660c63de599..1c1d5c52462 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -318,6 +318,78 @@ def test_compile_hourly_sum_statistics_total_increasing( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [("energy", "kWh", "kWh", 1)], +) +def test_compile_hourly_sum_statistics_total_increasing_small_dip( + hass_recorder, caplog, device_class, unit, native_unit, factor +): + """Test small dips in sensor readings do not trigger a reset.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "total_increasing", + "unit_of_measurement": unit, + } + seq = [10, 15, 20, 19, 30, 40, 50, 60, 70] + + four, eight, states = record_meter_states( + hass, zero, "sensor.test1", attributes, seq + ) + hist = history.get_significant_states( + hass, zero - timedelta.resolution, eight + timedelta.resolution + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "state": approx(factor * seq[2]), + "sum": approx(factor * 10.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "max": None, + "mean": None, + "min": None, + "state": approx(factor * seq[5]), + "sum": approx(factor * 30.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "max": None, + "mean": None, + "min": None, + "state": approx(factor * seq[8]), + "sum": approx(factor * 60.0), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): """Test compiling hourly statistics.""" zero = dt_util.utcnow() From 2e62de51166e54b2b167b772ac9db782bd0ec7d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Aug 2021 10:51:25 -0500 Subject: [PATCH 728/903] Adjust yeelight homekit model match (#55159) --- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/generated/zeroconf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 8d3d7be6f33..5910341cfb4 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -12,6 +12,6 @@ "hostname": "yeelink-*" }], "homekit": { - "models": ["YLD*"] + "models": ["YL*"] } } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index d973698a34b..8a68842475e 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -262,7 +262,7 @@ HOMEKIT = { "Touch HD": "rainmachine", "Welcome": "netatmo", "Wemo": "wemo", - "YLD*": "yeelight", + "YL*": "yeelight", "iSmartGate": "gogogate2", "iZone": "izone", "tado": "tado" From abfba1f4556493518dcd5548eefb806a97281ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20M=C3=BCndler?= Date: Tue, 24 Aug 2021 17:56:36 +0200 Subject: [PATCH 729/903] Handle missing mac address in syncthru (#55154) * Fix access errors to mac address printer.raw() is the only attribute accessed and will always be present. However depending on the printer, the mac address might be missing. * Update homeassistant/components/syncthru/__init__.py Co-authored-by: J. Nick Koston * Update homeassistant/components/syncthru/__init__.py Co-authored-by: J. Nick Koston * Update homeassistant/components/syncthru/__init__.py Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- homeassistant/components/syncthru/__init__.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index 9045b82e2ac..c422bfa6f33 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -86,11 +86,6 @@ def device_identifiers(printer: SyncThru) -> set[tuple[str, str]] | None: def device_connections(printer: SyncThru) -> set[tuple[str, str]]: """Get device connections for device registry.""" - connections = set() - try: - mac = printer.raw()["identity"]["mac_addr"] - if mac: - connections.add((dr.CONNECTION_NETWORK_MAC, mac)) - except AttributeError: - pass - return connections + if mac := printer.raw().get("identity", {}).get("mac_addr"): + return {(dr.CONNECTION_NETWORK_MAC, mac)} + return set() From 2c997586ebae4779e7f6ea85d648c42dc9c6c093 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Aug 2021 18:06:39 +0200 Subject: [PATCH 730/903] Deduplicate code in MQTT alarm_control_panel tests (#55149) --- .../mqtt/test_alarm_control_panel.py | 383 +++++------------- 1 file changed, 96 insertions(+), 287 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index c05aa052b5a..e01b246b8af 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -10,6 +10,14 @@ from homeassistant.components.mqtt.alarm_control_panel import ( MQTT_ALARM_ATTRIBUTES_BLOCKED, ) from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISARM, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, @@ -153,8 +161,19 @@ async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_UNKNOWN -async def test_arm_home_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while armed.""" +@pytest.mark.parametrize( + "service,payload", + [ + (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), + (SERVICE_ALARM_DISARM, "DISARM"), + ], +) +async def test_publish_mqtt_no_code(hass, mqtt_mock, service, payload): + """Test publishing of MQTT messages when no code is configured.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -162,194 +181,85 @@ async def test_arm_home_publishes_mqtt(hass, mqtt_mock): ) await hass.async_block_till_done() - await common.async_alarm_arm_home(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_HOME", 0, False + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, ) + mqtt_mock.async_publish.assert_called_once_with("alarm/command", payload, 0, False) -async def test_arm_home_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_mock): - """Test not publishing of MQTT messages with invalid. - When code_arm_required = True - """ +@pytest.mark.parametrize( + "service,payload", + [ + (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), + (SERVICE_ALARM_DISARM, "DISARM"), + ], +) +async def test_publish_mqtt_with_code(hass, mqtt_mock, service, payload): + """Test publishing of MQTT messages when code is configured.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) await hass.async_block_till_done() - call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_arm_home(hass, "abcd") + + # No code provided, should not publish + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, + ) assert mqtt_mock.async_publish.call_count == call_count - -async def test_arm_home_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages. - - When code_arm_required = False - """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[alarm_control_panel.DOMAIN]["code_arm_required"] = False - assert await async_setup_component( - hass, + # Wrong code provided, should not publish + await hass.services.async_call( alarm_control_panel.DOMAIN, - config, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "abcd"}, + blocking=True, ) - await hass.async_block_till_done() - - await common.async_alarm_arm_home(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_HOME", 0, False - ) - - -async def test_arm_away_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while armed.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - - await common.async_alarm_arm_away(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_AWAY", 0, False - ) - - -async def test_arm_away_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_mock): - """Test not publishing of MQTT messages with invalid code. - - When code_arm_required = True - """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG_CODE, - ) - await hass.async_block_till_done() - - call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_arm_away(hass, "abcd") assert mqtt_mock.async_publish.call_count == call_count - -async def test_arm_away_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages. - - When code_arm_required = False - """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[alarm_control_panel.DOMAIN]["code_arm_required"] = False - assert await async_setup_component( - hass, + # Correct code provided, should publish + await hass.services.async_call( alarm_control_panel.DOMAIN, - config, - ) - await hass.async_block_till_done() - - await common.async_alarm_arm_away(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_AWAY", 0, False + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "0123"}, + blocking=True, ) + mqtt_mock.async_publish.assert_called_once_with("alarm/command", payload, 0, False) -async def test_arm_night_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while armed.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - - await common.async_alarm_arm_night(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_NIGHT", 0, False - ) - - -async def test_arm_night_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_mock): - """Test not publishing of MQTT messages with invalid code. - - When code_arm_required = True - """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG_CODE, - ) - await hass.async_block_till_done() - - call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_arm_night(hass, "abcd") - assert mqtt_mock.async_publish.call_count == call_count - - -async def test_arm_night_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages. - - When code_arm_required = False - """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[alarm_control_panel.DOMAIN]["code_arm_required"] = False - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - config, - ) - await hass.async_block_till_done() - - await common.async_alarm_arm_night(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_NIGHT", 0, False - ) - - -async def test_arm_vacation_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while armed.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - - await common.async_alarm_arm_vacation(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_VACATION", 0, False - ) - - -async def test_arm_vacation_not_publishes_mqtt_with_invalid_code_when_req( - hass, mqtt_mock +@pytest.mark.parametrize( + "service,payload,disable_code", + [ + (SERVICE_ALARM_ARM_HOME, "ARM_HOME", "code_arm_required"), + (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY", "code_arm_required"), + (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT", "code_arm_required"), + (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION", "code_arm_required"), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS", "code_arm_required"), + (SERVICE_ALARM_DISARM, "DISARM", "code_disarm_required"), + ], +) +async def test_publish_mqtt_with_code_required_false( + hass, mqtt_mock, service, payload, disable_code ): - """Test not publishing of MQTT messages with invalid code. + """Test publishing of MQTT messages when code is configured. - When code_arm_required = True - """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG_CODE, - ) - await hass.async_block_till_done() - - call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_arm_vacation(hass, "abcd") - assert mqtt_mock.async_publish.call_count == call_count - - -async def test_arm_vacation_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages. - - When code_arm_required = False + code_arm_required = False / code_disarm_required = false """ config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[alarm_control_panel.DOMAIN]["code_arm_required"] = False + config[alarm_control_panel.DOMAIN][disable_code] = False assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -357,100 +267,35 @@ async def test_arm_vacation_publishes_mqtt_when_code_not_req(hass, mqtt_mock): ) await hass.async_block_till_done() - await common.async_alarm_arm_vacation(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_VACATION", 0, False - ) - - -async def test_arm_custom_bypass_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while armed.""" - assert await async_setup_component( - hass, + # No code provided, should publish + await hass.services.async_call( alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - } - }, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, ) - await hass.async_block_till_done() + mqtt_mock.async_publish.assert_called_once_with("alarm/command", payload, 0, False) + mqtt_mock.reset_mock() - await common.async_alarm_arm_custom_bypass(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_CUSTOM_BYPASS", 0, False - ) - - -async def test_arm_custom_bypass_not_publishes_mqtt_with_invalid_code_when_req( - hass, mqtt_mock -): - """Test not publishing of MQTT messages with invalid code. - - When code_arm_required = True - """ - assert await async_setup_component( - hass, + # Wrong code provided, should publish + await hass.services.async_call( alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_arm_required": True, - } - }, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "abcd"}, + blocking=True, ) - await hass.async_block_till_done() + mqtt_mock.async_publish.assert_called_once_with("alarm/command", payload, 0, False) + mqtt_mock.reset_mock() - call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_arm_custom_bypass(hass, "abcd") - assert mqtt_mock.async_publish.call_count == call_count - - -async def test_arm_custom_bypass_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages. - - When code_arm_required = False - """ - assert await async_setup_component( - hass, + # Correct code provided, should publish + await hass.services.async_call( alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_arm_required": False, - } - }, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "0123"}, + blocking=True, ) - await hass.async_block_till_done() - - await common.async_alarm_arm_custom_bypass(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_CUSTOM_BYPASS", 0, False - ) - - -async def test_disarm_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while disarmed.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - - await common.async_alarm_disarm(hass) - mqtt_mock.async_publish.assert_called_once_with("alarm/command", "DISARM", 0, False) + mqtt_mock.async_publish.assert_called_once_with("alarm/command", payload, 0, False) + mqtt_mock.reset_mock() async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock): @@ -476,42 +321,6 @@ async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock): ) -async def test_disarm_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages while disarmed. - - When code_disarm_required = False - """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[alarm_control_panel.DOMAIN]["code"] = "1234" - config[alarm_control_panel.DOMAIN]["code_disarm_required"] = False - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - config, - ) - await hass.async_block_till_done() - - await common.async_alarm_disarm(hass) - mqtt_mock.async_publish.assert_called_once_with("alarm/command", "DISARM", 0, False) - - -async def test_disarm_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_mock): - """Test not publishing of MQTT messages with invalid code. - - When code_disarm_required = True - """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG_CODE, - ) - await hass.async_block_till_done() - - call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_disarm(hass, "abcd") - assert mqtt_mock.async_publish.call_count == call_count - - async def test_update_state_via_state_topic_template(hass, mqtt_mock): """Test updating with template_value via state topic.""" assert await async_setup_component( From 38f0020619aaddabd051944a5014a30bfb4f39ac Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Aug 2021 18:08:17 +0200 Subject: [PATCH 731/903] Improve Tasmota MQTT discovery flow (#55147) --- .../components/tasmota/config_flow.py | 17 ++--- tests/components/tasmota/test_config_flow.py | 70 ++++++++++++++++--- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py index 85959ef0674..435604b4bdd 100644 --- a/homeassistant/components/tasmota/config_flow.py +++ b/homeassistant/components/tasmota/config_flow.py @@ -29,16 +29,17 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DOMAIN) - # Validate the topic, will throw if it fails - prefix = discovery_info["subscribed_topic"] - if prefix.endswith("/#"): - prefix = prefix[:-2] - try: - valid_subscribe_topic(f"{prefix}/#") - except vol.Invalid: + # Validate the message, abort if it fails + if not discovery_info["topic"].endswith("/config"): + # Not a Tasmota discovery message + return self.async_abort(reason="invalid_discovery_info") + if not discovery_info["payload"]: + # Empty payload, the Tasmota is not configured for native discovery return self.async_abort(reason="invalid_discovery_info") - self._prefix = prefix + # "tasmota/discovery/#" is hardcoded in Tasmota's manifest + assert discovery_info["subscribed_topic"] == "tasmota/discovery/#" + self._prefix = "tasmota/discovery" return await self.async_step_confirm() diff --git a/tests/components/tasmota/test_config_flow.py b/tests/components/tasmota/test_config_flow.py index c97ffb9edb8..7d6d0628de1 100644 --- a/tests/components/tasmota/test_config_flow.py +++ b/tests/components/tasmota/test_config_flow.py @@ -19,11 +19,20 @@ async def test_mqtt_abort_if_existing_entry(hass, mqtt_mock): async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): """Check MQTT flow aborts if discovery topic is invalid.""" discovery_info = { - "topic": "", - "payload": "", + "topic": "tasmota/discovery/DC4F220848A2/bla", + "payload": ( + '{"ip":"192.168.0.136","dn":"Tasmota","fn":["Tasmota",null,null,null,null,' + 'null,null,null],"hn":"tasmota_0848A2","mac":"DC4F220848A2","md":"Sonoff Basic",' + '"ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON",' + '"TOGGLE","HOLD"],"sw":"9.4.0.4","t":"tasmota_0848A2","ft":"%topic%/%prefix%/",' + '"tp":["cmnd","stat","tele"],"rl":[1,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],' + '"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],' + '"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":1,"117":0},' + '"lk":1,"lt_st":0,"sho":[0,0,0,0],"ver":1}' + ), "qos": 0, "retain": False, - "subscribed_topic": "custom_prefix/##", + "subscribed_topic": "tasmota/discovery/#", "timestamp": None, } result = await hass.config_entries.flow.async_init( @@ -32,15 +41,60 @@ async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): assert result["type"] == "abort" assert result["reason"] == "invalid_discovery_info" + discovery_info = { + "topic": "tasmota/discovery/DC4F220848A2/config", + "payload": "", + "qos": 0, + "retain": False, + "subscribed_topic": "tasmota/discovery/#", + "timestamp": None, + } + result = await hass.config_entries.flow.async_init( + "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + discovery_info = { + "topic": "tasmota/discovery/DC4F220848A2/config", + "payload": ( + '{"ip":"192.168.0.136","dn":"Tasmota","fn":["Tasmota",null,null,null,null,' + 'null,null,null],"hn":"tasmota_0848A2","mac":"DC4F220848A2","md":"Sonoff Basic",' + '"ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON",' + '"TOGGLE","HOLD"],"sw":"9.4.0.4","t":"tasmota_0848A2","ft":"%topic%/%prefix%/",' + '"tp":["cmnd","stat","tele"],"rl":[1,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],' + '"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],' + '"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":1,"117":0},' + '"lk":1,"lt_st":0,"sho":[0,0,0,0],"ver":1}' + ), + "qos": 0, + "retain": False, + "subscribed_topic": "tasmota/discovery/#", + "timestamp": None, + } + result = await hass.config_entries.flow.async_init( + "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] == "form" + async def test_mqtt_setup(hass, mqtt_mock) -> None: """Test we can finish a config flow through MQTT with custom prefix.""" discovery_info = { - "topic": "", - "payload": "", + "topic": "tasmota/discovery/DC4F220848A2/config", + "payload": ( + '{"ip":"192.168.0.136","dn":"Tasmota","fn":["Tasmota",null,null,null,null,' + 'null,null,null],"hn":"tasmota_0848A2","mac":"DC4F220848A2","md":"Sonoff Basic",' + '"ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON",' + '"TOGGLE","HOLD"],"sw":"9.4.0.4","t":"tasmota_0848A2","ft":"%topic%/%prefix%/",' + '"tp":["cmnd","stat","tele"],"rl":[1,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],' + '"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],' + '"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":1,"117":0},' + '"lk":1,"lt_st":0,"sho":[0,0,0,0],"ver":1}' + ), "qos": 0, "retain": False, - "subscribed_topic": "custom_prefix/123/#", + "subscribed_topic": "tasmota/discovery/#", "timestamp": None, } result = await hass.config_entries.flow.async_init( @@ -51,9 +105,7 @@ async def test_mqtt_setup(hass, mqtt_mock) -> None: result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "create_entry" - assert result["result"].data == { - "discovery_prefix": "custom_prefix/123", - } + assert result["result"].data == {"discovery_prefix": "tasmota/discovery"} async def test_user_setup(hass, mqtt_mock): From 39d5ae77a935b735b0ebe22782db086a465998bf Mon Sep 17 00:00:00 2001 From: Aaron David Schneider Date: Tue, 24 Aug 2021 18:10:32 +0200 Subject: [PATCH 732/903] Address late review of Fritz switch (#54842) --- homeassistant/components/fritz/switch.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index da17bef7159..430817d4506 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -408,11 +408,10 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity): """Turn off switch.""" await self._async_handle_turn_on_off(turn_on=False) - async def _async_handle_turn_on_off(self, turn_on: bool) -> bool: + async def _async_handle_turn_on_off(self, turn_on: bool) -> None: """Handle switch state change request.""" await self._switch(turn_on) self._attr_is_on = turn_on - return True class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): @@ -468,9 +467,9 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): self._is_available = True attributes_dict = { - "NewInternalClient": "internalIP", - "NewInternalPort": "internalPort", - "NewExternalPort": "externalPort", + "NewInternalClient": "internal_ip", + "NewInternalPort": "internal_port", + "NewExternalPort": "external_port", "NewProtocol": "protocol", "NewPortMappingDescription": "description", } @@ -547,15 +546,15 @@ class FritzBoxDeflectionSwitch(FritzBoxBaseSwitch, SwitchEntity): self._attr_is_on = self.dict_of_deflection["Enable"] == "1" self._is_available = True - self._attributes["Type"] = self.dict_of_deflection["Type"] - self._attributes["Number"] = self.dict_of_deflection["Number"] - self._attributes["DeflectionToNumber"] = self.dict_of_deflection[ + self._attributes["type"] = self.dict_of_deflection["Type"] + self._attributes["number"] = self.dict_of_deflection["Number"] + self._attributes["deflection_to_number"] = self.dict_of_deflection[ "DeflectionToNumber" ] # Return mode sample: "eImmediately" - self._attributes["Mode"] = self.dict_of_deflection["Mode"][1:] - self._attributes["Outgoing"] = self.dict_of_deflection["Outgoing"] - self._attributes["PhonebookID"] = self.dict_of_deflection["PhonebookID"] + self._attributes["mode"] = self.dict_of_deflection["Mode"][1:] + self._attributes["outgoing"] = self.dict_of_deflection["Outgoing"] + self._attributes["phonebook_id"] = self.dict_of_deflection["PhonebookID"] async def _async_switch_on_off_executor(self, turn_on: bool) -> None: """Handle deflection switch.""" @@ -674,7 +673,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch, SwitchEntity): std = wifi_info["NewStandard"] self._attributes["standard"] = std if std else None - self._attributes["BSSID"] = wifi_info["NewBSSID"] + self._attributes["bssid"] = wifi_info["NewBSSID"] self._attributes["mac_address_control"] = wifi_info[ "NewMACAddressControlEnabled" ] From 29f1fab7f7fa2ad8f6574343000f0c0dfe46e4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 24 Aug 2021 18:46:44 +0200 Subject: [PATCH 733/903] Move to aiogithubapi any async for the GitHub integration (#55143) --- CODEOWNERS | 2 +- homeassistant/components/github/manifest.json | 11 +- homeassistant/components/github/sensor.py | 270 ++++++++++-------- requirements_all.txt | 6 +- 4 files changed, 168 insertions(+), 121 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d9652519ed8..35216f34b67 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -188,7 +188,7 @@ homeassistant/components/geo_rss_events/* @exxamalte homeassistant/components/geonetnz_quakes/* @exxamalte homeassistant/components/geonetnz_volcano/* @exxamalte homeassistant/components/gios/* @bieniu -homeassistant/components/github/* @timmo001 +homeassistant/components/github/* @timmo001 @ludeeus homeassistant/components/gitter/* @fabaff homeassistant/components/glances/* @fabaff @engrbm87 homeassistant/components/goalzero/* @tkdrob diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json index 40693244b91..ce2ad4e047b 100644 --- a/homeassistant/components/github/manifest.json +++ b/homeassistant/components/github/manifest.json @@ -2,7 +2,12 @@ "domain": "github", "name": "GitHub", "documentation": "https://www.home-assistant.io/integrations/github", - "requirements": ["PyGithub==1.43.8"], - "codeowners": ["@timmo001"], + "requirements": [ + "aiogithubapi==21.8.0" + ], + "codeowners": [ + "@timmo001", + "@ludeeus" + ], "iot_class": "cloud_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index fb7a0167d8a..56cd7137504 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -1,8 +1,11 @@ -"""Support for GitHub.""" +"""Sensor platform for the GitHub integratiom.""" +from __future__ import annotations + +import asyncio from datetime import timedelta import logging -import github +from aiogithubapi import GitHubAPI, GitHubException import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -13,6 +16,7 @@ from homeassistant.const import ( CONF_PATH, CONF_URL, ) +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -52,23 +56,19 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the GitHub sensor platform.""" sensors = [] + session = async_get_clientsession(hass) for repository in config[CONF_REPOS]: data = GitHubData( repository=repository, - access_token=config.get(CONF_ACCESS_TOKEN), + access_token=config[CONF_ACCESS_TOKEN], + session=session, server_url=config.get(CONF_URL), ) - if data.setup_error is True: - _LOGGER.error( - "Error setting up GitHub platform. %s", - "Check previous errors for details", - ) - else: - sensors.append(GitHubSensor(data)) - add_entities(sensors, True) + sensors.append(GitHubSensor(data)) + async_add_entities(sensors, True) class GitHubSensor(SensorEntity): @@ -121,7 +121,7 @@ class GitHubSensor(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" attrs = { - ATTR_PATH: self._repository_path, + ATTR_PATH: self._github_data.repository_path, ATTR_NAME: self._name, ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message, ATTR_LATEST_COMMIT_SHA: self._latest_commit_sha, @@ -150,122 +150,164 @@ class GitHubSensor(SensorEntity): """Return the icon to use in the frontend.""" return "mdi:github" - def update(self): + async def async_update(self): """Collect updated data from GitHub API.""" - self._github_data.update() + await self._github_data.async_update() + self._available = self._github_data.available + if not self._available: + return self._name = self._github_data.name - self._repository_path = self._github_data.repository_path - self._available = self._github_data.available - self._latest_commit_message = self._github_data.latest_commit_message - self._latest_commit_sha = self._github_data.latest_commit_sha - if self._github_data.latest_release_url is not None: - self._latest_release_tag = self._github_data.latest_release_url.split( - "tag/" - )[1] - else: - self._latest_release_tag = None - self._latest_release_url = self._github_data.latest_release_url - self._state = self._github_data.latest_commit_sha[0:7] - self._open_issue_count = self._github_data.open_issue_count - self._latest_open_issue_url = self._github_data.latest_open_issue_url - self._pull_request_count = self._github_data.pull_request_count - self._latest_open_pr_url = self._github_data.latest_open_pr_url - self._stargazers = self._github_data.stargazers - self._forks = self._github_data.forks - self._clones = self._github_data.clones - self._clones_unique = self._github_data.clones_unique - self._views = self._github_data.views - self._views_unique = self._github_data.views_unique + self._state = self._github_data.last_commit.sha[0:7] + + self._latest_commit_message = self._github_data.last_commit.commit.message + self._latest_commit_sha = self._github_data.last_commit.sha + self._stargazers = self._github_data.repository_response.data.stargazers_count + self._forks = self._github_data.repository_response.data.forks_count + + self._pull_request_count = len(self._github_data.pulls_response.data) + self._open_issue_count = ( + self._github_data.repository_response.data.open_issues_count or 0 + ) - self._pull_request_count + + if self._github_data.last_release: + self._latest_release_tag = self._github_data.last_release.tag_name + self._latest_release_url = self._github_data.last_release.html_url + + if self._github_data.last_issue: + self._latest_open_issue_url = self._github_data.last_issue.html_url + + if self._github_data.last_pull_request: + self._latest_open_pr_url = self._github_data.last_pull_request.html_url + + if self._github_data.clones_response: + self._clones = self._github_data.clones_response.data.count + self._clones_unique = self._github_data.clones_response.data.uniques + + if self._github_data.views_response: + self._views = self._github_data.views_response.data.count + self._views_unique = self._github_data.views_response.data.uniques class GitHubData: """GitHub Data object.""" - def __init__(self, repository, access_token=None, server_url=None): + def __init__(self, repository, access_token, session, server_url=None): """Set up GitHub.""" - self._github = github + self._repository = repository + self.repository_path = repository[CONF_PATH] + self._github = GitHubAPI( + token=access_token, session=session, **{"base_url": server_url} + ) - self.setup_error = False - - try: - if server_url is not None: - server_url += "/api/v3" - self._github_obj = github.Github(access_token, base_url=server_url) - else: - self._github_obj = github.Github(access_token) - - self.repository_path = repository[CONF_PATH] - - repo = self._github_obj.get_repo(self.repository_path) - except self._github.GithubException as err: - _LOGGER.error("GitHub error for %s: %s", self.repository_path, err) - self.setup_error = True - return - - self.name = repository.get(CONF_NAME, repo.name) self.available = False - self.latest_commit_message = None - self.latest_commit_sha = None - self.latest_release_url = None - self.open_issue_count = None - self.latest_open_issue_url = None - self.pull_request_count = None - self.latest_open_pr_url = None - self.stargazers = None - self.forks = None - self.clones = None - self.clones_unique = None - self.views = None - self.views_unique = None + self.repository_response = None + self.commit_response = None + self.issues_response = None + self.pulls_response = None + self.releases_response = None + self.views_response = None + self.clones_response = None - def update(self): - """Update GitHub Sensor.""" + @property + def name(self): + """Return the name of the sensor.""" + return self._repository.get(CONF_NAME, self.repository_response.data.name) + + @property + def last_commit(self): + """Return the last issue.""" + return self.commit_response.data[0] if self.commit_response.data else None + + @property + def last_issue(self): + """Return the last issue.""" + return self.issues_response.data[0] if self.issues_response.data else None + + @property + def last_pull_request(self): + """Return the last pull request.""" + return self.pulls_response.data[0] if self.pulls_response.data else None + + @property + def last_release(self): + """Return the last release.""" + return self.releases_response.data[0] if self.releases_response.data else None + + async def async_update(self): + """Update GitHub data.""" try: - repo = self._github_obj.get_repo(self.repository_path) + await asyncio.gather( + self._update_repository(), + self._update_commit(), + self._update_issues(), + self._update_pulls(), + self._update_releases(), + ) - self.stargazers = repo.stargazers_count - self.forks = repo.forks_count - - open_pull_requests = repo.get_pulls(state="open", sort="created") - if open_pull_requests is not None: - self.pull_request_count = open_pull_requests.totalCount - if open_pull_requests.totalCount > 0: - self.latest_open_pr_url = open_pull_requests[0].html_url - - open_issues = repo.get_issues(state="open", sort="created") - if open_issues is not None: - if self.pull_request_count is None: - self.open_issue_count = open_issues.totalCount - else: - # pull requests are treated as issues too so we need to reduce the received count - self.open_issue_count = ( - open_issues.totalCount - self.pull_request_count - ) - - if open_issues.totalCount > 0: - self.latest_open_issue_url = open_issues[0].html_url - - latest_commit = repo.get_commits()[0] - self.latest_commit_sha = latest_commit.sha - self.latest_commit_message = latest_commit.commit.message - - releases = repo.get_releases() - if releases and releases.totalCount > 0: - self.latest_release_url = releases[0].html_url - - if repo.permissions.push: - clones = repo.get_clones_traffic() - if clones is not None: - self.clones = clones.get("count") - self.clones_unique = clones.get("uniques") - - views = repo.get_views_traffic() - if views is not None: - self.views = views.get("count") - self.views_unique = views.get("uniques") + if self.repository_response.data.permissions.push: + await asyncio.gather( + self._update_clones(), + self._update_views(), + ) self.available = True - except self._github.GithubException as err: + except GitHubException as err: _LOGGER.error("GitHub error for %s: %s", self.repository_path, err) self.available = False + + async def _update_repository(self): + """Update repository data.""" + self.repository_response = await self._github.repos.get(self.repository_path) + + async def _update_commit(self): + """Update commit data.""" + self.commit_response = await self._github.repos.list_commits( + self.repository_path, **{"params": {"per_page": 1}} + ) + + async def _update_issues(self): + """Update issues data.""" + self.issues_response = await self._github.repos.issues.list( + self.repository_path + ) + + async def _update_releases(self): + """Update releases data.""" + self.releases_response = await self._github.repos.releases.list( + self.repository_path + ) + + async def _update_clones(self): + """Update clones data.""" + self.clones_response = await self._github.repos.traffic.clones( + self.repository_path + ) + + async def _update_views(self): + """Update views data.""" + self.views_response = await self._github.repos.traffic.views( + self.repository_path + ) + + async def _update_pulls(self): + """Update pulls data.""" + response = await self._github.repos.pulls.list( + self.repository_path, **{"params": {"per_page": 100}} + ) + if not response.is_last_page: + results = await asyncio.gather( + *( + self._github.repos.pulls.list( + self.repository_path, + **{"params": {"per_page": 100, "page": page_number}}, + ) + for page_number in range( + response.next_page_number, response.last_page_number + 1 + ) + ) + ) + for result in results: + response.data.extend(result.data) + + self.pulls_response = response diff --git a/requirements_all.txt b/requirements_all.txt index d384d89d7ec..d68e8f854c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -28,9 +28,6 @@ PyEssent==0.14 # homeassistant.components.flick_electric PyFlick==0.0.2 -# homeassistant.components.github -PyGithub==1.43.8 - # homeassistant.components.mvglive PyMVGLive==1.1.4 @@ -175,6 +172,9 @@ aioflo==0.4.1 # homeassistant.components.yi aioftp==0.12.0 +# homeassistant.components.github +aiogithubapi==21.8.0 + # homeassistant.components.guardian aioguardian==1.0.8 From 2927dcd809c1eb1e16242fa98405361dfe99c35e Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 24 Aug 2021 13:09:36 -0400 Subject: [PATCH 734/903] Use a debouncer when updating ZHA group state (#53263) --- homeassistant/components/zha/entity.py | 17 ++++++++++++----- tests/components/zha/test_light.py | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index a5259deea5d..50dd7e16a28 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -10,6 +10,7 @@ from typing import Any from homeassistant.const import ATTR_NAME from homeassistant.core import CALLBACK_TYPE, Event, callback from homeassistant.helpers import entity +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -34,7 +35,7 @@ from .core.typing import CALLABLE_T, ChannelType, ZhaDeviceType _LOGGER = logging.getLogger(__name__) ENTITY_SUFFIX = "entity_suffix" -UPDATE_GROUP_FROM_CHILD_DELAY = 0.2 +UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 class BaseZhaEntity(LogMixin, entity.Entity): @@ -230,6 +231,7 @@ class ZhaGroupEntity(BaseZhaEntity): self._entity_ids: list[str] = entity_ids self._async_unsub_state_changed: CALLBACK_TYPE | None = None self._handled_group_membership = False + self._change_listener_debouncer: Debouncer | None = None @property def available(self) -> bool: @@ -256,6 +258,14 @@ class ZhaGroupEntity(BaseZhaEntity): signal_override=True, ) + if self._change_listener_debouncer is None: + self._change_listener_debouncer = Debouncer( + self.hass, + self, + cooldown=UPDATE_GROUP_FROM_CHILD_DELAY, + immediate=False, + function=functools.partial(self.async_update_ha_state, True), + ) self._async_unsub_state_changed = async_track_state_change_event( self.hass, self._entity_ids, self.async_state_changed_listener ) @@ -271,10 +281,7 @@ class ZhaGroupEntity(BaseZhaEntity): def async_state_changed_listener(self, event: Event): """Handle child updates.""" # Delay to ensure that we get updates from all members before updating the group - self.hass.loop.call_later( - UPDATE_GROUP_FROM_CHILD_DELAY, - lambda: self.async_schedule_update_ha_state(True), - ) + self.hass.create_task(self._change_listener_debouncer.async_call()) async def async_will_remove_from_hass(self) -> None: """Handle removal from Home Assistant.""" diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 0408f164049..915fc77462b 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -297,12 +297,12 @@ async def async_test_on_off_from_light(hass, cluster, entity_id): """Test on off functionality from the light.""" # turn on at light await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 3}) - await hass.async_block_till_done() + await async_wait_for_updates(hass) assert hass.states.get(entity_id).state == STATE_ON # turn off at light await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 3}) - await hass.async_block_till_done() + await async_wait_for_updates(hass) assert hass.states.get(entity_id).state == STATE_OFF From 81a6bec818e6ab9eddff8051abb256984bb79dd7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Aug 2021 19:56:46 +0200 Subject: [PATCH 735/903] Remove temperature conversion - dht (#55161) --- homeassistant/components/dht/sensor.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py index d81d12f33cf..a75c086c0a9 100644 --- a/homeassistant/components/dht/sensor.py +++ b/homeassistant/components/dht/sensor.py @@ -15,11 +15,10 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -35,7 +34,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) SENSOR_TEMPERATURE = "temperature" SENSOR_HUMIDITY = "humidity" SENSOR_TYPES = { - SENSOR_TEMPERATURE: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], + SENSOR_TEMPERATURE: ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], SENSOR_HUMIDITY: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], } @@ -69,7 +68,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the DHT sensor.""" - SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit available_sensors = { "AM2302": adafruit_dht.DHT22, "DHT11": adafruit_dht.DHT11, @@ -94,7 +92,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): DHTSensor( data, variable, - SENSOR_TYPES[variable][1], name, temperature_offset, humidity_offset, @@ -111,7 +108,6 @@ class DHTSensor(SensorEntity): self, dht_client, sensor_type, - temp_unit, name, temperature_offset, humidity_offset, @@ -120,7 +116,6 @@ class DHTSensor(SensorEntity): self.client_name = name self._name = SENSOR_TYPES[sensor_type][0] self.dht_client = dht_client - self.temp_unit = temp_unit self.type = sensor_type self.temperature_offset = temperature_offset self.humidity_offset = humidity_offset @@ -159,8 +154,6 @@ class DHTSensor(SensorEntity): ) if -20 <= temperature < 80: self._state = round(temperature + temperature_offset, 1) - if self.temp_unit == TEMP_FAHRENHEIT: - self._state = round(celsius_to_fahrenheit(temperature), 1) elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data: humidity = data[SENSOR_HUMIDITY] _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) From cb556fe98ed76a478dfd8e10d1c92f02977fb620 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Aug 2021 19:57:43 +0200 Subject: [PATCH 736/903] Remove temperature conversion - bme280 (#55162) --- homeassistant/components/bme280/const.py | 3 ++- homeassistant/components/bme280/sensor.py | 15 ++------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/bme280/const.py b/homeassistant/components/bme280/const.py index 19dee41c855..36753ef0292 100644 --- a/homeassistant/components/bme280/const.py +++ b/homeassistant/components/bme280/const.py @@ -6,6 +6,7 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, + TEMP_CELSIUS, ) # Common @@ -26,7 +27,7 @@ SENSOR_TEMP = "temperature" SENSOR_HUMID = "humidity" SENSOR_PRESS = "pressure" SENSOR_TYPES = { - SENSOR_TEMP: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], + SENSOR_TEMP: ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], SENSOR_HUMID: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], SENSOR_PRESS: ["Pressure", "mb", DEVICE_CLASS_PRESSURE], } diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py index 3b9589d0a6a..de6d6eb0729 100644 --- a/homeassistant/components/bme280/sensor.py +++ b/homeassistant/components/bme280/sensor.py @@ -7,18 +7,12 @@ from i2csense.bme280 import BME280 as BME280_i2c # pylint: disable=import-error import smbus from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_SCAN_INTERVAL, - TEMP_FAHRENHEIT, -) +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) -from homeassistant.util.temperature import celsius_to_fahrenheit from .const import ( CONF_DELTA_TEMP, @@ -47,7 +41,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the BME280 sensor.""" if discovery_info is None: return - SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit sensor_conf = discovery_info[SENSOR_DOMAIN] name = sensor_conf[CONF_NAME] scan_interval = max(sensor_conf[CONF_SCAN_INTERVAL], MIN_TIME_BETWEEN_UPDATES) @@ -110,7 +103,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entities.append( BME280Sensor( condition, - SENSOR_TYPES[condition][1], name, coordinator, ) @@ -121,11 +113,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BME280Sensor(CoordinatorEntity, SensorEntity): """Implementation of the BME280 sensor.""" - def __init__(self, sensor_type, temp_unit, name, coordinator): + def __init__(self, sensor_type, name, coordinator): """Initialize the sensor.""" super().__init__(coordinator) self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" - self.temp_unit = temp_unit self.type = sensor_type self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._attr_device_class = SENSOR_TYPES[sensor_type][2] @@ -135,8 +126,6 @@ class BME280Sensor(CoordinatorEntity, SensorEntity): """Return the state of the sensor.""" if self.type == SENSOR_TEMP: temperature = round(self.coordinator.data.temperature, 1) - if self.temp_unit == TEMP_FAHRENHEIT: - temperature = round(celsius_to_fahrenheit(temperature), 1) state = temperature elif self.type == SENSOR_HUMID: state = round(self.coordinator.data.humidity, 1) From f3ab174cd310f78f24d41039694ebc01856fb37c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Aug 2021 19:58:43 +0200 Subject: [PATCH 737/903] Remove temperature conversion - bme680 (#55163) --- homeassistant/components/bme680/sensor.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py index 9669738b2e5..5dca1da45a2 100644 --- a/homeassistant/components/bme680/sensor.py +++ b/homeassistant/components/bme680/sensor.py @@ -15,10 +15,9 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -56,7 +55,7 @@ SENSOR_PRESS = "pressure" SENSOR_GAS = "gas" SENSOR_AQ = "airquality" SENSOR_TYPES = { - SENSOR_TEMP: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], + SENSOR_TEMP: ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], SENSOR_HUMID: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], SENSOR_PRESS: ["Pressure", "mb", DEVICE_CLASS_PRESSURE], SENSOR_GAS: ["Gas Resistance", "Ohms", None], @@ -110,7 +109,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the BME680 sensor.""" - SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit name = config[CONF_NAME] sensor_handler = await hass.async_add_executor_job(_setup_bme680, config) @@ -119,9 +117,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= dev = [] for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append( - BME680Sensor(sensor_handler, variable, SENSOR_TYPES[variable][1], name) - ) + dev.append(BME680Sensor(sensor_handler, variable, name)) async_add_entities(dev) return @@ -321,11 +317,10 @@ class BME680Handler: class BME680Sensor(SensorEntity): """Implementation of the BME680 sensor.""" - def __init__(self, bme680_client, sensor_type, temp_unit, name): + def __init__(self, bme680_client, sensor_type, name): """Initialize the sensor.""" self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" self.bme680_client = bme680_client - self.temp_unit = temp_unit self.type = sensor_type self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._attr_device_class = SENSOR_TYPES[sensor_type][2] @@ -337,8 +332,6 @@ class BME680Sensor(SensorEntity): self._attr_native_value = round( self.bme680_client.sensor_data.temperature, 1 ) - if self.temp_unit == TEMP_FAHRENHEIT: - self._attr_native_value = round(celsius_to_fahrenheit(self.state), 1) elif self.type == SENSOR_HUMID: self._attr_native_value = round(self.bme680_client.sensor_data.humidity, 1) elif self.type == SENSOR_PRESS: From 18f80c32d7657a0b3680acaec92015cb967f1947 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Aug 2021 20:00:16 +0200 Subject: [PATCH 738/903] Remove temperature conversion - htu21d (#55165) --- homeassistant/components/htu21d/sensor.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py index 4f93ecbc42d..7032f36919b 100644 --- a/homeassistant/components/htu21d/sensor.py +++ b/homeassistant/components/htu21d/sensor.py @@ -13,11 +13,10 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -48,7 +47,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the HTU21D sensor.""" name = config.get(CONF_NAME) bus_number = config.get(CONF_I2C_BUS) - temp_unit = hass.config.units.temperature_unit bus = smbus.SMBus(config.get(CONF_I2C_BUS)) sensor = await hass.async_add_executor_job(partial(HTU21D, bus, logger=_LOGGER)) @@ -59,7 +57,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sensor_handler = await hass.async_add_executor_job(HTU21DHandler, sensor) dev = [ - HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, temp_unit), + HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, TEMP_CELSIUS), HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, PERCENTAGE), ] @@ -113,8 +111,6 @@ class HTU21DSensor(SensorEntity): if self._client.sensor.sample_ok: if self._variable == SENSOR_TEMPERATURE: value = round(self._client.sensor.temperature, 1) - if self.unit_of_measurement == TEMP_FAHRENHEIT: - value = celsius_to_fahrenheit(value) else: value = round(self._client.sensor.humidity, 1) self._state = value From 9555a3469168bb5a2eef6a08a52a261481df6654 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Tue, 24 Aug 2021 21:08:48 +0200 Subject: [PATCH 739/903] Add missing baseclass for rituals perfume genie entities (#55166) --- homeassistant/components/rituals_perfume_genie/sensor.py | 9 +++++---- homeassistant/components/rituals_perfume_genie/switch.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index c4b330d1ccf..878fb2f1a86 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from pyrituals import Diffuser +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, @@ -42,7 +43,7 @@ async def async_setup_entry( async_add_entities(entities) -class DiffuserPerfumeSensor(DiffuserEntity): +class DiffuserPerfumeSensor(DiffuserEntity, SensorEntity): """Representation of a diffuser perfume sensor.""" def __init__( @@ -64,7 +65,7 @@ class DiffuserPerfumeSensor(DiffuserEntity): return self._diffuser.perfume -class DiffuserFillSensor(DiffuserEntity): +class DiffuserFillSensor(DiffuserEntity, SensorEntity): """Representation of a diffuser fill sensor.""" def __init__( @@ -86,7 +87,7 @@ class DiffuserFillSensor(DiffuserEntity): return self._diffuser.fill -class DiffuserBatterySensor(DiffuserEntity): +class DiffuserBatterySensor(DiffuserEntity, SensorEntity): """Representation of a diffuser battery sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY @@ -104,7 +105,7 @@ class DiffuserBatterySensor(DiffuserEntity): return self._diffuser.battery_percentage -class DiffuserWifiSensor(DiffuserEntity): +class DiffuserWifiSensor(DiffuserEntity, SensorEntity): """Representation of a diffuser wifi sensor.""" _attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 180c144a358..a213db4e5db 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities(entities) -class DiffuserSwitch(SwitchEntity, DiffuserEntity): +class DiffuserSwitch(DiffuserEntity, SwitchEntity): """Representation of a diffuser switch.""" _attr_icon = "mdi:fan" From 857050268140e3285103c29c44f04c1a7ac31d06 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Tue, 24 Aug 2021 21:09:36 +0200 Subject: [PATCH 740/903] Convert Nanoleaf yaml and discovery to config flow (#52199) Co-authored-by: Paulus Schoutsen Co-authored-by: J. Nick Koston --- .coveragerc | 2 + CODEOWNERS | 1 + .../components/discovery/__init__.py | 2 +- homeassistant/components/nanoleaf/__init__.py | 32 +- .../components/nanoleaf/config_flow.py | 204 +++++++++ homeassistant/components/nanoleaf/const.py | 7 + homeassistant/components/nanoleaf/light.py | 93 ++-- .../components/nanoleaf/manifest.json | 11 +- .../components/nanoleaf/strings.json | 27 ++ .../components/nanoleaf/translations/en.json | 27 ++ homeassistant/components/nanoleaf/util.py | 7 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 11 + requirements_test_all.txt | 3 + tests/components/nanoleaf/__init__.py | 1 + tests/components/nanoleaf/test_config_flow.py | 399 ++++++++++++++++++ 16 files changed, 761 insertions(+), 67 deletions(-) create mode 100644 homeassistant/components/nanoleaf/config_flow.py create mode 100644 homeassistant/components/nanoleaf/const.py create mode 100644 homeassistant/components/nanoleaf/strings.json create mode 100644 homeassistant/components/nanoleaf/translations/en.json create mode 100644 homeassistant/components/nanoleaf/util.py create mode 100644 tests/components/nanoleaf/__init__.py create mode 100644 tests/components/nanoleaf/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index c98d56d7d5e..bb3140d7969 100644 --- a/.coveragerc +++ b/.coveragerc @@ -685,7 +685,9 @@ omit = homeassistant/components/myq/cover.py homeassistant/components/myq/light.py homeassistant/components/nad/media_player.py + homeassistant/components/nanoleaf/__init__.py homeassistant/components/nanoleaf/light.py + homeassistant/components/nanoleaf/util.py homeassistant/components/neato/__init__.py homeassistant/components/neato/api.py homeassistant/components/neato/camera.py diff --git a/CODEOWNERS b/CODEOWNERS index 35216f34b67..d3c1dc4d33d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -326,6 +326,7 @@ homeassistant/components/myq/* @bdraco @ehendrix23 homeassistant/components/mysensors/* @MartinHjelmare @functionpointer homeassistant/components/mystrom/* @fabaff homeassistant/components/nam/* @bieniu +homeassistant/components/nanoleaf/* @milanmeu homeassistant/components/neato/* @dshokouhi @Santobert homeassistant/components/nederlandse_spoorwegen/* @YarmoM homeassistant/components/nello/* @pschmitt diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 5b6bb7a5372..8bf31a94aef 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -55,7 +55,6 @@ SERVICE_HANDLERS = { "bose_soundtouch": ("media_player", "soundtouch"), "bluesound": ("media_player", "bluesound"), "lg_smart_device": ("media_player", "lg_soundbar"), - "nanoleaf_aurora": ("light", "nanoleaf"), } OPTIONAL_SERVICE_HANDLERS = {SERVICE_DLNA_DMR: ("media_player", "dlna_dmr")} @@ -87,6 +86,7 @@ MIGRATED_SERVICE_HANDLERS = [ SERVICE_XIAOMI_GW, "volumio", SERVICE_YEELIGHT, + "nanoleaf_aurora", ] DEFAULT_ENABLED = ( diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index 776d6a61772..84a33a14b3e 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -1 +1,31 @@ -"""The nanoleaf component.""" +"""The Nanoleaf integration.""" +from pynanoleaf.pynanoleaf import Nanoleaf, Unavailable + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DEVICE, DOMAIN, NAME, SERIAL_NO +from .util import pynanoleaf_get_info + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Nanoleaf from a config entry.""" + nanoleaf = Nanoleaf(entry.data[CONF_HOST]) + nanoleaf.token = entry.data[CONF_TOKEN] + try: + info = await hass.async_add_executor_job(pynanoleaf_get_info, nanoleaf) + except Unavailable as err: + raise ConfigEntryNotReady from err + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + DEVICE: nanoleaf, + NAME: info["name"], + SERIAL_NO: info["serialNo"], + } + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "light") + ) + return True diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py new file mode 100644 index 00000000000..831fa238b67 --- /dev/null +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -0,0 +1,204 @@ +"""Config flow for Nanoleaf integration.""" +from __future__ import annotations + +import logging +import os +from typing import Any, Final, cast + +from pynanoleaf import InvalidToken, Nanoleaf, NotAuthorizingNewTokens, Unavailable +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import persistent_notification +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.util.json import load_json, save_json + +from .const import DOMAIN +from .util import pynanoleaf_get_info + +_LOGGER = logging.getLogger(__name__) + +# For discovery integration import +CONFIG_FILE: Final = ".nanoleaf.conf" + +USER_SCHEMA: Final = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Nanoleaf config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize a Nanoleaf flow.""" + self.nanoleaf: Nanoleaf + + # For discovery integration import + self.discovery_conf: dict + self.device_id: str + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle Nanoleaf flow initiated by the user.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, last_step=False + ) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + self.nanoleaf = Nanoleaf(user_input[CONF_HOST]) + try: + await self.hass.async_add_executor_job(self.nanoleaf.authorize) + except Unavailable: + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + errors={"base": "cannot_connect"}, + last_step=False, + ) + except NotAuthorizingNewTokens: + pass + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error connecting to Nanoleaf") + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + last_step=False, + errors={"base": "unknown"}, + ) + return await self.async_step_link() + + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle Nanoleaf Zeroconf discovery.""" + _LOGGER.debug("Zeroconf discovered: %s", discovery_info) + return await self._async_discovery_handler(discovery_info) + + async def async_step_homekit(self, discovery_info: DiscoveryInfoType) -> FlowResult: + """Handle Nanoleaf Homekit discovery.""" + _LOGGER.debug("Homekit discovered: %s", discovery_info) + return await self._async_discovery_handler(discovery_info) + + async def _async_discovery_handler( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle Nanoleaf discovery.""" + host = discovery_info["host"] + # The name is unique and printed on the device and cannot be changed. + name = discovery_info["name"].replace(f".{discovery_info['type']}", "") + await self.async_set_unique_id(name) + self._abort_if_unique_id_configured({CONF_HOST: host}) + self.nanoleaf = Nanoleaf(host) + + # Import from discovery integration + self.device_id = discovery_info["properties"]["id"] + self.discovery_conf = cast( + dict, + await self.hass.async_add_executor_job( + load_json, self.hass.config.path(CONFIG_FILE) + ), + ) + self.nanoleaf.token = self.discovery_conf.get(self.device_id, {}).get( + "token", # >= 2021.4 + self.discovery_conf.get(host, {}).get("token"), # < 2021.4 + ) + if self.nanoleaf.token is not None: + _LOGGER.warning( + "Importing Nanoleaf %s from the discovery integration", name + ) + return await self.async_setup_finish(discovery_integration_import=True) + + self.context["title_placeholders"] = {"name": name} + return await self.async_step_link() + + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle Nanoleaf link step.""" + if user_input is None: + return self.async_show_form(step_id="link") + + try: + await self.hass.async_add_executor_job(self.nanoleaf.authorize) + except NotAuthorizingNewTokens: + return self.async_show_form( + step_id="link", errors={"base": "not_allowing_new_tokens"} + ) + except Unavailable: + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error authorizing Nanoleaf") + return self.async_show_form(step_id="link", errors={"base": "unknown"}) + return await self.async_setup_finish() + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle Nanoleaf configuration import.""" + self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) + _LOGGER.debug( + "Importing Nanoleaf on %s from your configuration.yaml", config[CONF_HOST] + ) + self.nanoleaf = Nanoleaf(config[CONF_HOST]) + self.nanoleaf.token = config[CONF_TOKEN] + return await self.async_setup_finish() + + async def async_setup_finish( + self, discovery_integration_import: bool = False + ) -> FlowResult: + """Finish Nanoleaf config flow.""" + try: + info = await self.hass.async_add_executor_job( + pynanoleaf_get_info, self.nanoleaf + ) + except Unavailable: + return self.async_abort(reason="cannot_connect") + except InvalidToken: + return self.async_abort(reason="invalid_token") + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error connecting with Nanoleaf at %s with token %s", + self.nanoleaf.host, + self.nanoleaf.token, + ) + return self.async_abort(reason="unknown") + name = info["name"] + + await self.async_set_unique_id(name) + self._abort_if_unique_id_configured({CONF_HOST: self.nanoleaf.host}) + + if discovery_integration_import: + if self.nanoleaf.host in self.discovery_conf: + self.discovery_conf.pop(self.nanoleaf.host) + if self.device_id in self.discovery_conf: + self.discovery_conf.pop(self.device_id) + _LOGGER.info( + "Successfully imported Nanoleaf %s from the discovery integration", + name, + ) + if self.discovery_conf: + await self.hass.async_add_executor_job( + save_json, self.hass.config.path(CONFIG_FILE), self.discovery_conf + ) + else: + await self.hass.async_add_executor_job( + os.remove, self.hass.config.path(CONFIG_FILE) + ) + persistent_notification.async_create( + self.hass, + "All Nanoleaf devices from the discovery integration are imported. If you used the discovery integration only for Nanoleaf you can remove it from your configuration.yaml", + f"Imported Nanoleaf {name}", + ) + + return self.async_create_entry( + title=name, + data={ + CONF_HOST: self.nanoleaf.host, + CONF_TOKEN: self.nanoleaf.token, + }, + ) diff --git a/homeassistant/components/nanoleaf/const.py b/homeassistant/components/nanoleaf/const.py new file mode 100644 index 00000000000..6d393fa3428 --- /dev/null +++ b/homeassistant/components/nanoleaf/const.py @@ -0,0 +1,7 @@ +"""Constants for Nanoleaf integration.""" + +DOMAIN = "nanoleaf" + +DEVICE = "device" +SERIAL_NO = "serial_no" +NAME = "name" diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index a6f453ce2aa..0a5288dc390 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -1,7 +1,9 @@ """Support for Nanoleaf Lights.""" +from __future__ import annotations + import logging -from pynanoleaf import Nanoleaf, Unavailable +from pynanoleaf import Unavailable import voluptuous as vol from homeassistant.components.light import ( @@ -18,22 +20,23 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, LightEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, ) -from homeassistant.util.json import load_json, save_json + +from .const import DEVICE, DOMAIN, NAME, SERIAL_NO _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Nanoleaf" -DATA_NANOLEAF = "nanoleaf" - -CONFIG_FILE = ".nanoleaf.conf" - ICON = "mdi:triangle-outline" SUPPORT_NANOLEAF = ( @@ -53,69 +56,34 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Import Nanoleaf light platform.""" + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: config[CONF_HOST], CONF_TOKEN: config[CONF_TOKEN]}, + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, add_entities: AddEntitiesCallback +) -> None: """Set up the Nanoleaf light.""" - - if DATA_NANOLEAF not in hass.data: - hass.data[DATA_NANOLEAF] = {} - - token = "" - if discovery_info is not None: - host = discovery_info["host"] - name = None - device_id = discovery_info["properties"]["id"] - - # if device already exists via config, skip discovery setup - if host in hass.data[DATA_NANOLEAF]: - return - _LOGGER.info("Discovered a new Nanoleaf: %s", discovery_info) - conf = load_json(hass.config.path(CONFIG_FILE)) - if host in conf and device_id not in conf: - conf[device_id] = conf.pop(host) - save_json(hass.config.path(CONFIG_FILE), conf) - token = conf.get(device_id, {}).get("token", "") - else: - host = config[CONF_HOST] - name = config[CONF_NAME] - token = config[CONF_TOKEN] - - nanoleaf_light = Nanoleaf(host) - - if not token: - token = nanoleaf_light.request_token() - if not token: - _LOGGER.error( - "Could not generate the auth token, did you press " - "and hold the power button on %s" - "for 5-7 seconds?", - name, - ) - return - conf = load_json(hass.config.path(CONFIG_FILE)) - conf[host] = {"token": token} - save_json(hass.config.path(CONFIG_FILE), conf) - - nanoleaf_light.token = token - - try: - info = nanoleaf_light.info - except Unavailable: - _LOGGER.error("Could not connect to Nanoleaf Light: %s on %s", name, host) - return - - if name is None: - name = info.name - - hass.data[DATA_NANOLEAF][host] = nanoleaf_light - add_entities([NanoleafLight(nanoleaf_light, name)], True) + data = hass.data[DOMAIN][entry.entry_id] + add_entities([NanoleafLight(data[DEVICE], data[NAME], data[SERIAL_NO])], True) class NanoleafLight(LightEntity): """Representation of a Nanoleaf Light.""" - def __init__(self, light, name): + def __init__(self, light, name, unique_id): """Initialize an Nanoleaf light.""" - self._unique_id = light.serialNo + self._unique_id = unique_id self._available = True self._brightness = None self._color_temp = None @@ -239,7 +207,6 @@ class NanoleafLight(LightEntity): def update(self): """Fetch new state data for this light.""" - try: self._available = self._light.available self._brightness = self._light.brightness diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json index 0984962fb73..42a9f512d3d 100644 --- a/homeassistant/components/nanoleaf/manifest.json +++ b/homeassistant/components/nanoleaf/manifest.json @@ -1,8 +1,15 @@ { "domain": "nanoleaf", "name": "Nanoleaf", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanoleaf", "requirements": ["pynanoleaf==0.1.0"], - "codeowners": [], + "zeroconf": ["_nanoleafms._tcp.local.", "_nanoleafapi._tcp.local."], + "homekit" : { + "models": [ + "NL*" + ] + }, + "codeowners": ["@milanmeu"], "iot_class": "local_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json new file mode 100644 index 00000000000..b08748757b7 --- /dev/null +++ b/homeassistant/components/nanoleaf/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "link": { + "title": "Link Nanoleaf", + "description": "Press and hold the power button on your Nanoleaf for 5 seconds until the button LEDs start flashing, then click **SUBMIT** within 30 seconds." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "not_allowing_new_tokens": "Nanoleaf is not allowing new tokens, follow the instructions above.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_token": "[%key:common::config_flow::error::invalid_access_token%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/nanoleaf/translations/en.json b/homeassistant/components/nanoleaf/translations/en.json new file mode 100644 index 00000000000..e76387d0246 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "invalid_token": "Invalid access token", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect", + "not_allowing_new_tokens": "Nanoleaf is not allowing new tokens, follow the instructions above.", + "unknown": "Unexpected error" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Press and hold the power button on your Nanoleaf for 5 seconds until the button LEDs start flashing, then click **SUBMIT** within 30 seconds.", + "title": "Link Nanoleaf" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/util.py b/homeassistant/components/nanoleaf/util.py new file mode 100644 index 00000000000..0031622e90b --- /dev/null +++ b/homeassistant/components/nanoleaf/util.py @@ -0,0 +1,7 @@ +"""Nanoleaf integration util.""" +from pynanoleaf.pynanoleaf import Nanoleaf + + +def pynanoleaf_get_info(nanoleaf_light: Nanoleaf) -> dict: + """Get Nanoleaf light info.""" + return nanoleaf_light.info diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3d43e4cbccb..ec2947443de 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -176,6 +176,7 @@ FLOWS = [ "myq", "mysensors", "nam", + "nanoleaf", "neato", "nest", "netatmo", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 8a68842475e..fd5194bd025 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -165,6 +165,16 @@ ZEROCONF = { "domain": "xiaomi_miio" } ], + "_nanoleafapi._tcp.local.": [ + { + "domain": "nanoleaf" + } + ], + "_nanoleafms._tcp.local.": [ + { + "domain": "nanoleaf" + } + ], "_nut._tcp.local.": [ { "domain": "nut" @@ -251,6 +261,7 @@ HOMEKIT = { "Iota": "abode", "LIFX": "lifx", "MYQ": "myq", + "NL*": "nanoleaf", "Netatmo Relay": "netatmo", "PowerView": "hunterdouglas_powerview", "Presence": "netatmo", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66217e2bcfa..60ba3fc80f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,6 +941,9 @@ pymyq==3.1.3 # homeassistant.components.mysensors pymysensors==0.21.0 +# homeassistant.components.nanoleaf +pynanoleaf==0.1.0 + # homeassistant.components.nuki pynuki==1.4.1 diff --git a/tests/components/nanoleaf/__init__.py b/tests/components/nanoleaf/__init__.py new file mode 100644 index 00000000000..ee614fad173 --- /dev/null +++ b/tests/components/nanoleaf/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nanoleaf integration.""" diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py new file mode 100644 index 00000000000..065cb4b5bb1 --- /dev/null +++ b/tests/components/nanoleaf/test_config_flow.py @@ -0,0 +1,399 @@ +"""Test the Nanoleaf config flow.""" +from unittest.mock import patch + +from pynanoleaf import InvalidToken, NotAuthorizingNewTokens, Unavailable + +from homeassistant import config_entries +from homeassistant.components.nanoleaf.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant + +TEST_NAME = "Canvas ADF9" +TEST_HOST = "192.168.0.100" +TEST_TOKEN = "R34F1c92FNv3pcZs4di17RxGqiLSwHM" +TEST_OTHER_TOKEN = "Qs4dxGcHR34l29RF1c92FgiLQBt3pcM" +TEST_DEVICE_ID = "5E:2E:EA:XX:XX:XX" +TEST_OTHER_DEVICE_ID = "5E:2E:EA:YY:YY:YY" + + +async def test_user_unavailable_user_step(hass: HomeAssistant) -> None: + """Test we handle Unavailable errors when host is not available in user step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=Unavailable("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result2["type"] == "form" + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + assert not result2["last_step"] + + +async def test_user_unavailable_link_step(hass: HomeAssistant) -> None: + """Test we abort if the device becomes unavailable in the link step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result2["type"] == "form" + assert result2["step_id"] == "link" + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=Unavailable("message"), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result3["type"] == "abort" + assert result3["reason"] == "cannot_connect" + + +async def test_user_unavailable_setup_finish(hass: HomeAssistant) -> None: + """Test we abort if the device becomes unavailable during setup_finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result2["type"] == "form" + assert result2["step_id"] == "link" + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + return_value=None, + ), patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + side_effect=Unavailable("message"), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result3["type"] == "abort" + assert result3["reason"] == "cannot_connect" + + +async def test_user_not_authorizing_new_tokens(hass: HomeAssistant) -> None: + """Test we handle NotAuthorizingNewTokens errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + assert not result["last_step"] + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=NotAuthorizingNewTokens("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result2["type"] == "form" + assert result2["errors"] is None + assert result2["step_id"] == "link" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + assert result3["type"] == "form" + assert result3["errors"] is None + assert result3["step_id"] == "link" + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=NotAuthorizingNewTokens("message"), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result4["type"] == "form" + assert result4["step_id"] == "link" + assert result4["errors"] == {"base": "not_allowing_new_tokens"} + + +async def test_user_exception(hass: HomeAssistant) -> None: + """Test we handle Exception errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result2["type"] == "form" + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} + assert not result2["last_step"] + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + return_value=None, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result3["step_id"] == "link" + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=Exception, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result4["type"] == "form" + assert result4["step_id"] == "link" + assert result4["errors"] == {"base": "unknown"} + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + return_value=None, + ), patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + side_effect=Exception, + ): + result5 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result5["type"] == "abort" + assert result5["reason"] == "unknown" + + +async def test_zeroconf_discovery(hass: HomeAssistant) -> None: + """Test zeroconfig discovery flow init.""" + zeroconf = "_nanoleafms._tcp.local" + with patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + return_value={"name": TEST_NAME}, + ), patch( + "homeassistant.components.nanoleaf.config_flow.load_json", + return_value={}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "host": TEST_HOST, + "name": f"{TEST_NAME}.{zeroconf}", + "type": zeroconf, + "properties": {"id": TEST_DEVICE_ID}, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "link" + + +async def test_homekit_discovery_link_unavailable( + hass: HomeAssistant, +) -> None: + """Test homekit discovery and abort if device is unavailable.""" + homekit = "_hap._tcp.local" + with patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + return_value={"name": TEST_NAME}, + ), patch( + "homeassistant.components.nanoleaf.config_flow.load_json", + return_value={}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data={ + "host": TEST_HOST, + "name": f"{TEST_NAME}.{homekit}", + "type": homekit, + "properties": {"id": TEST_DEVICE_ID}, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "link" + + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert context["title_placeholders"] == {"name": TEST_NAME} + assert context["unique_id"] == TEST_NAME + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=Unavailable("message"), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_import_config(hass: HomeAssistant) -> None: + """Test configuration import.""" + with patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + return_value={"name": TEST_NAME}, + ), patch( + "homeassistant.components.nanoleaf.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_config_invalid_token(hass: HomeAssistant) -> None: + """Test configuration import with invalid token.""" + with patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + side_effect=InvalidToken("message"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + assert result["type"] == "abort" + assert result["reason"] == "invalid_token" + + +async def test_import_last_discovery_integration_host_zeroconf( + hass: HomeAssistant, +) -> None: + """ + Test discovery integration import from < 2021.4 (host) with zeroconf. + + Device is last in Nanoleaf config file. + """ + zeroconf = "_nanoleafapi._tcp.local" + with patch( + "homeassistant.components.nanoleaf.config_flow.load_json", + return_value={TEST_HOST: {"token": TEST_TOKEN}}, + ), patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + return_value={"name": TEST_NAME}, + ), patch( + "homeassistant.components.nanoleaf.config_flow.os.remove", + return_value=None, + ) as mock_remove, patch( + "homeassistant.components.nanoleaf.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "host": TEST_HOST, + "name": f"{TEST_NAME}.{zeroconf}", + "type": zeroconf, + "properties": {"id": TEST_DEVICE_ID}, + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + } + mock_remove.assert_called_once() + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_not_last_discovery_integration_device_id_homekit( + hass: HomeAssistant, +) -> None: + """ + Test discovery integration import from >= 2021.4 (device_id) with homekit. + + Device is not the only one in the Nanoleaf config file. + """ + homekit = "_hap._tcp.local" + with patch( + "homeassistant.components.nanoleaf.config_flow.load_json", + return_value={ + TEST_DEVICE_ID: {"token": TEST_TOKEN}, + TEST_OTHER_DEVICE_ID: {"token": TEST_OTHER_TOKEN}, + }, + ), patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + return_value={"name": TEST_NAME}, + ), patch( + "homeassistant.components.nanoleaf.config_flow.save_json", + return_value=None, + ) as mock_save_json, patch( + "homeassistant.components.nanoleaf.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data={ + "host": TEST_HOST, + "name": f"{TEST_NAME}.{homekit}", + "type": homekit, + "properties": {"id": TEST_DEVICE_ID}, + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + } + mock_save_json.assert_called_once() + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 From 2932a3d6a7edeede424fc18411ceb8e2dff0dc5d Mon Sep 17 00:00:00 2001 From: Richard Meyer Date: Tue, 24 Aug 2021 14:10:28 -0500 Subject: [PATCH 741/903] Update version for smart-meter-texas to 0.4.7 (#54493) Co-authored-by: J. Nick Koston --- .../components/smart_meter_texas/__init__.py | 17 +++++++++++++---- .../components/smart_meter_texas/config_flow.py | 7 ++++--- .../components/smart_meter_texas/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index 7b500ed58e7..16379dca8cb 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -1,8 +1,9 @@ """The Smart Meter Texas integration.""" import asyncio import logging +import ssl -from smart_meter_texas import Account, Client +from smart_meter_texas import Account, Client, ClientSSLContext from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -39,7 +40,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: password = entry.data[CONF_PASSWORD] account = Account(username, password) - smart_meter_texas_data = SmartMeterTexasData(hass, entry, account) + + client_ssl_context = ClientSSLContext() + ssl_context = await client_ssl_context.get_ssl_context() + + smart_meter_texas_data = SmartMeterTexasData(hass, entry, account, ssl_context) try: await smart_meter_texas_data.client.authenticate() except SmartMeterTexasAuthError: @@ -87,13 +92,17 @@ class SmartMeterTexasData: """Manages coordinatation of API data updates.""" def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, account: Account + self, + hass: HomeAssistant, + entry: ConfigEntry, + account: Account, + ssl_context: ssl.SSLContext, ) -> None: """Initialize the data coordintator.""" self._entry = entry self.account = account websession = aiohttp_client.async_get_clientsession(hass) - self.client = Client(websession, account) + self.client = Client(websession, account, ssl_context=ssl_context) self.meters: list = [] async def setup(self): diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py index 296040d85f0..53428131e17 100644 --- a/homeassistant/components/smart_meter_texas/config_flow.py +++ b/homeassistant/components/smart_meter_texas/config_flow.py @@ -3,7 +3,7 @@ import asyncio import logging from aiohttp import ClientError -from smart_meter_texas import Account, Client +from smart_meter_texas import Account, Client, ClientSSLContext from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -28,10 +28,11 @@ async def validate_input(hass: core.HomeAssistant, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - + client_ssl_context = ClientSSLContext() + ssl_context = await client_ssl_context.get_ssl_context() client_session = aiohttp_client.async_get_clientsession(hass) account = Account(data["username"], data["password"]) - client = Client(client_session, account) + client = Client(client_session, account, ssl_context) try: await client.authenticate() diff --git a/homeassistant/components/smart_meter_texas/manifest.json b/homeassistant/components/smart_meter_texas/manifest.json index 0e8a6b91236..f70cf59b9b9 100644 --- a/homeassistant/components/smart_meter_texas/manifest.json +++ b/homeassistant/components/smart_meter_texas/manifest.json @@ -3,7 +3,7 @@ "name": "Smart Meter Texas", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smart_meter_texas", - "requirements": ["smart-meter-texas==0.4.0"], + "requirements": ["smart-meter-texas==0.4.7"], "codeowners": ["@grahamwetzler"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index d68e8f854c1..8740a8b00a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2146,7 +2146,7 @@ sleepyq==0.8.1 slixmpp==1.7.1 # homeassistant.components.smart_meter_texas -smart-meter-texas==0.4.0 +smart-meter-texas==0.4.7 # homeassistant.components.smarthab smarthab==0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60ba3fc80f4..cec84d474a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1197,7 +1197,7 @@ slackclient==2.5.0 sleepyq==0.8.1 # homeassistant.components.smart_meter_texas -smart-meter-texas==0.4.0 +smart-meter-texas==0.4.7 # homeassistant.components.smarthab smarthab==0.21 From 6cf312f3c8dcefe86d83b78a0670203393f40ee1 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 24 Aug 2021 14:13:18 -0500 Subject: [PATCH 742/903] Fix Sonos missing group member race condition on startup (#55158) --- homeassistant/components/sonos/const.py | 1 + homeassistant/components/sonos/speaker.py | 28 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 88b71066486..c5a630d73bd 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -142,6 +142,7 @@ SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_POLL_UPDATE = "sonos_poll_update" SONOS_ALARMS_UPDATED = "sonos_alarms_updated" SONOS_FAVORITES_UPDATED = "sonos_favorites_updated" +SONOS_SPEAKER_ADDED = "sonos_speaker_added" SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_REBOOTED = "sonos_rebooted" SONOS_SEEN = "sonos_seen" diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 9485d5dcff3..9febace5e8c 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -50,6 +50,7 @@ from .const import ( SONOS_POLL_UPDATE, SONOS_REBOOTED, SONOS_SEEN, + SONOS_SPEAKER_ADDED, SONOS_STATE_PLAYING, SONOS_STATE_TRANSITIONING, SONOS_STATE_UPDATED, @@ -196,6 +197,7 @@ class SonosSpeaker: self.sonos_group_entities: list[str] = [] self.soco_snapshot: Snapshot | None = None self.snapshot_group: list[SonosSpeaker] | None = None + self._group_members_missing: set[str] = set() def setup(self) -> None: """Run initial setup of the speaker.""" @@ -212,6 +214,11 @@ class SonosSpeaker: self._reboot_dispatcher = dispatcher_connect( self.hass, f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted ) + self._group_dispatcher = dispatcher_connect( + self.hass, + SONOS_SPEAKER_ADDED, + self.update_group_for_uid, + ) if battery_info := fetch_battery_info_or_none(self.soco): self.battery_info = battery_info @@ -240,6 +247,7 @@ class SonosSpeaker: } dispatcher_send(self.hass, SONOS_CREATE_MEDIA_PLAYER, self) + dispatcher_send(self.hass, SONOS_SPEAKER_ADDED, self.soco.uid) # # Entity management @@ -637,6 +645,16 @@ class SonosSpeaker: """Update group topology when polling.""" self.hass.add_job(self.create_update_groups_coro()) + def update_group_for_uid(self, uid: str) -> None: + """Update group topology if uid is missing.""" + if uid not in self._group_members_missing: + return + missing_zone = self.hass.data[DATA_SONOS].discovered[uid].zone_name + _LOGGER.debug( + "%s was missing, adding to %s group", missing_zone, self.zone_name + ) + self.update_groups() + @callback def async_update_groups(self, event: SonosEvent) -> None: """Handle callback for topology change event.""" @@ -658,7 +676,7 @@ class SonosSpeaker: slave_uids = [ p.uid for p in self.soco.group.members - if p.uid != coordinator_uid + if p.uid != coordinator_uid and p.is_visible ] return [coordinator_uid] + slave_uids @@ -690,11 +708,19 @@ class SonosSpeaker: for uid in group: speaker = self.hass.data[DATA_SONOS].discovered.get(uid) if speaker: + self._group_members_missing.discard(uid) sonos_group.append(speaker) entity_id = entity_registry.async_get_entity_id( MP_DOMAIN, DOMAIN, uid ) sonos_group_entities.append(entity_id) + else: + self._group_members_missing.add(uid) + _LOGGER.debug( + "%s group member unavailable (%s), will try again", + self.zone_name, + uid, + ) if self.sonos_group_entities == sonos_group_entities: # Useful in polling mode for speakers with stereo pairs or surrounds From 289734c748fbae05d60c4c5a6b18efcebc928b16 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 24 Aug 2021 16:25:31 -0400 Subject: [PATCH 743/903] Update ZHA config entry radio detection (#55128) --- homeassistant/components/zha/config_flow.py | 5 ++- tests/components/zha/test_config_flow.py | 35 ++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index b94b620581e..61898328d2e 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -238,7 +238,10 @@ async def detect_radios(dev_path: str) -> dict[str, Any] | None: """Probe all radio types on the device port.""" for radio in RadioType: dev_config = radio.controller.SCHEMA_DEVICE({CONF_DEVICE_PATH: dev_path}) - if await radio.controller.probe(dev_config): + probe_result = await radio.controller.probe(dev_config) + if probe_result: + if isinstance(probe_result, dict): + return {CONF_RADIO_TYPE: radio.name, CONF_DEVICE: probe_result} return {CONF_RADIO_TYPE: radio.name, CONF_DEVICE: dev_config} return None diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 81957f010dd..9f7e3baeaf1 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for ZHA config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch, sentinel import pytest import serial.tools.list_ports @@ -446,6 +446,39 @@ async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, cc_probe, ha assert cc_probe.await_count == 1 +@patch("zigpy_cc.zigbee.application.ControllerApplication.probe", return_value=False) +@patch( + "zigpy_deconz.zigbee.application.ControllerApplication.probe", return_value=False +) +@patch( + "zigpy_zigate.zigbee.application.ControllerApplication.probe", return_value=False +) +@patch("zigpy_xbee.zigbee.application.ControllerApplication.probe", return_value=False) +async def test_probe_new_ezsp(xbee_probe, zigate_probe, deconz_probe, cc_probe, hass): + """Test detect radios.""" + app_ctrl_cls = MagicMock() + app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE + app_ctrl_cls.probe = AsyncMock(side_efferct=(True, False)) + + p1 = patch( + "bellows.zigbee.application.ControllerApplication.probe", + return_value={ + zigpy.config.CONF_DEVICE_PATH: sentinel.usb_port, + "baudrate": 33840, + }, + ) + with p1 as probe_mock: + res = await config_flow.detect_radios("/dev/null") + assert probe_mock.await_count == 1 + assert res[CONF_RADIO_TYPE] == "ezsp" + assert zigpy.config.CONF_DEVICE in res + assert ( + res[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] + is sentinel.usb_port + ) + assert res[zigpy.config.CONF_DEVICE]["baudrate"] == 33840 + + @patch("bellows.zigbee.application.ControllerApplication.probe", return_value=False) async def test_user_port_config_fail(probe_mock, hass): """Test port config flow.""" From 24d017f9744341d42b8fa837e157846c0bb03707 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 24 Aug 2021 14:37:50 -0600 Subject: [PATCH 744/903] Add ability to configure OpenUV "protection window" UV indices (#54562) --- homeassistant/components/openuv/__init__.py | 15 ++++- .../components/openuv/config_flow.py | 55 ++++++++++++++++++- homeassistant/components/openuv/const.py | 6 ++ homeassistant/components/openuv/strings.json | 11 ++++ .../components/openuv/translations/en.json | 11 ++++ tests/components/openuv/test_config_flow.py | 33 ++++++++++- 6 files changed, 126 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index bcdd0b2ba40..d931049bb31 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -28,10 +28,14 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.service import verify_domain_control from .const import ( + CONF_FROM_WINDOW, + CONF_TO_WINDOW, DATA_CLIENT, DATA_LISTENER, DATA_PROTECTION_WINDOW, DATA_UV, + DEFAULT_FROM_WINDOW, + DEFAULT_TO_WINDOW, DOMAIN, LOGGER, ) @@ -55,13 +59,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: websession = aiohttp_client.async_get_clientsession(hass) openuv = OpenUV( + config_entry, Client( config_entry.data[CONF_API_KEY], config_entry.data.get(CONF_LATITUDE, hass.config.latitude), config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation), session=websession, - ) + ), ) await openuv.async_update() hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = openuv @@ -134,15 +139,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class OpenUV: """Define a generic OpenUV object.""" - def __init__(self, client: Client) -> None: + def __init__(self, config_entry: ConfigEntry, client: Client) -> None: """Initialize.""" + self._config_entry = config_entry self.client = client self.data: dict[str, Any] = {} async def async_update_protection_data(self) -> None: """Update binary sensor (protection window) data.""" + low = self._config_entry.options.get(CONF_FROM_WINDOW, DEFAULT_FROM_WINDOW) + high = self._config_entry.options.get(CONF_TO_WINDOW, DEFAULT_TO_WINDOW) + try: - resp = await self.client.uv_protection_window() + resp = await self.client.uv_protection_window(low=low, high=high) self.data[DATA_PROTECTION_WINDOW] = resp["result"] except OpenUvError as err: LOGGER.error("Error during protection data update: %s", err) diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index d8652ae09c5..62db9b2cf8c 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -8,16 +8,24 @@ from pyopenuv.errors import OpenUvError import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, ) +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv -from .const import DOMAIN +from .const import ( + CONF_FROM_WINDOW, + CONF_TO_WINDOW, + DEFAULT_FROM_WINDOW, + DEFAULT_TO_WINDOW, + DOMAIN, +) class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -51,6 +59,12 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors if errors else {}, ) + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OpenUvOptionsFlowHandler: + """Define the config flow to handle options.""" + return OpenUvOptionsFlowHandler(config_entry) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -75,3 +89,42 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self._show_form({CONF_API_KEY: "invalid_api_key"}) return self.async_create_entry(title=identifier, data=user_input) + + +class OpenUvOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a OpenUV options flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + 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=vol.Schema( + { + vol.Optional( + CONF_FROM_WINDOW, + description={ + "suggested_value": self.config_entry.options.get( + CONF_FROM_WINDOW, DEFAULT_FROM_WINDOW + ) + }, + ): vol.Coerce(float), + vol.Optional( + CONF_TO_WINDOW, + description={ + "suggested_value": self.config_entry.options.get( + CONF_FROM_WINDOW, DEFAULT_TO_WINDOW + ) + }, + ): vol.Coerce(float), + } + ), + ) diff --git a/homeassistant/components/openuv/const.py b/homeassistant/components/openuv/const.py index 683e349eb50..3b117fe37aa 100644 --- a/homeassistant/components/openuv/const.py +++ b/homeassistant/components/openuv/const.py @@ -4,11 +4,17 @@ import logging DOMAIN = "openuv" LOGGER = logging.getLogger(__package__) +CONF_FROM_WINDOW = "from_window" +CONF_TO_WINDOW = "to_window" + DATA_CLIENT = "data_client" DATA_LISTENER = "data_listener" DATA_PROTECTION_WINDOW = "protection_window" DATA_UV = "uv" +DEFAULT_FROM_WINDOW = 3.5 +DEFAULT_TO_WINDOW = 3.5 + TYPE_CURRENT_OZONE_LEVEL = "current_ozone_level" TYPE_CURRENT_UV_INDEX = "current_uv_index" TYPE_CURRENT_UV_LEVEL = "current_uv_level" diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index f865aa1e621..cd9ec36d93a 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -17,5 +17,16 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" } + }, + "options": { + "step": { + "init": { + "title": "Configure OpenUV", + "data": { + "from_window": "Starting UV index for the protection window", + "to_window": "Ending UV index for the protection window" + } + } + } } } diff --git a/homeassistant/components/openuv/translations/en.json b/homeassistant/components/openuv/translations/en.json index 6021c7a2030..92ca71cd46f 100644 --- a/homeassistant/components/openuv/translations/en.json +++ b/homeassistant/components/openuv/translations/en.json @@ -17,5 +17,16 @@ "title": "Fill in your information" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Starting UV index for the protection window", + "to_window": "Ending UV index for the protection window" + }, + "title": "Configure OpenUV" + } + } } } \ No newline at end of file diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py index 3feeb2638b4..8afd9803d7c 100644 --- a/tests/components/openuv/test_config_flow.py +++ b/tests/components/openuv/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyopenuv.errors import InvalidApiKeyError from homeassistant import data_entry_flow -from homeassistant.components.openuv import DOMAIN +from homeassistant.components.openuv import CONF_FROM_WINDOW, CONF_TO_WINDOW, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, @@ -57,6 +57,37 @@ async def test_invalid_api_key(hass): assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} +async def test_options_flow(hass): + """Test config flow options.""" + conf = { + CONF_API_KEY: "12345abcde", + CONF_ELEVATION: 59.1234, + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + } + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="abcde12345", + data=conf, + ) + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.openuv.async_setup_entry", return_value=True): + await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 2.0} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 2.0} + + async def test_step_user(hass): """Test that the user step works.""" conf = { From b8d4e9806e8773a53100b070122f3a07390148ab Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 24 Aug 2021 15:31:38 -0600 Subject: [PATCH 745/903] Remove unreachable code in OpenUV (#55181) --- homeassistant/components/openuv/config_flow.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 62db9b2cf8c..e397bbf7f95 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -72,11 +72,7 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not user_input: return await self._show_form() - if user_input.get(CONF_LATITUDE): - identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" - else: - identifier = "Default Coordinates" - + identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" await self.async_set_unique_id(identifier) self._abort_if_unique_id_configured() From b4238443c86860a0540df53a8a11b9fed87dea3c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 25 Aug 2021 00:14:22 +0000 Subject: [PATCH 746/903] [ci skip] Translation update --- .../components/nanoleaf/translations/ca.json | 27 +++++++++++++++++++ .../components/nanoleaf/translations/de.json | 27 +++++++++++++++++++ .../components/openuv/translations/ca.json | 11 ++++++++ .../components/openuv/translations/de.json | 11 ++++++++ .../components/sensor/translations/ca.json | 2 ++ 5 files changed, 78 insertions(+) create mode 100644 homeassistant/components/nanoleaf/translations/ca.json create mode 100644 homeassistant/components/nanoleaf/translations/de.json diff --git a/homeassistant/components/nanoleaf/translations/ca.json b/homeassistant/components/nanoleaf/translations/ca.json new file mode 100644 index 00000000000..80403026c91 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/ca.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_token": "Token d'acc\u00e9s no v\u00e0lid", + "unknown": "Error inesperat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "not_allowing_new_tokens": "Nanoleaf no permet 'tokens' nous, segueix les instruccions de sobre.", + "unknown": "Error inesperat" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Prem i mant\u00e9 el bot\u00f3 d'engegada del Nanoleaf durant 5 segons fins que els LEDs del bot\u00f3 comencin a parpellejar, despr\u00e9s clica a **ENVIA** abans de que passin 30 segons.", + "title": "Enlla\u00e7 Nanoleaf" + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/de.json b/homeassistant/components/nanoleaf/translations/de.json new file mode 100644 index 00000000000..b79c2995cb4 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/de.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_token": "Ung\u00fcltiger Zugriffs-Token", + "unknown": "Unerwarteter Fehler" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "not_allowing_new_tokens": "Nanoleaf l\u00e4sst keine neuen Tokens zu, befolge die obigen Anweisungen.", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Halte die Ein-/Aus-Taste an deinem Nanoleaf 5 Sekunden lang gedr\u00fcckt, bis die LEDs der Tasten zu blinken beginnen, und klicke dann innerhalb von 30 Sekunden auf **SENDEN**.", + "title": "Nanoleaf verkn\u00fcpfen" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/ca.json b/homeassistant/components/openuv/translations/ca.json index fbe163212f3..df55a79d1a5 100644 --- a/homeassistant/components/openuv/translations/ca.json +++ b/homeassistant/components/openuv/translations/ca.json @@ -17,5 +17,16 @@ "title": "Introdueix la teva informaci\u00f3" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "\u00cdndex UV d'inicialitzaci\u00f3 de la finestra protectora", + "to_window": "\u00cdndex UV de finalitzaci\u00f3 de la finestra protectora" + }, + "title": "Configura OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/de.json b/homeassistant/components/openuv/translations/de.json index 88f9e69a5b6..abc32f68f02 100644 --- a/homeassistant/components/openuv/translations/de.json +++ b/homeassistant/components/openuv/translations/de.json @@ -17,5 +17,16 @@ "title": "Gib deine Informationen ein" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Anfangs-UV-Index f\u00fcr das Schutzfenster", + "to_window": "End-UV-Index f\u00fcr das Schutzfenster" + }, + "title": "OpenUV konfigurieren" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/sensor/translations/ca.json b/homeassistant/components/sensor/translations/ca.json index 9303635ca60..c90ed273a67 100644 --- a/homeassistant/components/sensor/translations/ca.json +++ b/homeassistant/components/sensor/translations/ca.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "Concentraci\u00f3 actual de di\u00f2xid de sofre de {entity_name}", "is_temperature": "Temperatura actual de {entity_name}", "is_value": "Valor actual de {entity_name}", + "is_volatile_organic_compounds": "Concentraci\u00f3 actual de compostos org\u00e0nics vol\u00e0tils de {entity_name}", "is_voltage": "Voltatge actual de {entity_name}" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "Canvia la concentraci\u00f3 de di\u00f2xid de sofre de {entity_name}", "temperature": "Canvia la temperatura de {entity_name}", "value": "Canvia el valor de {entity_name}", + "volatile_organic_compounds": "Canvia la concentraci\u00f3 de compostos org\u00e0nics vol\u00e0tils de {entity_name}", "voltage": "Canvia el voltatge de {entity_name}" } }, From 1e05c81fe612051ba675d55e1554b4a38a08b2ab Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Aug 2021 23:48:13 -0700 Subject: [PATCH 747/903] Fix recorder test (#55169) Co-authored-by: Erik --- tests/components/energy/test_sensor.py | 2 +- tests/components/sensor/test_recorder.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 1375a1c292c..542ea3296ce 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -220,7 +220,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "14", - {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 1c1d5c52462..abeda03ed7b 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -359,6 +359,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( assert stats == { "sensor.test1": [ { + "last_reset": None, "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), "max": None, @@ -368,6 +369,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "sum": approx(factor * 10.0), }, { + "last_reset": None, "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), "max": None, @@ -377,6 +379,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "sum": approx(factor * 30.0), }, { + "last_reset": None, "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), "max": None, From 703c8f56f39140de60e6180c41eb7a377b2de1db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Aug 2021 09:46:28 +0200 Subject: [PATCH 748/903] Bump codecov/codecov-action from 2.0.2 to 2.0.3 (#55194) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 06d22228a28..ec7aeb7afb0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -740,4 +740,4 @@ jobs: coverage report --fail-under=94 coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2.0.2 + uses: codecov/codecov-action@v2.0.3 From ab6be2890a771e62ce56922afe6952488099b952 Mon Sep 17 00:00:00 2001 From: Luke Waite Date: Wed, 25 Aug 2021 04:02:53 -0400 Subject: [PATCH 749/903] Add statistics for emoncms power and energy feeds (#55109) --- homeassistant/components/emoncms/sensor.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 5180275b528..033f7878b5e 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -5,7 +5,12 @@ import logging import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( CONF_API_KEY, CONF_ID, @@ -13,6 +18,8 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_URL, CONF_VALUE_TEMPLATE, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, HTTP_OK, POWER_WATT, STATE_UNKNOWN, @@ -149,6 +156,13 @@ class EmonCmsSensor(SensorEntity): self._sensorid = sensorid self._elem = elem + if unit_of_measurement == "kWh": + self._attr_device_class = DEVICE_CLASS_ENERGY + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + elif unit_of_measurement == "W": + self._attr_device_class = DEVICE_CLASS_POWER + self._attr_state_class = STATE_CLASS_MEASUREMENT + if self._value_template is not None: self._state = self._value_template.render_with_possible_json_value( elem["value"], STATE_UNKNOWN From ed95bda781d944d9d2c526b0f6f8df1e1d091e34 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 10:28:43 +0200 Subject: [PATCH 750/903] Use EntityDescription - dht (#55183) --- homeassistant/components/dht/sensor.py | 84 ++++++++++++-------------- 1 file changed, 39 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py index a75c086c0a9..810db33e5e4 100644 --- a/homeassistant/components/dht/sensor.py +++ b/homeassistant/components/dht/sensor.py @@ -1,5 +1,6 @@ """Support for Adafruit DHT temperature and humidity sensor.""" -from contextlib import suppress +from __future__ import annotations + from datetime import timedelta import logging @@ -7,7 +8,11 @@ import adafruit_dht import board import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, @@ -33,10 +38,22 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) SENSOR_TEMPERATURE = "temperature" SENSOR_HUMIDITY = "humidity" -SENSOR_TYPES = { - SENSOR_TEMPERATURE: ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - SENSOR_HUMIDITY: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] def validate_pin_input(value): @@ -53,7 +70,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_SENSOR): cv.string, vol.Required(CONF_PIN): vol.All(cv.string, validate_pin_input), vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TEMPERATURE_OFFSET, default=0): vol.All( @@ -84,21 +101,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return False data = DHTClient(sensor, pin, name) - dev = [] - with suppress(KeyError): - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append( - DHTSensor( - data, - variable, - name, - temperature_offset, - humidity_offset, - ) - ) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + DHTSensor(data, name, temperature_offset, humidity_offset, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - add_entities(dev, True) + add_entities(entities, True) class DHTSensor(SensorEntity): @@ -107,36 +118,18 @@ class DHTSensor(SensorEntity): def __init__( self, dht_client, - sensor_type, name, temperature_offset, humidity_offset, + description: SensorEntityDescription, ): """Initialize the sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.dht_client = dht_client - self.type = sensor_type self.temperature_offset = temperature_offset self.humidity_offset = humidity_offset - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_name = f"{name} {description.name}" def update(self): """Get the latest data from the DHT and updates the states.""" @@ -145,7 +138,8 @@ class DHTSensor(SensorEntity): humidity_offset = self.humidity_offset data = self.dht_client.data - if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMPERATURE and sensor_type in data: temperature = data[SENSOR_TEMPERATURE] _LOGGER.debug( "Temperature %.1f \u00b0C + offset %.1f", @@ -153,12 +147,12 @@ class DHTSensor(SensorEntity): temperature_offset, ) if -20 <= temperature < 80: - self._state = round(temperature + temperature_offset, 1) - elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data: + self._attr_native_value = round(temperature + temperature_offset, 1) + elif sensor_type == SENSOR_HUMIDITY and sensor_type in data: humidity = data[SENSOR_HUMIDITY] _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) if 0 <= humidity <= 100: - self._state = round(humidity + humidity_offset, 1) + self._attr_native_value = round(humidity + humidity_offset, 1) class DHTClient: From 4036ba82fe4c018d66366f92a6837db1e2d48d60 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 10:29:59 +0200 Subject: [PATCH 751/903] Remove temperature conversion - mhz19 (#55164) --- homeassistant/components/mhz19/sensor.py | 13 ++++--------- tests/components/mhz19/test_sensor.py | 21 ++++++++++----------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/mhz19/sensor.py b/homeassistant/components/mhz19/sensor.py index 7d5d5eba183..ea90186e75a 100644 --- a/homeassistant/components/mhz19/sensor.py +++ b/homeassistant/components/mhz19/sensor.py @@ -13,11 +13,10 @@ from homeassistant.const import ( CONF_NAME, DEVICE_CLASS_CO2, DEVICE_CLASS_TEMPERATURE, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -31,7 +30,7 @@ ATTR_CO2_CONCENTRATION = "co2_concentration" SENSOR_TEMPERATURE = "temperature" SENSOR_CO2 = "co2" SENSOR_TYPES = { - SENSOR_TEMPERATURE: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], + SENSOR_TEMPERATURE: ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], SENSOR_CO2: ["CO2", CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -57,14 +56,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): err, ) return False - SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit data = MHZClient(co2sensor, config.get(CONF_SERIAL_DEVICE)) dev = [] name = config.get(CONF_NAME) for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append(MHZ19Sensor(data, variable, SENSOR_TYPES[variable][1], name)) + dev.append(MHZ19Sensor(data, variable, name)) add_entities(dev, True) return True @@ -73,11 +71,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class MHZ19Sensor(SensorEntity): """Representation of an CO2 sensor.""" - def __init__(self, mhz_client, sensor_type, temp_unit, name): + def __init__(self, mhz_client, sensor_type, name): """Initialize a new PM sensor.""" self._mhz_client = mhz_client self._sensor_type = sensor_type - self._temp_unit = temp_unit self._name = name self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._ppm = None @@ -104,8 +101,6 @@ class MHZ19Sensor(SensorEntity): self._mhz_client.update() data = self._mhz_client.data self._temperature = data.get(SENSOR_TEMPERATURE) - if self._temperature is not None and self._temp_unit == TEMP_FAHRENHEIT: - self._temperature = round(celsius_to_fahrenheit(self._temperature), 1) self._ppm = data.get(SENSOR_CO2) @property diff --git a/tests/components/mhz19/test_sensor.py b/tests/components/mhz19/test_sensor.py index 26e9441f9fc..5af7338abc4 100644 --- a/tests/components/mhz19/test_sensor.py +++ b/tests/components/mhz19/test_sensor.py @@ -86,13 +86,13 @@ async def aiohttp_client_update_good_read(mock_function): async def test_co2_sensor(mock_function, hass): """Test CO2 sensor.""" client = mhz19.MHZClient(co2sensor, "test.serial") - sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_CO2, None, "name") + sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_CO2, "name") sensor.hass = hass sensor.update() assert sensor.name == "name: CO2" assert sensor.state == 1000 - assert sensor.unit_of_measurement == CONCENTRATION_PARTS_PER_MILLION + assert sensor.native_unit_of_measurement == CONCENTRATION_PARTS_PER_MILLION assert sensor.should_poll assert sensor.extra_state_attributes == {"temperature": 24} @@ -101,13 +101,13 @@ async def test_co2_sensor(mock_function, hass): async def test_temperature_sensor(mock_function, hass): """Test temperature sensor.""" client = mhz19.MHZClient(co2sensor, "test.serial") - sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_TEMPERATURE, None, "name") + sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_TEMPERATURE, "name") sensor.hass = hass sensor.update() assert sensor.name == "name: Temperature" assert sensor.state == 24 - assert sensor.unit_of_measurement == TEMP_CELSIUS + assert sensor.native_unit_of_measurement == TEMP_CELSIUS assert sensor.should_poll assert sensor.extra_state_attributes == {"co2_concentration": 1000} @@ -115,11 +115,10 @@ async def test_temperature_sensor(mock_function, hass): @patch("pmsensor.co2sensor.read_mh_z19_with_temperature", return_value=(1000, 24)) async def test_temperature_sensor_f(mock_function, hass): """Test temperature sensor.""" - client = mhz19.MHZClient(co2sensor, "test.serial") - sensor = mhz19.MHZ19Sensor( - client, mhz19.SENSOR_TEMPERATURE, TEMP_FAHRENHEIT, "name" - ) - sensor.hass = hass - sensor.update() + with patch.object(hass.config.units, "temperature_unit", TEMP_FAHRENHEIT): + client = mhz19.MHZClient(co2sensor, "test.serial") + sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_TEMPERATURE, "name") + sensor.hass = hass + sensor.update() - assert sensor.state == 75.2 + assert sensor.state == "75" From e2b1122eecb3c53960ee4af9fc3896dfd5e3ce36 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 25 Aug 2021 10:30:29 +0200 Subject: [PATCH 752/903] Activate mypy in gtfs (followup on reverted #54328) (#55195) --- homeassistant/components/gtfs/sensor.py | 17 ++++++++++------- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index f8f89b1ea36..f97bc9796ec 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -11,7 +11,10 @@ import pygtfs from sqlalchemy.sql import text import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, + SensorEntity, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_NAME, @@ -254,8 +257,8 @@ WHEELCHAIR_ACCESS_OPTIONS = {1: True, 2: False} WHEELCHAIR_BOARDING_DEFAULT = STATE_UNKNOWN WHEELCHAIR_BOARDING_OPTIONS = {1: True, 2: False} -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { # type: ignore +PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( + { vol.Required(CONF_ORIGIN): cv.string, vol.Required(CONF_DESTINATION): cv.string, vol.Required(CONF_DATA): cv.string, @@ -490,7 +493,7 @@ def setup_platform( origin = config.get(CONF_ORIGIN) destination = config.get(CONF_DESTINATION) name = config.get(CONF_NAME) - offset = config.get(CONF_OFFSET) + offset: datetime.timedelta = config[CONF_OFFSET] include_tomorrow = config[CONF_TOMORROW] if not os.path.exists(gtfs_dir): @@ -541,10 +544,10 @@ class GTFSDepartureSensor(SensorEntity): self._icon = ICON self._name = "" self._state: str | None = None - self._attributes = {} + self._attributes: dict[str, Any] = {} self._agency = None - self._departure = {} + self._departure: dict[str, Any] = {} self._destination = None self._origin = None self._route = None @@ -559,7 +562,7 @@ class GTFSDepartureSensor(SensorEntity): return self._name @property - def native_value(self) -> str | None: # type: ignore + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._state diff --git a/mypy.ini b/mypy.ini index e566b7f1898..82ed7d6ae9d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1355,9 +1355,6 @@ ignore_errors = true [mypy-homeassistant.components.growatt_server.*] ignore_errors = true -[mypy-homeassistant.components.gtfs.*] -ignore_errors = true - [mypy-homeassistant.components.habitica.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index bb2cf72b72e..581b4865f7c 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -44,7 +44,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.gpmdp.*", "homeassistant.components.gree.*", "homeassistant.components.growatt_server.*", - "homeassistant.components.gtfs.*", "homeassistant.components.habitica.*", "homeassistant.components.harmony.*", "homeassistant.components.hassio.*", From bf6d54991040b9ace2a0a6f361c2ba1a6e058e7f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 25 Aug 2021 02:34:02 -0600 Subject: [PATCH 753/903] Use EntityDescription - guardian (#55118) --- homeassistant/components/guardian/__init__.py | 38 ++--- .../components/guardian/binary_sensor.py | 120 +++++++--------- homeassistant/components/guardian/sensor.py | 135 +++++++----------- homeassistant/components/guardian/switch.py | 14 +- 4 files changed, 129 insertions(+), 178 deletions(-) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 915746c5ed5..94413c76578 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -213,20 +214,13 @@ class GuardianEntity(CoordinatorEntity): """Define a base Guardian entity.""" def __init__( # pylint: disable=super-init-not-called - self, - entry: ConfigEntry, - kind: str, - name: str, - device_class: str | None, - icon: str | None, + self, entry: ConfigEntry, description: EntityDescription ) -> None: """Initialize.""" - self._attr_device_class = device_class self._attr_device_info = {"manufacturer": "Elexa"} self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: "Data provided by Elexa"} - self._attr_icon = icon - self._attr_name = name self._entry = entry + self.entity_description = description @callback def _async_update_from_latest_data(self) -> None: @@ -244,13 +238,10 @@ class PairedSensorEntity(GuardianEntity): self, entry: ConfigEntry, coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str | None, - icon: str | None, + description: EntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, kind, name, device_class, icon) + super().__init__(entry, description) paired_sensor_uid = coordinator.data["uid"] self._attr_device_info = { @@ -258,9 +249,10 @@ class PairedSensorEntity(GuardianEntity): "name": f"Guardian Paired Sensor {paired_sensor_uid}", "via_device": (DOMAIN, entry.data[CONF_UID]), } - self._attr_name = f"Guardian Paired Sensor {paired_sensor_uid}: {name}" - self._attr_unique_id = f"{paired_sensor_uid}_{kind}" - self._kind = kind + self._attr_name = ( + f"Guardian Paired Sensor {paired_sensor_uid}: {description.name}" + ) + self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" self.coordinator = coordinator async def async_added_to_hass(self) -> None: @@ -275,22 +267,18 @@ class ValveControllerEntity(GuardianEntity): self, entry: ConfigEntry, coordinators: dict[str, DataUpdateCoordinator], - kind: str, - name: str, - device_class: str | None, - icon: str | None, + description: EntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, kind, name, device_class, icon) + super().__init__(entry, description) self._attr_device_info = { "identifiers": {(DOMAIN, entry.data[CONF_UID])}, "name": f"Guardian Valve Controller {entry.data[CONF_UID]}", "model": coordinators[API_SYSTEM_DIAGNOSTICS].data["firmware"], } - self._attr_name = f"Guardian {entry.data[CONF_UID]}: {name}" - self._attr_unique_id = f"{entry.data[CONF_UID]}_{kind}" - self._kind = kind + self._attr_name = f"Guardian {entry.data[CONF_UID]}: {description.name}" + self._attr_unique_id = f"{entry.data[CONF_UID]}_{description.key}" self.coordinators = coordinators @property diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 1cbc9f5cede..b420605a0ec 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOVING, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -31,14 +32,30 @@ SENSOR_KIND_AP_INFO = "ap_enabled" SENSOR_KIND_LEAK_DETECTED = "leak_detected" SENSOR_KIND_MOVED = "moved" -SENSOR_ATTRS_MAP = { - SENSOR_KIND_AP_INFO: ("Onboard AP Enabled", DEVICE_CLASS_CONNECTIVITY), - SENSOR_KIND_LEAK_DETECTED: ("Leak Detected", DEVICE_CLASS_MOISTURE), - SENSOR_KIND_MOVED: ("Recently Moved", DEVICE_CLASS_MOVING), -} +SENSOR_DESCRIPTION_AP_ENABLED = BinarySensorEntityDescription( + key=SENSOR_KIND_AP_INFO, + name="Onboard AP Enabled", + device_class=DEVICE_CLASS_CONNECTIVITY, +) +SENSOR_DESCRIPTION_LEAK_DETECTED = BinarySensorEntityDescription( + key=SENSOR_KIND_LEAK_DETECTED, + name="Leak Detected", + device_class=DEVICE_CLASS_MOISTURE, +) +SENSOR_DESCRIPTION_MOVED = BinarySensorEntityDescription( + key=SENSOR_KIND_MOVED, + name="Recently Moved", + device_class=DEVICE_CLASS_MOVING, +) -PAIRED_SENSOR_SENSORS = [SENSOR_KIND_LEAK_DETECTED, SENSOR_KIND_MOVED] -VALVE_CONTROLLER_SENSORS = [SENSOR_KIND_AP_INFO, SENSOR_KIND_LEAK_DETECTED] +PAIRED_SENSOR_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_LEAK_DETECTED, + SENSOR_DESCRIPTION_MOVED, +) +VALVE_CONTROLLER_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_AP_ENABLED, + SENSOR_DESCRIPTION_LEAK_DETECTED, +) async def async_setup_entry( @@ -53,21 +70,12 @@ async def async_setup_entry( uid ] - entities = [] - for kind in PAIRED_SENSOR_SENSORS: - name, device_class = SENSOR_ATTRS_MAP[kind] - entities.append( - PairedSensorBinarySensor( - entry, - coordinator, - kind, - name, - device_class, - None, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + PairedSensorBinarySensor(entry, coordinator, description) + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) # Handle adding paired sensors after HASS startup: hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id].append( @@ -78,38 +86,24 @@ async def async_setup_entry( ) ) - sensors: list[PairedSensorBinarySensor | ValveControllerBinarySensor] = [] - # Add all valve controller-specific binary sensors: - for kind in VALVE_CONTROLLER_SENSORS: - name, device_class = SENSOR_ATTRS_MAP[kind] - sensors.append( - ValveControllerBinarySensor( - entry, - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], - kind, - name, - device_class, - None, - ) + sensors: list[PairedSensorBinarySensor | ValveControllerBinarySensor] = [ + ValveControllerBinarySensor( + entry, hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], description ) + for description in VALVE_CONTROLLER_DESCRIPTIONS + ] # Add all paired sensor-specific binary sensors: - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ - entry.entry_id - ].values(): - for kind in PAIRED_SENSOR_SENSORS: - name, device_class = SENSOR_ATTRS_MAP[kind] - sensors.append( - PairedSensorBinarySensor( - entry, - coordinator, - kind, - name, - device_class, - None, - ) - ) + sensors.extend( + [ + PairedSensorBinarySensor(entry, coordinator, description) + for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + entry.entry_id + ].values() + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) @@ -121,22 +115,19 @@ class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity): self, entry: ConfigEntry, coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str | None, - icon: str | None, + description: BinarySensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, coordinator, kind, name, device_class, icon) + super().__init__(entry, coordinator, description) self._attr_is_on = True @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_LEAK_DETECTED: + if self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: self._attr_is_on = self.coordinator.data["wet"] - elif self._kind == SENSOR_KIND_MOVED: + elif self.entity_description.key == SENSOR_KIND_MOVED: self._attr_is_on = self.coordinator.data["moved"] @@ -147,27 +138,24 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): self, entry: ConfigEntry, coordinators: dict[str, DataUpdateCoordinator], - kind: str, - name: str, - device_class: str | None, - icon: str | None, + description: BinarySensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, coordinators, kind, name, device_class, icon) + super().__init__(entry, coordinators, description) self._attr_is_on = True async def _async_continue_entity_setup(self) -> None: """Add an API listener.""" - if self._kind == SENSOR_KIND_AP_INFO: + if self.entity_description.key == SENSOR_KIND_AP_INFO: self.async_add_coordinator_update_listener(API_WIFI_STATUS) - elif self._kind == SENSOR_KIND_LEAK_DETECTED: + elif self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: self.async_add_coordinator_update_listener(API_SYSTEM_ONBOARD_SENSOR_STATUS) @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_AP_INFO: + if self.entity_description.key == SENSOR_KIND_AP_INFO: self._attr_available = self.coordinators[ API_WIFI_STATUS ].last_update_success @@ -181,7 +169,7 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): ) } ) - elif self._kind == SENSOR_KIND_LEAK_DETECTED: + elif self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: self._attr_available = self.coordinators[ API_SYSTEM_ONBOARD_SENSOR_STATUS ].last_update_success diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index ed3cfedba0e..fb7952669cc 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -1,7 +1,7 @@ """Sensors for the Elexa Guardian integration.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, @@ -13,7 +13,6 @@ 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.update_coordinator import DataUpdateCoordinator from . import PairedSensorEntity, ValveControllerEntity from .const import ( @@ -31,19 +30,33 @@ SENSOR_KIND_BATTERY = "battery" SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_UPTIME = "uptime" -SENSOR_ATTRS_MAP = { - SENSOR_KIND_BATTERY: ("Battery", DEVICE_CLASS_BATTERY, None, PERCENTAGE), - SENSOR_KIND_TEMPERATURE: ( - "Temperature", - DEVICE_CLASS_TEMPERATURE, - None, - TEMP_FAHRENHEIT, - ), - SENSOR_KIND_UPTIME: ("Uptime", None, "mdi:timer", TIME_MINUTES), -} +SENSOR_DESCRIPTION_BATTERY = SensorEntityDescription( + key=SENSOR_KIND_BATTERY, + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + native_unit_of_measurement=PERCENTAGE, +) +SENSOR_DESCRIPTION_TEMPERATURE = SensorEntityDescription( + key=SENSOR_KIND_TEMPERATURE, + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_FAHRENHEIT, +) +SENSOR_DESCRIPTION_UPTIME = SensorEntityDescription( + key=SENSOR_KIND_UPTIME, + name="Uptime", + icon="mdi:timer", + native_unit_of_measurement=TIME_MINUTES, +) -PAIRED_SENSOR_SENSORS = [SENSOR_KIND_BATTERY, SENSOR_KIND_TEMPERATURE] -VALVE_CONTROLLER_SENSORS = [SENSOR_KIND_TEMPERATURE, SENSOR_KIND_UPTIME] +PAIRED_SENSOR_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_BATTERY, + SENSOR_DESCRIPTION_TEMPERATURE, +) +VALVE_CONTROLLER_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_TEMPERATURE, + SENSOR_DESCRIPTION_UPTIME, +) async def async_setup_entry( @@ -58,16 +71,12 @@ async def async_setup_entry( uid ] - entities = [] - for kind in PAIRED_SENSOR_SENSORS: - name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] - entities.append( - PairedSensorSensor( - entry, coordinator, kind, name, device_class, icon, unit - ) - ) - - async_add_entities(entities, True) + async_add_entities( + [ + PairedSensorSensor(entry, coordinator, description) + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) # Handle adding paired sensors after HASS startup: hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id].append( @@ -78,34 +87,24 @@ async def async_setup_entry( ) ) - sensors: list[PairedSensorSensor | ValveControllerSensor] = [] - # Add all valve controller-specific binary sensors: - for kind in VALVE_CONTROLLER_SENSORS: - name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] - sensors.append( - ValveControllerSensor( - entry, - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], - kind, - name, - device_class, - icon, - unit, - ) + sensors: list[PairedSensorSensor | ValveControllerSensor] = [ + ValveControllerSensor( + entry, hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], description ) + for description in VALVE_CONTROLLER_DESCRIPTIONS + ] # Add all paired sensor-specific binary sensors: - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ - entry.entry_id - ].values(): - for kind in PAIRED_SENSOR_SENSORS: - name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] - sensors.append( - PairedSensorSensor( - entry, coordinator, kind, name, device_class, icon, unit - ) - ) + sensors.extend( + [ + PairedSensorSensor(entry, coordinator, description) + for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + entry.entry_id + ].values() + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) @@ -113,64 +112,34 @@ async def async_setup_entry( class PairedSensorSensor(PairedSensorEntity, SensorEntity): """Define a binary sensor related to a Guardian valve controller.""" - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str | None, - icon: str | None, - unit: str | None, - ) -> None: - """Initialize.""" - super().__init__(entry, coordinator, kind, name, device_class, icon) - - self._attr_native_unit_of_measurement = unit - @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_BATTERY: + if self.entity_description.key == SENSOR_KIND_BATTERY: self._attr_native_value = self.coordinator.data["battery"] - elif self._kind == SENSOR_KIND_TEMPERATURE: + elif self.entity_description.key == SENSOR_KIND_TEMPERATURE: self._attr_native_value = self.coordinator.data["temperature"] class ValveControllerSensor(ValveControllerEntity, SensorEntity): """Define a generic Guardian sensor.""" - def __init__( - self, - entry: ConfigEntry, - coordinators: dict[str, DataUpdateCoordinator], - kind: str, - name: str, - device_class: str | None, - icon: str | None, - unit: str | None, - ) -> None: - """Initialize.""" - super().__init__(entry, coordinators, kind, name, device_class, icon) - - self._attr_native_unit_of_measurement = unit - async def _async_continue_entity_setup(self) -> None: """Register API interest (and related tasks) when the entity is added.""" - if self._kind == SENSOR_KIND_TEMPERATURE: + if self.entity_description.key == SENSOR_KIND_TEMPERATURE: self.async_add_coordinator_update_listener(API_SYSTEM_ONBOARD_SENSOR_STATUS) @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_TEMPERATURE: + if self.entity_description.key == SENSOR_KIND_TEMPERATURE: self._attr_available = self.coordinators[ API_SYSTEM_ONBOARD_SENSOR_STATUS ].last_update_success self._attr_native_value = self.coordinators[ API_SYSTEM_ONBOARD_SENSOR_STATUS ].data["temperature"] - elif self._kind == SENSOR_KIND_UPTIME: + elif self.entity_description.key == SENSOR_KIND_UPTIME: self._attr_available = self.coordinators[ API_SYSTEM_DIAGNOSTICS ].last_update_success diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 7343a1f5c27..9b7db16dd15 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -7,7 +7,7 @@ from aioguardian import Client from aioguardian.errors import GuardianError import voluptuous as vol -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILENAME, CONF_PORT, CONF_URL from homeassistant.core import HomeAssistant, callback @@ -39,6 +39,14 @@ SERVICE_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics" SERVICE_UNPAIR_SENSOR = "unpair_sensor" SERVICE_UPGRADE_FIRMWARE = "upgrade_firmware" +SWITCH_KIND_VALVE = "valve" + +SWITCH_DESCRIPTION_VALVE = SwitchEntityDescription( + key=SWITCH_KIND_VALVE, + name="Valve Controller", + icon="mdi:water", +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -90,9 +98,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): coordinators: dict[str, DataUpdateCoordinator], ) -> None: """Initialize.""" - super().__init__( - entry, coordinators, "valve", "Valve Controller", None, "mdi:water" - ) + super().__init__(entry, coordinators, SWITCH_DESCRIPTION_VALVE) self._attr_is_on = True self._client = client From 839b9563adb87daea7864b5469d0954ff9ac630c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 10:35:15 +0200 Subject: [PATCH 754/903] Use EntityDescription - htu21d (#55186) --- homeassistant/components/htu21d/sensor.py | 60 +++++++++++------------ 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py index 7032f36919b..d43a0733daf 100644 --- a/homeassistant/components/htu21d/sensor.py +++ b/homeassistant/components/htu21d/sensor.py @@ -1,4 +1,6 @@ """Support for HTU21D temperature and humidity sensor.""" +from __future__ import annotations + from datetime import timedelta from functools import partial import logging @@ -7,7 +9,11 @@ from i2csense.htu21d import HTU21D # pylint: disable=import-error import smbus import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_NAME, DEVICE_CLASS_HUMIDITY, @@ -30,6 +36,19 @@ DEFAULT_NAME = "HTU21D Sensor" SENSOR_TEMPERATURE = "temperature" SENSOR_HUMIDITY = "humidity" +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -37,11 +56,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) -DEVICE_CLASS_MAP = { - SENSOR_TEMPERATURE: DEVICE_CLASS_TEMPERATURE, - SENSOR_HUMIDITY: DEVICE_CLASS_HUMIDITY, -} - async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the HTU21D sensor.""" @@ -56,12 +70,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sensor_handler = await hass.async_add_executor_job(HTU21DHandler, sensor) - dev = [ - HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, TEMP_CELSIUS), - HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, PERCENTAGE), + entities = [ + HTU21DSensor(sensor_handler, name, description) for description in SENSOR_TYPES ] - async_add_entities(dev) + async_add_entities(entities) class HTU21DHandler: @@ -81,38 +94,21 @@ class HTU21DHandler: class HTU21DSensor(SensorEntity): """Implementation of the HTU21D sensor.""" - def __init__(self, htu21d_client, name, variable, unit): + def __init__(self, htu21d_client, name, description: SensorEntityDescription): """Initialize the sensor.""" - self._name = f"{name}_{variable}" - self._variable = variable - self._unit_of_measurement = unit + self.entity_description = description self._client = htu21d_client - self._state = None - self._attr_device_class = DEVICE_CLASS_MAP[variable] - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement + self._attr_name = f"{name}_{description.key}" async def async_update(self): """Get the latest data from the HTU21D sensor and update the state.""" await self.hass.async_add_executor_job(self._client.update) if self._client.sensor.sample_ok: - if self._variable == SENSOR_TEMPERATURE: + if self.entity_description.key == SENSOR_TEMPERATURE: value = round(self._client.sensor.temperature, 1) else: value = round(self._client.sensor.humidity, 1) - self._state = value + self._attr_native_value = value else: _LOGGER.warning("Bad sample") From 5e44498f1c892ee7ab874f761eb6755b1b04be89 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 10:36:41 +0200 Subject: [PATCH 755/903] Use EntityDescription - bme680 (#55185) --- homeassistant/components/bme680/sensor.py | 79 ++++++++++++++++------- 1 file changed, 55 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py index 5dca1da45a2..6c32639fdb4 100644 --- a/homeassistant/components/bme680/sensor.py +++ b/homeassistant/components/bme680/sensor.py @@ -1,4 +1,6 @@ """Support for BME680 Sensor over SMBus.""" +from __future__ import annotations + import logging import threading from time import monotonic, sleep @@ -7,7 +9,11 @@ import bme680 # pylint: disable=import-error from smbus import SMBus import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, @@ -54,13 +60,37 @@ SENSOR_HUMID = "humidity" SENSOR_PRESS = "pressure" SENSOR_GAS = "gas" SENSOR_AQ = "airquality" -SENSOR_TYPES = { - SENSOR_TEMP: ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - SENSOR_HUMID: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], - SENSOR_PRESS: ["Pressure", "mb", DEVICE_CLASS_PRESSURE], - SENSOR_GAS: ["Gas Resistance", "Ohms", None], - SENSOR_AQ: ["Air Quality", PERCENTAGE, None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMP, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMID, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=SENSOR_PRESS, + name="Pressure", + native_unit_of_measurement="mb", + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key=SENSOR_GAS, + name="Gas Resistance", + native_unit_of_measurement="Ohms", + ), + SensorEntityDescription( + key=SENSOR_AQ, + name="Air Quality", + native_unit_of_measurement=PERCENTAGE, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS, SENSOR_AQ] OVERSAMPLING_VALUES = {0, 1, 2, 4, 8, 16} FILTER_VALUES = {0, 1, 3, 7, 15, 31, 63, 127} @@ -70,7 +100,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.positive_int, vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): cv.positive_int, vol.Optional( @@ -115,12 +145,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if sensor_handler is None: return - dev = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append(BME680Sensor(sensor_handler, variable, name)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + BME680Sensor(sensor_handler, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - async_add_entities(dev) - return + async_add_entities(entities) def _setup_bme680(config): @@ -317,30 +349,29 @@ class BME680Handler: class BME680Sensor(SensorEntity): """Implementation of the BME680 sensor.""" - def __init__(self, bme680_client, sensor_type, name): + def __init__(self, bme680_client, name, description: SensorEntityDescription): """Initialize the sensor.""" - self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.bme680_client = bme680_client - self.type = sensor_type - self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] async def async_update(self): """Get the latest data from the BME680 and update the states.""" await self.hass.async_add_executor_job(self.bme680_client.update) - if self.type == SENSOR_TEMP: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMP: self._attr_native_value = round( self.bme680_client.sensor_data.temperature, 1 ) - elif self.type == SENSOR_HUMID: + elif sensor_type == SENSOR_HUMID: self._attr_native_value = round(self.bme680_client.sensor_data.humidity, 1) - elif self.type == SENSOR_PRESS: + elif sensor_type == SENSOR_PRESS: self._attr_native_value = round(self.bme680_client.sensor_data.pressure, 1) - elif self.type == SENSOR_GAS: + elif sensor_type == SENSOR_GAS: self._attr_native_value = int( round(self.bme680_client.sensor_data.gas_resistance, 0) ) - elif self.type == SENSOR_AQ: + elif sensor_type == SENSOR_AQ: aq_score = self.bme680_client.sensor_data.air_quality if aq_score is not None: self._attr_native_value = round(aq_score, 1) From 4a03d8dc4763011d0c3c05518cacd681b85c5395 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 10:39:59 +0200 Subject: [PATCH 756/903] Use EntityDescription - bme280 (#55184) --- homeassistant/components/bme280/__init__.py | 4 +-- homeassistant/components/bme280/const.py | 29 ++++++++++++++--- homeassistant/components/bme280/sensor.py | 36 ++++++++++----------- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/bme280/__init__.py b/homeassistant/components/bme280/__init__.py index 8de2b2ffe8b..eca9ac85bc9 100644 --- a/homeassistant/components/bme280/__init__.py +++ b/homeassistant/components/bme280/__init__.py @@ -30,7 +30,7 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_T_STANDBY, DOMAIN, - SENSOR_TYPES, + SENSOR_KEYS, ) CONFIG_SCHEMA = vol.Schema( @@ -54,7 +54,7 @@ CONFIG_SCHEMA = vol.Schema( ): vol.Coerce(float), vol.Optional( CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED - ): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + ): vol.All(cv.ensure_list, [vol.In(SENSOR_KEYS)]), vol.Optional( CONF_OVERSAMPLING_TEMP, default=DEFAULT_OVERSAMPLING_TEMP ): vol.Coerce(int), diff --git a/homeassistant/components/bme280/const.py b/homeassistant/components/bme280/const.py index 36753ef0292..e217c0df29e 100644 --- a/homeassistant/components/bme280/const.py +++ b/homeassistant/components/bme280/const.py @@ -1,6 +1,9 @@ """Constants for the BME280 component.""" +from __future__ import annotations + from datetime import timedelta +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, @@ -26,11 +29,27 @@ DEFAULT_SCAN_INTERVAL = 300 SENSOR_TEMP = "temperature" SENSOR_HUMID = "humidity" SENSOR_PRESS = "pressure" -SENSOR_TYPES = { - SENSOR_TEMP: ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - SENSOR_HUMID: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], - SENSOR_PRESS: ["Pressure", "mb", DEVICE_CLASS_PRESSURE], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMP, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMID, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=SENSOR_PRESS, + name="Pressure", + native_unit_of_measurement="mb", + device_class=DEVICE_CLASS_PRESSURE, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3) # SPI diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py index de6d6eb0729..f49d8959076 100644 --- a/homeassistant/components/bme280/sensor.py +++ b/homeassistant/components/bme280/sensor.py @@ -6,7 +6,11 @@ from bme280spi import BME280 as BME280_spi # pylint: disable=import-error from i2csense.bme280 import BME280 as BME280_i2c # pylint: disable=import-error import smbus -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -98,38 +102,34 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= update_interval=scan_interval, ) await coordinator.async_refresh() - entities = [] - for condition in sensor_conf[CONF_MONITORED_CONDITIONS]: - entities.append( - BME280Sensor( - condition, - name, - coordinator, - ) - ) + monitored_conditions = sensor_conf[CONF_MONITORED_CONDITIONS] + entities = [ + BME280Sensor(name, coordinator, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] async_add_entities(entities, True) class BME280Sensor(CoordinatorEntity, SensorEntity): """Implementation of the BME280 sensor.""" - def __init__(self, sensor_type, name, coordinator): + def __init__(self, name, coordinator, description: SensorEntityDescription): """Initialize the sensor.""" super().__init__(coordinator) - self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" - self.type = sensor_type - self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] + self.entity_description = description + self._attr_name = f"{name} {description.name}" @property def native_value(self): """Return the state of the sensor.""" - if self.type == SENSOR_TEMP: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMP: temperature = round(self.coordinator.data.temperature, 1) state = temperature - elif self.type == SENSOR_HUMID: + elif sensor_type == SENSOR_HUMID: state = round(self.coordinator.data.humidity, 1) - elif self.type == SENSOR_PRESS: + elif sensor_type == SENSOR_PRESS: state = round(self.coordinator.data.pressure, 1) return state From f92ba18a6b39b193849b249301f98b9a7324ae2a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 25 Aug 2021 02:42:57 -0600 Subject: [PATCH 757/903] Use EntityDescription - notion (#55120) --- homeassistant/components/notion/__init__.py | 9 +- .../components/notion/binary_sensor.py | 92 +++++++++++++------ homeassistant/components/notion/sensor.py | 56 ++++------- 3 files changed, 88 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index aab10916514..c06a08560e9 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -17,6 +17,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, ) +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -137,14 +138,11 @@ class NotionEntity(CoordinatorEntity): sensor_id: str, bridge_id: str, system_id: str, - name: str, - device_class: str, + description: EntityDescription, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._attr_device_class = device_class - bridge = self.coordinator.data["bridges"].get(bridge_id, {}) sensor = self.coordinator.data["sensors"][sensor_id] self._attr_device_info = { @@ -157,7 +155,7 @@ class NotionEntity(CoordinatorEntity): } self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._attr_name = f'{sensor["name"]}: {name}' + self._attr_name = f'{sensor["name"]}: {description.name}' self._attr_unique_id = ( f'{sensor_id}_{coordinator.data["tasks"][task_id]["task_type"]}' ) @@ -165,6 +163,7 @@ class NotionEntity(CoordinatorEntity): self._sensor_id = sensor_id self._system_id = system_id self._task_id = task_id + self.entity_description = description @property def available(self) -> bool: diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index d3b1d8e3ef2..15c5877ae77 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -1,11 +1,16 @@ """Support for Notion binary sensors.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY, DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_DOOR, + DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, DEVICE_CLASS_WINDOW, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -28,18 +33,58 @@ from .const import ( SENSOR_WINDOW_HINGED_VERTICAL, ) -BINARY_SENSOR_TYPES = { - SENSOR_BATTERY: ("Low Battery", "battery"), - SENSOR_DOOR: ("Door", DEVICE_CLASS_DOOR), - SENSOR_GARAGE_DOOR: ("Garage Door", "garage_door"), - SENSOR_LEAK: ("Leak Detector", DEVICE_CLASS_MOISTURE), - SENSOR_MISSING: ("Missing", DEVICE_CLASS_CONNECTIVITY), - SENSOR_SAFE: ("Safe", DEVICE_CLASS_DOOR), - SENSOR_SLIDING: ("Sliding Door/Window", DEVICE_CLASS_DOOR), - SENSOR_SMOKE_CO: ("Smoke/Carbon Monoxide Detector", DEVICE_CLASS_SMOKE), - SENSOR_WINDOW_HINGED_HORIZONTAL: ("Hinged Window", DEVICE_CLASS_WINDOW), - SENSOR_WINDOW_HINGED_VERTICAL: ("Hinged Window", DEVICE_CLASS_WINDOW), -} +BINARY_SENSOR_DESCRIPTIONS = ( + BinarySensorEntityDescription( + key=SENSOR_BATTERY, + name="Low Battery", + device_class=DEVICE_CLASS_BATTERY, + ), + BinarySensorEntityDescription( + key=SENSOR_DOOR, + name="Door", + device_class=DEVICE_CLASS_DOOR, + ), + BinarySensorEntityDescription( + key=SENSOR_GARAGE_DOOR, + name="Garage Door", + device_class=DEVICE_CLASS_GARAGE_DOOR, + ), + BinarySensorEntityDescription( + key=SENSOR_LEAK, + name="Leak Detector", + device_class=DEVICE_CLASS_MOISTURE, + ), + BinarySensorEntityDescription( + key=SENSOR_MISSING, + name="Missing", + device_class=DEVICE_CLASS_CONNECTIVITY, + ), + BinarySensorEntityDescription( + key=SENSOR_SAFE, + name="Safe", + device_class=DEVICE_CLASS_DOOR, + ), + BinarySensorEntityDescription( + key=SENSOR_SLIDING, + name="Sliding Door/Window", + device_class=DEVICE_CLASS_DOOR, + ), + BinarySensorEntityDescription( + key=SENSOR_SMOKE_CO, + name="Smoke/Carbon Monoxide Detector", + device_class=DEVICE_CLASS_SMOKE, + ), + BinarySensorEntityDescription( + key=SENSOR_WINDOW_HINGED_HORIZONTAL, + name="Hinged Window", + device_class=DEVICE_CLASS_WINDOW, + ), + BinarySensorEntityDescription( + key=SENSOR_WINDOW_HINGED_VERTICAL, + name="Hinged Window", + device_class=DEVICE_CLASS_WINDOW, + ), +) async def async_setup_entry( @@ -48,27 +93,22 @@ async def async_setup_entry( """Set up Notion sensors based on a config entry.""" coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] - sensor_list = [] - for task_id, task in coordinator.data["tasks"].items(): - if task["task_type"] not in BINARY_SENSOR_TYPES: - continue - - name, device_class = BINARY_SENSOR_TYPES[task["task_type"]] - sensor = coordinator.data["sensors"][task["sensor_id"]] - - sensor_list.append( + async_add_entities( + [ NotionBinarySensor( coordinator, task_id, sensor["id"], sensor["bridge"]["id"], sensor["system_id"], - name, - device_class, + description, ) - ) - - async_add_entities(sensor_list) + for task_id, task in coordinator.data["tasks"].items() + for description in BINARY_SENSOR_DESCRIPTIONS + if description.key == task["task_type"] + and (sensor := coordinator.data["sensors"][task["sensor_id"]]) + ] + ) class NotionBinarySensor(NotionEntity, BinarySensorEntity): diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index cf6c394dbda..803cfce3360 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -1,17 +1,21 @@ """Support for Notion sensors.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import NotionEntity from .const import DATA_COORDINATOR, DOMAIN, LOGGER, SENSOR_TEMPERATURE -SENSOR_TYPES = { - SENSOR_TEMPERATURE: ("Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) -} +SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), +) async def async_setup_entry( @@ -20,51 +24,27 @@ async def async_setup_entry( """Set up Notion sensors based on a config entry.""" coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] - sensor_list = [] - for task_id, task in coordinator.data["tasks"].items(): - if task["task_type"] not in SENSOR_TYPES: - continue - - name, device_class, unit = SENSOR_TYPES[task["task_type"]] - sensor = coordinator.data["sensors"][task["sensor_id"]] - - sensor_list.append( + async_add_entities( + [ NotionSensor( coordinator, task_id, sensor["id"], sensor["bridge"]["id"], sensor["system_id"], - name, - device_class, - unit, + description, ) - ) - - async_add_entities(sensor_list) + for task_id, task in coordinator.data["tasks"].items() + for description in SENSOR_DESCRIPTIONS + if description.key == task["task_type"] + and (sensor := coordinator.data["sensors"][task["sensor_id"]]) + ] + ) class NotionSensor(NotionEntity, SensorEntity): """Define a Notion sensor.""" - def __init__( - self, - coordinator: DataUpdateCoordinator, - task_id: str, - sensor_id: str, - bridge_id: str, - system_id: str, - name: str, - device_class: str, - unit: str, - ) -> None: - """Initialize the entity.""" - super().__init__( - coordinator, task_id, sensor_id, bridge_id, system_id, name, device_class - ) - - self._attr_native_unit_of_measurement = unit - @callback def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" From c9e8d42405b94f2cdf7cda218239fb3f11d5686a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 25 Aug 2021 02:51:02 -0600 Subject: [PATCH 758/903] Use EntityDescription - airvisual (#55125) --- .../components/airvisual/__init__.py | 6 +- homeassistant/components/airvisual/sensor.py | 199 +++++++++--------- 2 files changed, 105 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index c44e39b59e4..0419e43cd81 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -32,6 +32,7 @@ from homeassistant.helpers import ( config_validation as cv, entity_registry, ) +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -358,11 +359,14 @@ async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class AirVisualEntity(CoordinatorEntity): """Define a generic AirVisual entity.""" - def __init__(self, coordinator: DataUpdateCoordinator) -> None: + def __init__( + self, coordinator: DataUpdateCoordinator, description: EntityDescription + ) -> None: """Initialize.""" super().__init__(coordinator) self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self.entity_description = description async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 922c84357ae..72f94875ea8 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,7 +1,7 @@ """Support for AirVisual air quality sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, @@ -14,10 +14,15 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_SHOW_ON_MAP, CONF_STATE, + DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, PERCENTAGE, TEMP_CELSIUS, ) @@ -59,60 +64,84 @@ SENSOR_KIND_SENSOR_LIFE = "sensor_life" SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_VOC = "voc" -GEOGRAPHY_SENSORS = [ - (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None), - (SENSOR_KIND_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), - (SENSOR_KIND_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), -] +GEOGRAPHY_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_KIND_LEVEL, + name="Air Pollution Level", + device_class=DEVICE_CLASS_POLLUTANT_LEVEL, + icon="mdi:gauge", + ), + SensorEntityDescription( + key=SENSOR_KIND_AQI, + name="Air Quality Index", + device_class=DEVICE_CLASS_AQI, + native_unit_of_measurement="AQI", + ), + SensorEntityDescription( + key=SENSOR_KIND_POLLUTANT, + name="Main Pollutant", + device_class=DEVICE_CLASS_POLLUTANT_LABEL, + icon="mdi:chemical-weapon", + ), +) GEOGRAPHY_SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} -NODE_PRO_SENSORS = [ - (SENSOR_KIND_AQI, "Air Quality Index", None, "mdi:chart-line", "AQI"), - (SENSOR_KIND_BATTERY_LEVEL, "Battery", DEVICE_CLASS_BATTERY, None, PERCENTAGE), - ( - SENSOR_KIND_CO2, - "C02", - DEVICE_CLASS_CO2, - None, - CONCENTRATION_PARTS_PER_MILLION, +NODE_PRO_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_KIND_AQI, + name="Air Quality Index", + device_class=DEVICE_CLASS_AQI, + native_unit_of_measurement="AQI", ), - (SENSOR_KIND_HUMIDITY, "Humidity", DEVICE_CLASS_HUMIDITY, None, PERCENTAGE), - ( - SENSOR_KIND_PM_0_1, - "PM 0.1", - None, - "mdi:sprinkler", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + SensorEntityDescription( + key=SENSOR_KIND_BATTERY_LEVEL, + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + native_unit_of_measurement=PERCENTAGE, ), - ( - SENSOR_KIND_PM_1_0, - "PM 1.0", - None, - "mdi:sprinkler", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + SensorEntityDescription( + key=SENSOR_KIND_CO2, + name="C02", + device_class=DEVICE_CLASS_CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), - ( - SENSOR_KIND_PM_2_5, - "PM 2.5", - None, - "mdi:sprinkler", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + SensorEntityDescription( + key=SENSOR_KIND_HUMIDITY, + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, ), - ( - SENSOR_KIND_TEMPERATURE, - "Temperature", - DEVICE_CLASS_TEMPERATURE, - None, - TEMP_CELSIUS, + SensorEntityDescription( + key=SENSOR_KIND_PM_0_1, + name="PM 0.1", + device_class=DEVICE_CLASS_PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), - ( - SENSOR_KIND_VOC, - "VOC", - None, - "mdi:sprinkler", - CONCENTRATION_PARTS_PER_MILLION, + SensorEntityDescription( + key=SENSOR_KIND_PM_1_0, + name="PM 1.0", + device_class=DEVICE_CLASS_PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), -] + SensorEntityDescription( + key=SENSOR_KIND_PM_2_5, + name="PM 2.5", + device_class=DEVICE_CLASS_PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key=SENSOR_KIND_TEMPERATURE, + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + SensorEntityDescription( + key=SENSOR_KIND_VOC, + name="VOC", + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), +) STATE_POLLUTANT_LABEL_CO = "co" STATE_POLLUTANT_LABEL_N2 = "n2" @@ -161,22 +190,14 @@ async def async_setup_entry( INTEGRATION_TYPE_GEOGRAPHY_NAME, ): sensors = [ - AirVisualGeographySensor( - coordinator, - config_entry, - kind, - name, - icon, - unit, - locale, - ) + AirVisualGeographySensor(coordinator, config_entry, description, locale) for locale in GEOGRAPHY_SENSOR_LOCALES - for kind, name, icon, unit in GEOGRAPHY_SENSORS + for description in GEOGRAPHY_SENSOR_DESCRIPTIONS ] else: sensors = [ - AirVisualNodeProSensor(coordinator, kind, name, device_class, icon, unit) - for kind, name, device_class, icon, unit in NODE_PRO_SENSORS + AirVisualNodeProSensor(coordinator, description) + for description in NODE_PRO_SENSOR_DESCRIPTIONS ] async_add_entities(sensors, True) @@ -189,19 +210,12 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): self, coordinator: DataUpdateCoordinator, config_entry: ConfigEntry, - kind: str, - name: str, - icon: str, - unit: str | None, + description: SensorEntityDescription, locale: str, ) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__(coordinator, description) - if kind == SENSOR_KIND_LEVEL: - self._attr_device_class = DEVICE_CLASS_POLLUTANT_LEVEL - elif kind == SENSOR_KIND_POLLUTANT: - self._attr_device_class = DEVICE_CLASS_POLLUTANT_LABEL self._attr_extra_state_attributes.update( { ATTR_CITY: config_entry.data.get(CONF_CITY), @@ -209,12 +223,9 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): ATTR_COUNTRY: config_entry.data.get(CONF_COUNTRY), } ) - self._attr_icon = icon - self._attr_name = f"{GEOGRAPHY_SENSOR_LOCALES[locale]} {name}" - self._attr_unique_id = f"{config_entry.unique_id}_{locale}_{kind}" - self._attr_native_unit_of_measurement = unit + self._attr_name = f"{GEOGRAPHY_SENSOR_LOCALES[locale]} {description.name}" + self._attr_unique_id = f"{config_entry.unique_id}_{locale}_{description.key}" self._config_entry = config_entry - self._kind = kind self._locale = locale @property @@ -230,16 +241,16 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): except KeyError: return - if self._kind == SENSOR_KIND_LEVEL: + if self.entity_description.key == SENSOR_KIND_LEVEL: aqi = data[f"aqi{self._locale}"] [(self._attr_native_value, self._attr_icon)] = [ (name, icon) for (floor, ceiling), (name, icon) in POLLUTANT_LEVELS.items() if floor <= aqi <= ceiling ] - elif self._kind == SENSOR_KIND_AQI: + elif self.entity_description.key == SENSOR_KIND_AQI: self._attr_native_value = data[f"aqi{self._locale}"] - elif self._kind == SENSOR_KIND_POLLUTANT: + elif self.entity_description.key == SENSOR_KIND_POLLUTANT: symbol = data[f"main{self._locale}"] self._attr_native_value = symbol self._attr_extra_state_attributes.update( @@ -281,25 +292,15 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): """Define an AirVisual sensor related to a Node/Pro unit.""" def __init__( - self, - coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str | None, - icon: str | None, - unit: str, + self, coordinator: DataUpdateCoordinator, description: SensorEntityDescription ) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__(coordinator, description) - self._attr_device_class = device_class - self._attr_icon = icon self._attr_name = ( - f"{coordinator.data['settings']['node_name']} Node/Pro: {name}" + f"{coordinator.data['settings']['node_name']} Node/Pro: {description.name}" ) - self._attr_unique_id = f"{coordinator.data['serial_number']}_{kind}" - self._attr_native_unit_of_measurement = unit - self._kind = kind + self._attr_unique_id = f"{coordinator.data['serial_number']}_{description.key}" @property def device_info(self) -> DeviceInfo: @@ -318,7 +319,7 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): @callback def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" - if self._kind == SENSOR_KIND_AQI: + if self.entity_description.key == SENSOR_KIND_AQI: if self.coordinator.data["settings"]["is_aqi_usa"]: self._attr_native_value = self.coordinator.data["measurements"][ "aqi_us" @@ -327,23 +328,23 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): self._attr_native_value = self.coordinator.data["measurements"][ "aqi_cn" ] - elif self._kind == SENSOR_KIND_BATTERY_LEVEL: + elif self.entity_description.key == SENSOR_KIND_BATTERY_LEVEL: self._attr_native_value = self.coordinator.data["status"]["battery"] - elif self._kind == SENSOR_KIND_CO2: + elif self.entity_description.key == SENSOR_KIND_CO2: self._attr_native_value = self.coordinator.data["measurements"].get("co2") - elif self._kind == SENSOR_KIND_HUMIDITY: + elif self.entity_description.key == SENSOR_KIND_HUMIDITY: self._attr_native_value = self.coordinator.data["measurements"].get( "humidity" ) - elif self._kind == SENSOR_KIND_PM_0_1: + elif self.entity_description.key == SENSOR_KIND_PM_0_1: self._attr_native_value = self.coordinator.data["measurements"].get("pm0_1") - elif self._kind == SENSOR_KIND_PM_1_0: + elif self.entity_description.key == SENSOR_KIND_PM_1_0: self._attr_native_value = self.coordinator.data["measurements"].get("pm1_0") - elif self._kind == SENSOR_KIND_PM_2_5: + elif self.entity_description.key == SENSOR_KIND_PM_2_5: self._attr_native_value = self.coordinator.data["measurements"].get("pm2_5") - elif self._kind == SENSOR_KIND_TEMPERATURE: + elif self.entity_description.key == SENSOR_KIND_TEMPERATURE: self._attr_native_value = self.coordinator.data["measurements"].get( "temperature_C" ) - elif self._kind == SENSOR_KIND_VOC: + elif self.entity_description.key == SENSOR_KIND_VOC: self._attr_native_value = self.coordinator.data["measurements"].get("voc") From e99761fd7fcfd08f913eaa4b26d5360f3334559b Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 25 Aug 2021 02:52:37 -0600 Subject: [PATCH 759/903] Use EntityDescription - flunearyou (#55126) --- homeassistant/components/flunearyou/sensor.py | 136 ++++++++++-------- 1 file changed, 80 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index e28419c5d06..98d11b0dc45 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -1,7 +1,7 @@ """Support for user- and CDC-based flu info sensors from Flu Near You.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -38,20 +38,63 @@ SENSOR_TYPE_USER_NO_SYMPTOMS = "none" SENSOR_TYPE_USER_SYMPTOMS = "symptoms" SENSOR_TYPE_USER_TOTAL = "total" -CDC_SENSORS = [ - (SENSOR_TYPE_CDC_LEVEL, "CDC Level", "mdi:biohazard", None), - (SENSOR_TYPE_CDC_LEVEL2, "CDC Level 2", "mdi:biohazard", None), -] +CDC_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_TYPE_CDC_LEVEL, + name="CDC Level", + icon="mdi:biohazard", + ), + SensorEntityDescription( + key=SENSOR_TYPE_CDC_LEVEL2, + name="CDC Level 2", + icon="mdi:biohazard", + ), +) -USER_SENSORS = [ - (SENSOR_TYPE_USER_CHICK, "Avian Flu Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_DENGUE, "Dengue Fever Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_FLU, "Flu Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_LEPTO, "Leptospirosis Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_NO_SYMPTOMS, "No Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_SYMPTOMS, "Flu-like Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_TOTAL, "Total Symptoms", "mdi:alert", "reports"), -] +USER_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_TYPE_USER_CHICK, + name="Avian Flu Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_DENGUE, + name="Dengue Fever Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_FLU, + name="Flu Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_LEPTO, + name="Leptospirosis Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_NO_SYMPTOMS, + name="No Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_SYMPTOMS, + name="Flu-like Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_TOTAL, + name="Total Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), +) EXTENDED_SENSOR_TYPE_MAPPING = { SENSOR_TYPE_USER_FLU: "ili", @@ -66,32 +109,16 @@ async def async_setup_entry( """Set up Flu Near You sensors based on a config entry.""" coordinators = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] - sensors: list[CdcSensor | UserSensor] = [] - - for (sensor_type, name, icon, unit) in CDC_SENSORS: - sensors.append( - CdcSensor( - coordinators[CATEGORY_CDC_REPORT], - entry, - sensor_type, - name, - icon, - unit, - ) - ) - - for (sensor_type, name, icon, unit) in USER_SENSORS: - sensors.append( - UserSensor( - coordinators[CATEGORY_USER_REPORT], - entry, - sensor_type, - name, - icon, - unit, - ) - ) - + sensors: list[CdcSensor | UserSensor] = [ + CdcSensor(coordinators[CATEGORY_CDC_REPORT], entry, description) + for description in CDC_SENSOR_DESCRIPTIONS + ] + sensors.extend( + [ + UserSensor(coordinators[CATEGORY_USER_REPORT], entry, description) + for description in USER_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) @@ -102,23 +129,18 @@ class FluNearYouSensor(CoordinatorEntity, SensorEntity): self, coordinator: DataUpdateCoordinator, entry: ConfigEntry, - sensor_type: str, - name: str, - icon: str, - unit: str | None, + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._attr_icon = icon - self._attr_name = name self._attr_unique_id = ( f"{entry.data[CONF_LATITUDE]}," - f"{entry.data[CONF_LONGITUDE]}_{sensor_type}" + f"{entry.data[CONF_LONGITUDE]}_{description.key}" ) - self._attr_native_unit_of_measurement = unit self._entry = entry - self._sensor_type = sensor_type + self.entity_description = description @callback def _handle_coordinator_update(self) -> None: @@ -149,7 +171,7 @@ class CdcSensor(FluNearYouSensor): ATTR_STATE: self.coordinator.data["name"], } ) - self._attr_native_value = self.coordinator.data[self._sensor_type] + self._attr_native_value = self.coordinator.data[self.entity_description.key] class UserSensor(FluNearYouSensor): @@ -168,10 +190,10 @@ class UserSensor(FluNearYouSensor): } ) - if self._sensor_type in self.coordinator.data["state"]["data"]: - states_key = self._sensor_type - elif self._sensor_type in EXTENDED_SENSOR_TYPE_MAPPING: - states_key = EXTENDED_SENSOR_TYPE_MAPPING[self._sensor_type] + if self.entity_description.key in self.coordinator.data["state"]["data"]: + states_key = self.entity_description.key + elif self.entity_description.key in EXTENDED_SENSOR_TYPE_MAPPING: + states_key = EXTENDED_SENSOR_TYPE_MAPPING[self.entity_description.key] self._attr_extra_state_attributes[ ATTR_STATE_REPORTS_THIS_WEEK @@ -180,7 +202,7 @@ class UserSensor(FluNearYouSensor): ATTR_STATE_REPORTS_LAST_WEEK ] = self.coordinator.data["state"]["last_week_data"][states_key] - if self._sensor_type == SENSOR_TYPE_USER_TOTAL: + if self.entity_description.key == SENSOR_TYPE_USER_TOTAL: self._attr_native_value = sum( v for k, v in self.coordinator.data["local"].items() @@ -194,4 +216,6 @@ class UserSensor(FluNearYouSensor): ) ) else: - self._attr_native_value = self.coordinator.data["local"][self._sensor_type] + self._attr_native_value = self.coordinator.data["local"][ + self.entity_description.key + ] From db5e159b6dcdfb20a83a0cecb10cc2417e7ea156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20Rutkai?= Date: Wed, 25 Aug 2021 10:55:46 +0200 Subject: [PATCH 760/903] Updating IBM Watson SDK (#54914) --- homeassistant/components/watson_tts/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json index 679ea1ef5c3..cf70a808829 100644 --- a/homeassistant/components/watson_tts/manifest.json +++ b/homeassistant/components/watson_tts/manifest.json @@ -2,7 +2,7 @@ "domain": "watson_tts", "name": "IBM Watson TTS", "documentation": "https://www.home-assistant.io/integrations/watson_tts", - "requirements": ["ibm-watson==5.1.0"], + "requirements": ["ibm-watson==5.2.2"], "codeowners": ["@rutkai"], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 8740a8b00a8..7458081ab44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -835,7 +835,7 @@ iammeter==0.1.7 iaqualink==0.3.90 # homeassistant.components.watson_tts -ibm-watson==5.1.0 +ibm-watson==5.2.2 # homeassistant.components.watson_iot ibmiotf==0.3.4 From 49041b1469500a9ff2ad165452a37fc9ca2607ff Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 25 Aug 2021 11:16:23 +0200 Subject: [PATCH 761/903] Add account type to Forecast.Solar integration (#55175) --- homeassistant/components/forecast_solar/sensor.py | 8 +++++++- tests/components/forecast_solar/conftest.py | 1 + tests/components/forecast_solar/test_sensor.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 29ba14ac463..2ad86186652 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -5,7 +5,12 @@ from datetime import datetime from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_NAME +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -56,6 +61,7 @@ class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): ATTR_IDENTIFIERS: {(DOMAIN, entry_id)}, ATTR_NAME: "Solar Production Forecast", ATTR_MANUFACTURER: "Forecast.Solar", + ATTR_MODEL: coordinator.data.account_type.value, ATTR_ENTRY_TYPE: ENTRY_TYPE_SERVICE, } diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 8b9227a8d04..0bf080535f6 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -61,6 +61,7 @@ def mock_forecast_solar() -> Generator[None, MagicMock, None]: estimate = MagicMock(spec=models.Estimate) estimate.now.return_value = now estimate.timezone = "Europe/Amsterdam" + estimate.account_type.value = "public" estimate.energy_production_today = 100000 estimate.energy_production_tomorrow = 200000 estimate.power_production_now = 300000 diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index a2b105ccbd1..6c910d699c4 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -142,7 +142,7 @@ async def test_sensors( assert device_entry.manufacturer == "Forecast.Solar" assert device_entry.name == "Solar Production Forecast" assert device_entry.entry_type == ENTRY_TYPE_SERVICE - assert not device_entry.model + assert device_entry.model == "public" assert not device_entry.sw_version From bf1112bc10367a0d12a1428e6f71223817898c0d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 11:19:28 +0200 Subject: [PATCH 762/903] Remove temperature conversion - temper (#55188) --- homeassistant/components/temper/sensor.py | 36 ++++++----------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/temper/sensor.py b/homeassistant/components/temper/sensor.py index ffb5660109c..bb9da812245 100644 --- a/homeassistant/components/temper/sensor.py +++ b/homeassistant/components/temper/sensor.py @@ -10,7 +10,7 @@ from homeassistant.const import ( CONF_OFFSET, DEVICE_CLASS_TEMPERATURE, DEVICE_DEFAULT_NAME, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) _LOGGER = logging.getLogger(__name__) @@ -35,7 +35,6 @@ def get_temper_devices(): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Temper sensors.""" - temp_unit = hass.config.units.temperature_unit name = config.get(CONF_NAME) scaling = {"scale": config.get(CONF_SCALE), "offset": config.get(CONF_OFFSET)} temper_devices = get_temper_devices() @@ -43,7 +42,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for idx, dev in enumerate(temper_devices): if idx != 0: name = f"{name}_{idx!s}" - TEMPER_SENSORS.append(TemperSensor(dev, temp_unit, name, scaling)) + TEMPER_SENSORS.append(TemperSensor(dev, name, scaling)) add_entities(TEMPER_SENSORS) @@ -61,30 +60,16 @@ def reset_devices(): class TemperSensor(SensorEntity): """Representation of a Temper temperature sensor.""" - def __init__(self, temper_device, temp_unit, name, scaling): + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_native_unit_of_measurement = TEMP_CELSIUS + + def __init__(self, temper_device, name, scaling): """Initialize the sensor.""" - self.temp_unit = temp_unit self.scale = scaling["scale"] self.offset = scaling["offset"] - self.current_value = None - self._name = name self.set_temper_device(temper_device) - self._attr_device_class = DEVICE_CLASS_TEMPERATURE - @property - def name(self): - """Return the name of the temperature sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the entity.""" - return self.current_value - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self.temp_unit + self._attr_name = name def set_temper_device(self, temper_device): """Assign the underlying device for this sensor.""" @@ -96,11 +81,8 @@ class TemperSensor(SensorEntity): def update(self): """Retrieve latest state.""" try: - format_str = ( - "fahrenheit" if self.temp_unit == TEMP_FAHRENHEIT else "celsius" - ) - sensor_value = self.temper_device.get_temperature(format_str) - self.current_value = round(sensor_value, 1) + sensor_value = self.temper_device.get_temperature("celsius") + self._attr_native_value = round(sensor_value, 1) except OSError: _LOGGER.error( "Failed to get temperature. The device address may" From 186b8d4f4b6d5368e961358cedba202e00e4473a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 25 Aug 2021 02:43:08 -0700 Subject: [PATCH 763/903] Fix rainforest eagle incorrectly fetch conncted first try (#55193) --- .../components/rainforest_eagle/data.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index f76b809f2a9..76ddb2d25d7 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -136,23 +136,28 @@ class EagleDataCoordinator(DataUpdateCoordinator): async def _async_update_data_200(self): """Get the latest data from the Eagle-200 device.""" - if self.eagle200_meter is None: + eagle200_meter = self.eagle200_meter + + if eagle200_meter is None: 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.eagle200_meter = aioeagle.ElectricMeter.create_instance( + eagle200_meter = aioeagle.ElectricMeter.create_instance( hub, self.hardware_address ) - - is_connected = self.eagle200_meter.is_connected + is_connected = True + else: + is_connected = eagle200_meter.is_connected async with async_timeout.timeout(30): - data = await self.eagle200_meter.get_device_query() + data = await eagle200_meter.get_device_query() - if is_connected and not self.eagle200_meter.is_connected: + if self.eagle200_meter is None: + self.eagle200_meter = eagle200_meter + elif is_connected and not eagle200_meter.is_connected: _LOGGER.warning("Lost connection with electricity meter") _LOGGER.debug("API data: %s", data) From 51361fbd2b03c522c2449169dcbaea5b94319079 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 25 Aug 2021 11:50:54 +0200 Subject: [PATCH 764/903] Add configurable `state_class` to Modbus sensors (#54103) * add configurable state_class * Add test of new parameter. Co-authored-by: jan Iversen --- homeassistant/components/modbus/__init__.py | 3 +++ homeassistant/components/modbus/const.py | 1 + homeassistant/components/modbus/sensor.py | 2 ++ tests/components/modbus/test_sensor.py | 7 ++++++- 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 0ff3b67c79f..7d598a5464a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -13,6 +13,7 @@ from homeassistant.components.cover import ( ) from homeassistant.components.sensor import ( DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, + STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, ) from homeassistant.components.switch import ( DEVICE_CLASSES_SCHEMA as SWITCH_DEVICE_CLASSES_SCHEMA, @@ -75,6 +76,7 @@ from .const import ( CONF_RETRY_ON_EMPTY, CONF_REVERSE_ORDER, CONF_SCALE, + CONF_STATE_CLASS, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OFF, @@ -269,6 +271,7 @@ SENSOR_SCHEMA = vol.All( BASE_STRUCT_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_REVERSE_ORDER): cv.boolean, } diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 3bcd85053d2..b259b93285f 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -41,6 +41,7 @@ CONF_RETRY_ON_EMPTY = "retry_on_empty" CONF_REVERSE_ORDER = "reverse_order" CONF_PRECISION = "precision" CONF_SCALE = "scale" +CONF_STATE_CLASS = "state_class" CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OFF = "state_off" diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 2041f8974da..c2f69065196 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -12,6 +12,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .base_platform import BaseStructPlatform +from .const import CONF_STATE_CLASS from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -48,6 +49,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): """Initialize the modbus register sensor.""" super().__init__(hub, entry) self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_state_class = entry.get(CONF_STATE_CLASS) async def async_added_to_hass(self): """Handle entity which will be added.""" diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index a52f833be1c..0da9d86f262 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.modbus.const import ( CONF_LAZY_ERROR, CONF_PRECISION, CONF_SCALE, + CONF_STATE_CLASS, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_NONE, @@ -20,7 +21,10 @@ from homeassistant.components.modbus.const import ( DATA_TYPE_STRING, DATA_TYPE_UINT, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, +) from homeassistant.const import ( CONF_ADDRESS, CONF_COUNT, @@ -62,6 +66,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" CONF_PRECISION: 0, CONF_SCALE: 1, CONF_OFFSET: 0, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", From ebe48e78b7991f20b91d478853456fa4f4bd82eb Mon Sep 17 00:00:00 2001 From: Yuval Aboulafia Date: Wed, 25 Aug 2021 13:05:58 +0300 Subject: [PATCH 765/903] Refactor Jewish Calendar to use EntityDescription (#54852) --- .../components/jewish_calendar/__init__.py | 33 ---- .../jewish_calendar/binary_sensor.py | 49 +++-- .../components/jewish_calendar/sensor.py | 172 +++++++++++++++--- 3 files changed, 173 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 35c1505561d..bb7cc9d2841 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -10,39 +10,6 @@ from homeassistant.helpers.discovery import async_load_platform DOMAIN = "jewish_calendar" -SENSOR_TYPES = { - "binary": { - "issur_melacha_in_effect": ["Issur Melacha in Effect", "mdi:power-plug-off"] - }, - "data": { - "date": ["Date", "mdi:judaism"], - "weekly_portion": ["Parshat Hashavua", "mdi:book-open-variant"], - "holiday": ["Holiday", "mdi:calendar-star"], - "omer_count": ["Day of the Omer", "mdi:counter"], - "daf_yomi": ["Daf Yomi", "mdi:book-open-variant"], - }, - "time": { - "first_light": ["Alot Hashachar", "mdi:weather-sunset-up"], - "talit": ["Talit and Tefillin", "mdi:calendar-clock"], - "gra_end_shma": ['Latest time for Shma Gr"a', "mdi:calendar-clock"], - "mga_end_shma": ['Latest time for Shma MG"A', "mdi:calendar-clock"], - "gra_end_tfila": ['Latest time for Tefilla Gr"a', "mdi:calendar-clock"], - "mga_end_tfila": ['Latest time for Tefilla MG"A', "mdi:calendar-clock"], - "big_mincha": ["Mincha Gedola", "mdi:calendar-clock"], - "small_mincha": ["Mincha Ketana", "mdi:calendar-clock"], - "plag_mincha": ["Plag Hamincha", "mdi:weather-sunset-down"], - "sunset": ["Shkia", "mdi:weather-sunset"], - "first_stars": ["T'set Hakochavim", "mdi:weather-night"], - "upcoming_shabbat_candle_lighting": [ - "Upcoming Shabbat Candle Lighting", - "mdi:candle", - ], - "upcoming_shabbat_havdalah": ["Upcoming Shabbat Havdalah", "mdi:weather-night"], - "upcoming_candle_lighting": ["Upcoming Candle Lighting", "mdi:candle"], - "upcoming_havdalah": ["Upcoming Havdalah", "mdi:weather-night"], - }, -} - CONF_DIASPORA = "diaspora" CONF_LANGUAGE = "language" CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 954b22debd0..c4e9b1e347f 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -1,40 +1,51 @@ """Support for Jewish Calendar binary sensors.""" +from __future__ import annotations + import datetime as dt import hdate -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.core import callback +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import event +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import DOMAIN, SENSOR_TYPES +from . import DOMAIN + +BINARY_SENSORS = BinarySensorEntityDescription( + key="issur_melacha_in_effect", + name="Issur Melacha in Effect", + icon="mdi:power-plug-off", +) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +): """Set up the Jewish Calendar binary sensor devices.""" if discovery_info is None: return - async_add_entities( - [ - JewishCalendarBinarySensor(hass.data[DOMAIN], sensor, sensor_info) - for sensor, sensor_info in SENSOR_TYPES["binary"].items() - ] - ) + async_add_entities([JewishCalendarBinarySensor(hass.data[DOMAIN], BINARY_SENSORS)]) class JewishCalendarBinarySensor(BinarySensorEntity): """Representation of an Jewish Calendar binary sensor.""" - def __init__(self, data, sensor, sensor_info): + _attr_should_poll = False + + def __init__(self, data, description: BinarySensorEntityDescription) -> None: """Initialize the binary sensor.""" - self._type = sensor - self._prefix = data["prefix"] - self._attr_name = f"{data['name']} {sensor_info[0]}" - self._attr_unique_id = f"{self._prefix}_{self._type}" - self._attr_icon = sensor_info[1] - self._attr_should_poll = False + self._attr_name = f"{data['name']} {description.name}" + self._attr_unique_id = f"{data['prefix']}_{description.key}" self._location = data["location"] self._hebrew = data["language"] == "hebrew" self._candle_lighting_offset = data["candle_lighting_offset"] @@ -42,7 +53,7 @@ class JewishCalendarBinarySensor(BinarySensorEntity): self._update_unsub = None @property - def is_on(self): + def is_on(self) -> bool: """Return true if sensor is on.""" return self._get_zmanim().issur_melacha_in_effect @@ -56,7 +67,7 @@ class JewishCalendarBinarySensor(BinarySensorEntity): hebrew=self._hebrew, ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() self._schedule_update() diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index e3f51ea5e2c..4e90dd00058 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -1,31 +1,147 @@ """Platform to retrieve Jewish calendar information for Home Assistant.""" +from __future__ import annotations + from datetime import datetime import logging import hdate -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import DEVICE_CLASS_TIMESTAMP, SUN_EVENT_SUNSET +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import get_astral_event_date +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import DOMAIN, SENSOR_TYPES +from . import DOMAIN _LOGGER = logging.getLogger(__name__) +DATA_SENSORS = ( + SensorEntityDescription( + key="date", + name="Date", + icon="mdi:judaism", + ), + SensorEntityDescription( + key="weekly_portion", + name="Parshat Hashavua", + icon="mdi:book-open-variant", + ), + SensorEntityDescription( + key="holiday", + name="Holiday", + icon="mdi:calendar-star", + ), + SensorEntityDescription( + key="omer_count", + name="Day of the Omer", + icon="mdi:counter", + ), + SensorEntityDescription( + key="daf_yomi", + name="Daf Yomi", + icon="mdi:book-open-variant", + ), +) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +TIME_SENSORS = ( + SensorEntityDescription( + key="first_light", + name="Alot Hashachar", + icon="mdi:weather-sunset-up", + ), + SensorEntityDescription( + key="talit", + name="Talit and Tefillin", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="gra_end_shma", + name='Latest time for Shma Gr"a', + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="mga_end_shma", + name='Latest time for Shma MG"A', + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="gra_end_tfila", + name='Latest time for Tefilla Gr"a', + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="mga_end_tfila", + name='Latest time for Tefilla MG"A', + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="big_mincha", + name="Mincha Gedola", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="small_mincha", + name="Mincha Ketana", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="plag_mincha", + name="Plag Hamincha", + icon="mdi:weather-sunset-down", + ), + SensorEntityDescription( + key="sunset", + name="Shkia", + icon="mdi:weather-sunset", + ), + SensorEntityDescription( + key="first_stars", + name="T'set Hakochavim", + icon="mdi:weather-night", + ), + SensorEntityDescription( + key="upcoming_shabbat_candle_lighting", + name="Upcoming Shabbat Candle Lighting", + icon="mdi:candle", + ), + SensorEntityDescription( + key="upcoming_shabbat_havdalah", + name="Upcoming Shabbat Havdalah", + icon="mdi:weather-night", + ), + SensorEntityDescription( + key="upcoming_candle_lighting", + name="Upcoming Candle Lighting", + icon="mdi:candle", + ), + SensorEntityDescription( + key="upcoming_havdalah", + name="Upcoming Havdalah", + icon="mdi:weather-night", + ), +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +): """Set up the Jewish calendar sensor platform.""" if discovery_info is None: return sensors = [ - JewishCalendarSensor(hass.data[DOMAIN], sensor, sensor_info) - for sensor, sensor_info in SENSOR_TYPES["data"].items() + JewishCalendarSensor(hass.data[DOMAIN], description) + for description in DATA_SENSORS ] sensors.extend( - JewishCalendarTimeSensor(hass.data[DOMAIN], sensor, sensor_info) - for sensor, sensor_info in SENSOR_TYPES["time"].items() + JewishCalendarTimeSensor(hass.data[DOMAIN], description) + for description in TIME_SENSORS ) async_add_entities(sensors) @@ -34,20 +150,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class JewishCalendarSensor(SensorEntity): """Representation of an Jewish calendar sensor.""" - def __init__(self, data, sensor, sensor_info): + def __init__(self, data, description: SensorEntityDescription) -> None: """Initialize the Jewish calendar sensor.""" - self._type = sensor - self._prefix = data["prefix"] - self._attr_name = f"{data['name']} {sensor_info[0]}" - self._attr_unique_id = f"{self._prefix}_{self._type}" - self._attr_icon = sensor_info[1] + self.entity_description = description + self._attr_name = f"{data['name']} {description.name}" + self._attr_unique_id = f"{data['prefix']}_{description.key}" self._location = data["location"] self._hebrew = data["language"] == "hebrew" self._candle_lighting_offset = data["candle_lighting_offset"] self._havdalah_offset = data["havdalah_offset"] self._diaspora = data["diaspora"] self._state = None - self._holiday_attrs = {} + self._holiday_attrs: dict[str, str] = {} @property def native_value(self): @@ -87,7 +201,7 @@ class JewishCalendarSensor(SensorEntity): after_tzais_date = daytime_date.next_day self._state = self.get_state(daytime_date, after_shkia_date, after_tzais_date) - _LOGGER.debug("New value for %s: %s", self._type, self._state) + _LOGGER.debug("New value for %s: %s", self.entity_description.key, self._state) def make_zmanim(self, date): """Create a Zmanim object.""" @@ -100,9 +214,9 @@ class JewishCalendarSensor(SensorEntity): ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - if self._type != "holiday": + if self.entity_description.key != "holiday": return {} return self._holiday_attrs @@ -110,19 +224,19 @@ class JewishCalendarSensor(SensorEntity): """For a given type of sensor, return the state.""" # Terminology note: by convention in py-libhdate library, "upcoming" # refers to "current" or "upcoming" dates. - if self._type == "date": + if self.entity_description.key == "date": return after_shkia_date.hebrew_date - if self._type == "weekly_portion": + if self.entity_description.key == "weekly_portion": # Compute the weekly portion based on the upcoming shabbat. return after_tzais_date.upcoming_shabbat.parasha - if self._type == "holiday": + if self.entity_description.key == "holiday": self._holiday_attrs["id"] = after_shkia_date.holiday_name self._holiday_attrs["type"] = after_shkia_date.holiday_type.name self._holiday_attrs["type_id"] = after_shkia_date.holiday_type.value return after_shkia_date.holiday_description - if self._type == "omer_count": + if self.entity_description.key == "omer_count": return after_shkia_date.omer_day - if self._type == "daf_yomi": + if self.entity_description.key == "daf_yomi": return daytime_date.daf_yomi return None @@ -141,9 +255,9 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): return dt_util.as_utc(self._state).isoformat() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - attrs = {} + attrs: dict[str, str] = {} if self._state is None: return attrs @@ -152,24 +266,24 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): def get_state(self, daytime_date, after_shkia_date, after_tzais_date): """For a given type of sensor, return the state.""" - if self._type == "upcoming_shabbat_candle_lighting": + if self.entity_description.key == "upcoming_shabbat_candle_lighting": times = self.make_zmanim( after_tzais_date.upcoming_shabbat.previous_day.gdate ) return times.candle_lighting - if self._type == "upcoming_candle_lighting": + if self.entity_description.key == "upcoming_candle_lighting": times = self.make_zmanim( after_tzais_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate ) return times.candle_lighting - if self._type == "upcoming_shabbat_havdalah": + if self.entity_description.key == "upcoming_shabbat_havdalah": times = self.make_zmanim(after_tzais_date.upcoming_shabbat.gdate) return times.havdalah - if self._type == "upcoming_havdalah": + if self.entity_description.key == "upcoming_havdalah": times = self.make_zmanim( after_tzais_date.upcoming_shabbat_or_yom_tov.last_day.gdate ) return times.havdalah times = self.make_zmanim(dt_util.now()).zmanim - return times[self._type] + return times[self.entity_description.key] From 3432efddaaf846e8df6f37f5e29ce0d56a0c9c29 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Aug 2021 12:23:42 +0200 Subject: [PATCH 766/903] Remember state of MQTT availability topics when reconfiguring (#55199) --- homeassistant/components/mqtt/mixins.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index a40f06a3bb6..11bf70ceceb 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -330,7 +330,10 @@ class MqttAvailability(Entity): self.async_write_ha_state() - self._available = {topic: False for topic in self._avail_topics} + self._available = { + topic: (self._available[topic] if topic in self._available else False) + for topic in self._avail_topics + } topics = { f"availability_{topic}": { "topic": topic, From a23f4dac6207d6872a6584fb3ef879b7fd72c493 Mon Sep 17 00:00:00 2001 From: Meow Date: Wed, 25 Aug 2021 12:26:37 +0200 Subject: [PATCH 767/903] Add service to clear completed shoppinglist items (#55032) --- .../components/shopping_list/__init__.py | 25 +++++-- .../components/shopping_list/const.py | 7 ++ .../components/shopping_list/services.yaml | 4 + tests/components/shopping_list/test_init.py | 75 ++++++++++++++++++- tests/components/shopping_list/test_intent.py | 9 +++ 5 files changed, 112 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 841865cd759..49b4d8a5d91 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -12,7 +12,15 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json -from .const import DOMAIN +from .const import ( + DOMAIN, + SERVICE_ADD_ITEM, + SERVICE_CLEAR_COMPLETED_ITEMS, + SERVICE_COMPLETE_ALL, + SERVICE_COMPLETE_ITEM, + SERVICE_INCOMPLETE_ALL, + SERVICE_INCOMPLETE_ITEM, +) ATTR_COMPLETE = "complete" @@ -22,11 +30,6 @@ EVENT = "shopping_list_updated" ITEM_UPDATE_SCHEMA = vol.Schema({ATTR_COMPLETE: bool, ATTR_NAME: str}) PERSISTENCE = ".shopping_list.json" -SERVICE_ADD_ITEM = "add_item" -SERVICE_COMPLETE_ITEM = "complete_item" -SERVICE_INCOMPLETE_ITEM = "incomplete_item" -SERVICE_COMPLETE_ALL = "complete_all" -SERVICE_INCOMPLETE_ALL = "incomplete_all" SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): vol.Any(None, cv.string)}) SERVICE_LIST_SCHEMA = vol.Schema({}) @@ -116,6 +119,10 @@ async def async_setup_entry(hass, config_entry): """Mark all items in the list as incomplete.""" await data.async_update_list({"complete": False}) + async def clear_completed_items_service(call): + """Clear all completed items from the list.""" + await data.async_clear_completed() + data = hass.data[DOMAIN] = ShoppingData(hass) await data.async_load() @@ -143,6 +150,12 @@ async def async_setup_entry(hass, config_entry): incomplete_all_service, schema=SERVICE_LIST_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_COMPLETED_ITEMS, + clear_completed_items_service, + schema=SERVICE_LIST_SCHEMA, + ) hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) diff --git a/homeassistant/components/shopping_list/const.py b/homeassistant/components/shopping_list/const.py index 4878d317780..2969fc8f86d 100644 --- a/homeassistant/components/shopping_list/const.py +++ b/homeassistant/components/shopping_list/const.py @@ -1,2 +1,9 @@ """All constants related to the shopping list component.""" DOMAIN = "shopping_list" + +SERVICE_ADD_ITEM = "add_item" +SERVICE_COMPLETE_ITEM = "complete_item" +SERVICE_INCOMPLETE_ITEM = "incomplete_item" +SERVICE_COMPLETE_ALL = "complete_all" +SERVICE_INCOMPLETE_ALL = "incomplete_all" +SERVICE_CLEAR_COMPLETED_ITEMS = "clear_completed_items" diff --git a/homeassistant/components/shopping_list/services.yaml b/homeassistant/components/shopping_list/services.yaml index 7bf209550d7..0af388cfcb1 100644 --- a/homeassistant/components/shopping_list/services.yaml +++ b/homeassistant/components/shopping_list/services.yaml @@ -40,3 +40,7 @@ complete_all: incomplete_all: name: Incomplete all description: Marks all items as incomplete in the shopping list. + +clear_completed_items: + name: Clear completed items + description: Clear completed items from the shopping list. diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index 48482787f4d..65fddec894e 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -1,12 +1,17 @@ """Test shopping list component.""" -from homeassistant.components.shopping_list.const import DOMAIN +from homeassistant.components.shopping_list.const import ( + DOMAIN, + SERVICE_ADD_ITEM, + SERVICE_CLEAR_COMPLETED_ITEMS, + SERVICE_COMPLETE_ITEM, +) from homeassistant.components.websocket_api.const import ( ERR_INVALID_FORMAT, ERR_NOT_FOUND, TYPE_RESULT, ) -from homeassistant.const import HTTP_NOT_FOUND +from homeassistant.const import ATTR_NAME, HTTP_NOT_FOUND from homeassistant.helpers import intent @@ -53,6 +58,29 @@ async def test_update_list(hass, sl_setup): assert cheese["complete"] is False +async def test_clear_completed_items(hass, sl_setup): + """Test clear completed list items.""" + await intent.async_handle( + hass, + "test", + "HassShoppingListAddItem", + {"item": {"value": "beer"}}, + ) + + await intent.async_handle( + hass, "test", "HassShoppingListAddItem", {"item": {"value": "cheese"}} + ) + + assert len(hass.data[DOMAIN].items) == 2 + + # Update a single attribute, other attributes shouldn't change + await hass.data[DOMAIN].async_update_list({"complete": True}) + + await hass.data[DOMAIN].async_clear_completed() + + assert len(hass.data[DOMAIN].items) == 0 + + async def test_recent_items_intent(hass, sl_setup): """Test recent items.""" @@ -471,3 +499,46 @@ async def test_ws_reorder_items_failure(hass, hass_ws_client, sl_setup): msg = await client.receive_json() assert msg["success"] is False assert msg["error"]["code"] == ERR_INVALID_FORMAT + + +async def test_add_item_service(hass, sl_setup): + """Test adding shopping_list item service.""" + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_ITEM, + {ATTR_NAME: "beer"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(hass.data[DOMAIN].items) == 1 + + +async def test_clear_completed_items_service(hass, sl_setup): + """Test clearing completed shopping_list items service.""" + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_ITEM, + {ATTR_NAME: "beer"}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(hass.data[DOMAIN].items) == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_COMPLETE_ITEM, + {ATTR_NAME: "beer"}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(hass.data[DOMAIN].items) == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_COMPLETED_ITEMS, + {}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(hass.data[DOMAIN].items) == 0 diff --git a/tests/components/shopping_list/test_intent.py b/tests/components/shopping_list/test_intent.py index d0bcb1d837c..a03353e510e 100644 --- a/tests/components/shopping_list/test_intent.py +++ b/tests/components/shopping_list/test_intent.py @@ -20,3 +20,12 @@ async def test_recent_items_intent(hass, sl_setup): response.speech["plain"]["speech"] == "These are the top 3 items on your shopping list: soda, wine, beer" ) + + +async def test_recent_items_intent_no_items(hass, sl_setup): + """Test recent items.""" + response = await intent.async_handle(hass, "test", "HassShoppingListLastItems") + + assert ( + response.speech["plain"]["speech"] == "There are no items on your shopping list" + ) From 7df8d0c9734f364a3f6a1da32394336e118cd07d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 25 Aug 2021 12:29:00 +0200 Subject: [PATCH 768/903] Check for duplicate host/port and integration name in modbus (#54664) * Check for duplicate host/port and integration name. * Change to use set(). * Please CI. * Add basic tests. --- homeassistant/components/modbus/__init__.py | 2 + homeassistant/components/modbus/validators.py | 31 ++++++- tests/components/modbus/test_init.py | 88 +++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 7d598a5464a..26d196f8af9 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -121,6 +121,7 @@ from .const import ( from .modbus import ModbusHub, async_modbus_setup from .validators import ( duplicate_entity_validator, + duplicate_modbus_validator, number_validator, scan_interval_validator, struct_validator, @@ -338,6 +339,7 @@ CONFIG_SCHEMA = vol.Schema( cv.ensure_list, scan_interval_validator, duplicate_entity_validator, + duplicate_modbus_validator, [ vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA), ], diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 543618e11fd..fdfffaebd61 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -11,11 +11,14 @@ import voluptuous as vol from homeassistant.const import ( CONF_ADDRESS, CONF_COUNT, + CONF_HOST, CONF_NAME, + CONF_PORT, CONF_SCAN_INTERVAL, CONF_SLAVE, CONF_STRUCTURE, CONF_TIMEOUT, + CONF_TYPE, ) from .const import ( @@ -37,8 +40,10 @@ from .const import ( DATA_TYPE_UINT16, DATA_TYPE_UINT32, DATA_TYPE_UINT64, + DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, PLATFORMS, + SERIAL, ) _LOGGER = logging.getLogger(__name__) @@ -221,5 +226,29 @@ def duplicate_entity_validator(config: dict) -> dict: for i in reversed(errors): del config[hub_index][conf_key][i] - + return config + + +def duplicate_modbus_validator(config: list) -> list: + """Control modbus connection for duplicates.""" + hosts: set[str] = set() + names: set[str] = set() + errors = [] + for index, hub in enumerate(config): + name = hub.get(CONF_NAME, DEFAULT_HUB) + host = hub[CONF_PORT] if hub[CONF_TYPE] == SERIAL else hub[CONF_HOST] + if host in hosts: + err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!" + _LOGGER.warning(err) + errors.append(index) + elif name in names: + err = f"Modbus {name}  is duplicate, second entry not loaded!" + _LOGGER.warning(err) + errors.append(index) + else: + hosts.add(host) + names.add(name) + + for i in reversed(errors): + del config[i] return config diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 3eb1beb460f..b24115ee964 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -59,6 +59,8 @@ from homeassistant.components.modbus.const import ( UDP, ) from homeassistant.components.modbus.validators import ( + duplicate_entity_validator, + duplicate_modbus_validator, number_validator, struct_validator, ) @@ -202,6 +204,92 @@ async def test_exception_struct_validator(do_config): pytest.fail("struct_validator missing exception") +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + }, + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST + "2", + CONF_PORT: TEST_PORT_TCP, + }, + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + }, + { + CONF_NAME: TEST_MODBUS_NAME + "2", + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + }, + ], + ], +) +async def test_duplicate_modbus_validator(do_config): + """Test duplicate modbus validator.""" + duplicate_modbus_validator(do_config) + assert len(do_config) == 1 + + +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 119, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + }, + { + CONF_NAME: TEST_ENTITY_NAME + "2", + CONF_ADDRESS: 117, + }, + ], + } + ], + ], +) +async def test_duplicate_entity_validator(do_config): + """Test duplicate entity validator.""" + duplicate_entity_validator(do_config) + assert len(do_config[0][CONF_SENSORS]) == 1 + + @pytest.mark.parametrize( "do_config", [ From bd407f3ff4fdb610b257b8881c3f8c827179f1e2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 12:59:31 +0200 Subject: [PATCH 769/903] Fix name - temper (#55189) --- homeassistant/components/temper/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/temper/sensor.py b/homeassistant/components/temper/sensor.py index bb9da812245..0274043c089 100644 --- a/homeassistant/components/temper/sensor.py +++ b/homeassistant/components/temper/sensor.py @@ -35,13 +35,13 @@ def get_temper_devices(): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Temper sensors.""" - name = config.get(CONF_NAME) + prefix = name = config[CONF_NAME] scaling = {"scale": config.get(CONF_SCALE), "offset": config.get(CONF_OFFSET)} temper_devices = get_temper_devices() for idx, dev in enumerate(temper_devices): if idx != 0: - name = f"{name}_{idx!s}" + name = f"{prefix}_{idx!s}" TEMPER_SENSORS.append(TemperSensor(dev, name, scaling)) add_entities(TEMPER_SENSORS) From 0d654fa6b379cdb8b0b595301fd316e66d8d121b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B8rge=20Nordli?= Date: Wed, 25 Aug 2021 13:00:11 +0200 Subject: [PATCH 770/903] Extract attribute names out of vol.Optional when validating entity service schema (#55157) --- homeassistant/helpers/config_validation.py | 10 ++++--- tests/helpers/test_config_validation.py | 32 ++++++++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index de9fa2a4169..3a2fb6c70e4 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -120,7 +120,7 @@ def path(value: Any) -> str: # Adapted from: # https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666 -def has_at_least_one_key(*keys: str) -> Callable: +def has_at_least_one_key(*keys: Any) -> Callable[[dict], dict]: """Validate that at least one key exists.""" def validate(obj: dict) -> dict: @@ -131,12 +131,13 @@ def has_at_least_one_key(*keys: str) -> Callable: for k in obj: if k in keys: return obj - raise vol.Invalid("must contain at least one of {}.".format(", ".join(keys))) + expected = ", ".join(str(k) for k in keys) + raise vol.Invalid(f"must contain at least one of {expected}.") return validate -def has_at_most_one_key(*keys: str) -> Callable[[dict], dict]: +def has_at_most_one_key(*keys: Any) -> Callable[[dict], dict]: """Validate that zero keys exist or one key exists.""" def validate(obj: dict) -> dict: @@ -145,7 +146,8 @@ def has_at_most_one_key(*keys: str) -> Callable[[dict], dict]: raise vol.Invalid("expected dictionary") if len(set(keys) & set(obj)) > 1: - raise vol.Invalid("must contain at most one of {}.".format(", ".join(keys))) + expected = ", ".join(str(k) for k in keys) + raise vol.Invalid(f"must contain at most one of {expected}.") return obj return validate diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 79b558e5083..cf832dfde50 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -388,6 +388,34 @@ def test_service_schema(): cv.SERVICE_SCHEMA(value) +def test_entity_service_schema(): + """Test make_entity_service_schema validation.""" + schema = cv.make_entity_service_schema( + {vol.Required("required"): cv.positive_int, vol.Optional("optional"): cv.string} + ) + + options = ( + {}, + None, + {"entity_id": "light.kitchen"}, + {"optional": "value", "entity_id": "light.kitchen"}, + {"required": 1}, + {"required": 2, "area_id": "kitchen", "foo": "bar"}, + {"required": "str", "area_id": "kitchen"}, + ) + for value in options: + with pytest.raises(vol.MultipleInvalid): + cv.SERVICE_SCHEMA(value) + + options = ( + {"required": 1, "entity_id": "light.kitchen"}, + {"required": 2, "optional": "value", "device_id": "a_device"}, + {"required": 3, "area_id": "kitchen"}, + ) + for value in options: + schema(value) + + def test_slug(): """Test slug validation.""" schema = vol.Schema(cv.slug) @@ -912,7 +940,7 @@ def test_has_at_most_one_key(): with pytest.raises(vol.MultipleInvalid): schema(value) - for value in ({}, {"beer": None}, {"soda": None}): + for value in ({}, {"beer": None}, {"soda": None}, {vol.Optional("soda"): None}): schema(value) @@ -924,7 +952,7 @@ def test_has_at_least_one_key(): with pytest.raises(vol.MultipleInvalid): schema(value) - for value in ({"beer": None}, {"soda": None}): + for value in ({"beer": None}, {"soda": None}, {vol.Required("soda"): None}): schema(value) From ffbd2d79c8e03f4a7fee2c65a28181a0158127ea Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Aug 2021 13:00:35 +0200 Subject: [PATCH 771/903] Generate statistics for all sensors with a supported state_class (#54882) * Generate statistics for all sensors * Fix bugs, add tests * Address review comments * Cleanup warnings * Simplify tests * Simplify selection of statistics * Fix tests --- .../components/recorder/statistics.py | 13 ++ homeassistant/components/sensor/recorder.py | 127 +++++++----- tests/components/sensor/test_recorder.py | 194 ++++++++++++++++-- 3 files changed, 272 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 34112fcc059..06f7851b1a6 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -227,6 +227,19 @@ def _get_metadata( return metadata +def get_metadata( + hass: HomeAssistant, + statistic_id: str, +) -> dict[str, str] | None: + """Return metadata for a statistic_id.""" + statistic_ids = [statistic_id] + with session_scope(hass=hass) as session: + metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) + if not metadata_ids: + return None + return _get_metadata(hass, session, statistic_ids, None).get(metadata_ids[0]) + + def _configured_unit(unit: str, units: UnitSystem) -> str: """Return the pressure and temperature units configured by the user.""" if unit == PRESSURE_PA: diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 6dc91b52c9a..77f91752327 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -9,10 +9,8 @@ from typing import Callable from homeassistant.components.recorder import history, statistics from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, DEVICE_CLASS_GAS, - DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_MONETARY, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, @@ -26,7 +24,6 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, - PERCENTAGE, POWER_KILO_WATT, POWER_WATT, PRESSURE_BAR, @@ -51,24 +48,18 @@ from . import ATTR_LAST_RESET, DOMAIN _LOGGER = logging.getLogger(__name__) -DEVICE_CLASS_OR_UNIT_STATISTICS = { +DEVICE_CLASS_STATISTICS: dict[str, dict[str, set[str]]] = { STATE_CLASS_MEASUREMENT: { - DEVICE_CLASS_BATTERY: {"mean", "min", "max"}, - DEVICE_CLASS_HUMIDITY: {"mean", "min", "max"}, - DEVICE_CLASS_POWER: {"mean", "min", "max"}, - DEVICE_CLASS_PRESSURE: {"mean", "min", "max"}, - DEVICE_CLASS_TEMPERATURE: {"mean", "min", "max"}, - PERCENTAGE: {"mean", "min", "max"}, # Deprecated, support will be removed in Home Assistant 2021.11 DEVICE_CLASS_ENERGY: {"sum"}, DEVICE_CLASS_GAS: {"sum"}, DEVICE_CLASS_MONETARY: {"sum"}, }, - STATE_CLASS_TOTAL_INCREASING: { - DEVICE_CLASS_ENERGY: {"sum"}, - DEVICE_CLASS_GAS: {"sum"}, - DEVICE_CLASS_MONETARY: {"sum"}, - }, + STATE_CLASS_TOTAL_INCREASING: {}, +} +DEFAULT_STATISTICS = { + STATE_CLASS_MEASUREMENT: {"mean", "min", "max"}, + STATE_CLASS_TOTAL_INCREASING: {"sum"}, } # Normalized units which will be stored in the statistics table @@ -116,31 +107,20 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { } # Keep track of entities for which a warning about unsupported unit has been logged -WARN_UNSUPPORTED_UNIT = set() +WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" +WARN_UNSTABLE_UNIT = "sensor_warn_unstable_unit" -def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str]]: - """Get (entity_id, state_class, key) of all sensors for which to compile statistics. - - Key is either a device class or a unit and is used to index the - DEVICE_CLASS_OR_UNIT_STATISTICS map. - """ +def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str | None]]: + """Get (entity_id, state_class, device_class) of all sensors for which to compile statistics.""" all_sensors = hass.states.all(DOMAIN) entity_ids = [] for state in all_sensors: if (state_class := state.attributes.get(ATTR_STATE_CLASS)) not in STATE_CLASSES: continue - - if ( - key := state.attributes.get(ATTR_DEVICE_CLASS) - ) in DEVICE_CLASS_OR_UNIT_STATISTICS[state_class]: - entity_ids.append((state.entity_id, state_class, key)) - - if ( - key := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - ) in DEVICE_CLASS_OR_UNIT_STATISTICS[state_class]: - entity_ids.append((state.entity_id, state_class, key)) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + entity_ids.append((state.entity_id, state_class, device_class)) return entity_ids @@ -190,18 +170,39 @@ def _time_weighted_average( return accumulated / (end - start).total_seconds() +def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: + """Return True if all states have the same unit.""" + return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} + + def _normalize_states( - entity_history: list[State], key: str, entity_id: str + hass: HomeAssistant, + entity_history: list[State], + device_class: str | None, + entity_id: str, ) -> tuple[str | None, list[tuple[float, State]]]: """Normalize units.""" unit = None - if key not in UNIT_CONVERSIONS: + if device_class not in UNIT_CONVERSIONS: # We're not normalizing this device class, return the state as they are fstates = [ (float(el.state), el) for el in entity_history if _is_number(el.state) ] if fstates: + all_units = _get_units(fstates) + if len(all_units) > 1: + if WARN_UNSTABLE_UNIT not in hass.data: + hass.data[WARN_UNSTABLE_UNIT] = set() + if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: + hass.data[WARN_UNSTABLE_UNIT].add(entity_id) + _LOGGER.warning( + "The unit of %s is changing, got %s, generation of long term " + "statistics will be suppressed unless the unit is stable", + entity_id, + all_units, + ) + return None, [] unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) return unit, fstates @@ -215,15 +216,17 @@ def _normalize_states( fstate = float(state.state) unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # Exclude unsupported units from statistics - if unit not in UNIT_CONVERSIONS[key]: - if entity_id not in WARN_UNSUPPORTED_UNIT: - WARN_UNSUPPORTED_UNIT.add(entity_id) + if unit not in UNIT_CONVERSIONS[device_class]: + if WARN_UNSUPPORTED_UNIT not in hass.data: + hass.data[WARN_UNSUPPORTED_UNIT] = set() + if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: + hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) _LOGGER.warning("%s has unknown unit %s", entity_id, unit) continue - fstates.append((UNIT_CONVERSIONS[key][unit](fstate), state)) + fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) - return DEVICE_CLASS_UNITS[key], fstates + return DEVICE_CLASS_UNITS[device_class], fstates def reset_detected(state: float, previous_state: float | None) -> bool: @@ -247,18 +250,39 @@ def compile_statistics( hass, start - datetime.timedelta.resolution, end, [i[0] for i in entities] ) - for entity_id, state_class, key in entities: - wanted_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[state_class][key] + for entity_id, state_class, device_class in entities: + if device_class in DEVICE_CLASS_STATISTICS[state_class]: + wanted_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class] + else: + wanted_statistics = DEFAULT_STATISTICS[state_class] if entity_id not in history_list: continue entity_history = history_list[entity_id] - unit, fstates = _normalize_states(entity_history, key, entity_id) + unit, fstates = _normalize_states(hass, entity_history, device_class, entity_id) if not fstates: continue + # Check metadata + if old_metadata := statistics.get_metadata(hass, entity_id): + if old_metadata["unit_of_measurement"] != unit: + if WARN_UNSTABLE_UNIT not in hass.data: + hass.data[WARN_UNSTABLE_UNIT] = set() + if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: + hass.data[WARN_UNSTABLE_UNIT].add(entity_id) + _LOGGER.warning( + "The unit of %s (%s) does not match the unit of already " + "compiled statistics (%s). Generation of long term statistics " + "will be suppressed unless the unit changes back to %s", + entity_id, + unit, + old_metadata["unit_of_measurement"], + unit, + ) + continue + result[entity_id] = {} # Set meta data @@ -370,8 +394,11 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - statistic_ids = {} - for entity_id, state_class, key in entities: - provided_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[state_class][key] + for entity_id, state_class, device_class in entities: + if device_class in DEVICE_CLASS_STATISTICS[state_class]: + provided_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class] + else: + provided_statistics = DEFAULT_STATISTICS[state_class] if statistic_type is not None and statistic_type not in provided_statistics: continue @@ -386,16 +413,20 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - ): continue - native_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + metadata = statistics.get_metadata(hass, entity_id) + if metadata: + native_unit: str | None = metadata["unit_of_measurement"] + else: + native_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if key not in UNIT_CONVERSIONS: + if device_class not in UNIT_CONVERSIONS: statistic_ids[entity_id] = native_unit continue - if native_unit not in UNIT_CONVERSIONS[key]: + if native_unit not in UNIT_CONVERSIONS[device_class]: continue - statistics_unit = DEVICE_CLASS_UNITS[key] + statistics_unit = DEVICE_CLASS_UNITS[device_class] statistic_ids[entity_id] = statistics_unit return statistic_ids diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index abeda03ed7b..5b82fcf0506 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -107,24 +107,34 @@ def test_compile_hourly_statistics( @pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES]) def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes): """Test compiling hourly statistics for unsupported sensor.""" - attributes = dict(attributes) zero = dt_util.utcnow() hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) four, states = record_states(hass, zero, "sensor.test1", attributes) - if "unit_of_measurement" in attributes: - attributes["unit_of_measurement"] = "invalid" - _, _states = record_states(hass, zero, "sensor.test2", attributes) - states = {**states, **_states} - attributes.pop("unit_of_measurement") - _, _states = record_states(hass, zero, "sensor.test3", attributes) - states = {**states, **_states} - attributes["state_class"] = "invalid" - _, _states = record_states(hass, zero, "sensor.test4", attributes) + + attributes_tmp = dict(attributes) + attributes_tmp["unit_of_measurement"] = "invalid" + _, _states = record_states(hass, zero, "sensor.test2", attributes_tmp) states = {**states, **_states} - attributes.pop("state_class") - _, _states = record_states(hass, zero, "sensor.test5", attributes) + attributes_tmp.pop("unit_of_measurement") + _, _states = record_states(hass, zero, "sensor.test3", attributes_tmp) + states = {**states, **_states} + + attributes_tmp = dict(attributes) + attributes_tmp["state_class"] = "invalid" + _, _states = record_states(hass, zero, "sensor.test4", attributes_tmp) + states = {**states, **_states} + attributes_tmp.pop("state_class") + _, _states = record_states(hass, zero, "sensor.test5", attributes_tmp) + states = {**states, **_states} + + attributes_tmp = dict(attributes) + attributes_tmp["device_class"] = "invalid" + _, _states = record_states(hass, zero, "sensor.test6", attributes_tmp) + states = {**states, **_states} + attributes_tmp.pop("device_class") + _, _states = record_states(hass, zero, "sensor.test7", attributes_tmp) states = {**states, **_states} hist = history.get_significant_states(hass, zero, four) @@ -134,7 +144,9 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ - {"statistic_id": "sensor.test1", "unit_of_measurement": "°C"} + {"statistic_id": "sensor.test1", "unit_of_measurement": "°C"}, + {"statistic_id": "sensor.test6", "unit_of_measurement": "°C"}, + {"statistic_id": "sensor.test7", "unit_of_measurement": "°C"}, ] stats = statistics_during_period(hass, zero) assert stats == { @@ -149,7 +161,31 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "state": None, "sum": None, } - ] + ], + "sensor.test6": [ + { + "statistic_id": "sensor.test6", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(16.440677966101696), + "min": approx(10.0), + "max": approx(30.0), + "last_reset": None, + "state": None, + "sum": None, + } + ], + "sensor.test7": [ + { + "statistic_id": "sensor.test7", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(16.440677966101696), + "min": approx(10.0), + "max": approx(30.0), + "last_reset": None, + "state": None, + "sum": None, + } + ], } assert "Error while processing event StatisticsTask" not in caplog.text @@ -859,6 +895,136 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes): hass.states.set("sensor.test6", 0, attributes=attributes) +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + (None, "%", "%", 16.440677, 10, 30), + ("battery", "%", "%", 16.440677, 10, 30), + ("battery", None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_units_1( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): + """Test compiling hourly statistics where units change from one hour to the next.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, zero + timedelta(hours=1), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, zero + timedelta(hours=2), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + assert "does not match the unit of already compiled" not in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + wait_recording_done(hass) + assert ( + "The unit of sensor.test1 (cats) does not match the unit of already compiled " + f"statistics ({native_unit})" in caplog.text + ) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + (None, "%", "%", 16.440677, 10, 30), + ("battery", "%", "%", 16.440677, 10, 30), + ("battery", None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_units_2( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): + """Test compiling hourly statistics where units change during an hour.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, zero + timedelta(hours=1), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(minutes=30)) + wait_recording_done(hass) + assert "The unit of sensor.test1 is changing" in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": "cats"} + ] + stats = statistics_during_period(hass, zero) + assert stats == {} + + assert "Error while processing event StatisticsTask" not in caplog.text + + def record_states(hass, zero, entity_id, attributes): """Record some test states. From bb42eb11761176964ee1533256ac219896475f6d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Aug 2021 13:01:55 +0200 Subject: [PATCH 772/903] Warn if a sensor with state_class_total has a decreasing value (#55197) --- homeassistant/components/energy/sensor.py | 7 +++- homeassistant/components/sensor/recorder.py | 37 +++++++++++++++++++-- tests/components/sensor/test_recorder.py | 6 ++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 099ea8df0ab..45ef8ea5c17 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -298,7 +298,12 @@ class EnergyCostSensor(SensorEntity): ) return - if reset_detected(energy, float(self._last_energy_sensor_state)): + if reset_detected( + self.hass, + cast(str, self._config[self._adapter.entity_energy_key]), + energy, + float(self._last_energy_sensor_state), + ): # Energy meter was reset, reset cost sensor too self._reset(0) # Update with newly incurred cost diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 77f91752327..2115cca2892 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -39,6 +39,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity import entity_sources import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util import homeassistant.util.temperature as temperature_util @@ -106,6 +107,8 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { }, } +# Keep track of entities for which a warning about decreasing value has been logged +WARN_DIP = "sensor_warn_total_increasing_dip" # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" WARN_UNSTABLE_UNIT = "sensor_warn_unstable_unit" @@ -229,9 +232,36 @@ def _normalize_states( return DEVICE_CLASS_UNITS[device_class], fstates -def reset_detected(state: float, previous_state: float | None) -> bool: +def warn_dip(hass: HomeAssistant, entity_id: str) -> None: + """Log a warning once if a sensor with state_class_total has a decreasing value.""" + if WARN_DIP not in hass.data: + hass.data[WARN_DIP] = set() + if entity_id not in hass.data[WARN_DIP]: + hass.data[WARN_DIP].add(entity_id) + domain = entity_sources(hass).get(entity_id, {}).get("domain") + if domain in ["energy", "growatt_server", "solaredge"]: + return + _LOGGER.warning( + "Entity %s %shas state class total_increasing, but its state is " + "not strictly increasing. Please create a bug report at %s", + entity_id, + f"from integration {domain} " if domain else "", + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + "+label%3A%22integration%3A+recorder%22", + ) + + +def reset_detected( + hass: HomeAssistant, entity_id: str, state: float, previous_state: float | None +) -> bool: """Test if a total_increasing sensor has been reset.""" - return previous_state is not None and state < 0.9 * previous_state + if previous_state is None: + return False + + if 0.9 * previous_state <= state < previous_state: + warn_dip(hass, entity_id) + + return state < 0.9 * previous_state def compile_statistics( @@ -337,7 +367,8 @@ def compile_statistics( fstate, ) elif state_class == STATE_CLASS_TOTAL_INCREASING and ( - old_state is None or reset_detected(fstate, new_state) + old_state is None + or reset_detected(hass, entity_id, fstate, new_state) ): reset = True _LOGGER.info( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 5b82fcf0506..0234a8c0613 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -427,6 +427,12 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( ] } assert "Error while processing event StatisticsTask" not in caplog.text + assert ( + "Entity sensor.test1 has state class total_increasing, but its state is not " + "strictly increasing. Please create a bug report at https://github.com/" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" + "+recorder%22" + ) in caplog.text def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): From 6b4e3bca6fb057d84d748c505b33f13d8440ce25 Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Wed, 25 Aug 2021 07:24:29 -0400 Subject: [PATCH 773/903] Add type annotations to amcrest integration (#54761) Co-authored-by: Milan Meulemans --- .strict-typing | 1 + homeassistant/components/amcrest/__init__.py | 100 +++++--- .../components/amcrest/binary_sensor.py | 61 +++-- homeassistant/components/amcrest/camera.py | 228 ++++++++++-------- homeassistant/components/amcrest/helpers.py | 13 +- homeassistant/components/amcrest/sensor.py | 33 ++- mypy.ini | 11 + 7 files changed, 282 insertions(+), 165 deletions(-) diff --git a/.strict-typing b/.strict-typing index e8c4f83fa80..e0993c2954a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -15,6 +15,7 @@ homeassistant.components.alarm_control_panel.* homeassistant.components.amazon_polly.* homeassistant.components.ambee.* homeassistant.components.ambient_station.* +homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.automation.* homeassistant.components.binary_sensor.* diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index d248a3d8f7c..26247816ac9 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -1,13 +1,18 @@ """Support for Amcrest IP cameras.""" +from __future__ import annotations + from contextlib import suppress -from datetime import timedelta +from dataclasses import dataclass +from datetime import datetime, timedelta import logging import threading +from typing import Any, Callable import aiohttp from amcrest import AmcrestError, ApiWrapper, LoginError import voluptuous as vol +from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.camera import DOMAIN as CAMERA @@ -27,12 +32,14 @@ from homeassistant.const import ( ENTITY_MATCH_NONE, HTTP_BASIC_AUTHENTICATION, ) +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.service import async_extract_entity_ids +from homeassistant.helpers.typing import ConfigType from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST @@ -73,7 +80,7 @@ SCAN_INTERVAL = timedelta(seconds=10) AUTHENTICATION_LIST = {"basic": "basic"} -def _has_unique_names(devices): +def _has_unique_names(devices: list[dict[str, Any]]) -> list[dict[str, Any]]: names = [device[CONF_NAME] for device in devices] vol.Schema(vol.Unique())(names) return devices @@ -119,7 +126,15 @@ CONFIG_SCHEMA = vol.Schema( class AmcrestChecker(ApiWrapper): """amcrest.ApiWrapper wrapper for catching errors.""" - def __init__(self, hass, name, host, port, user, password): + def __init__( + self, + hass: HomeAssistant, + name: str, + host: str, + port: int, + user: str, + password: str, + ) -> None: """Initialize.""" self._hass = hass self._wrap_name = name @@ -128,7 +143,7 @@ class AmcrestChecker(ApiWrapper): self._wrap_login_err = False self._wrap_event_flag = threading.Event() self._wrap_event_flag.set() - self._unsub_recheck = None + self._unsub_recheck: Callable[[], None] | None = None super().__init__( host, port, @@ -139,23 +154,23 @@ class AmcrestChecker(ApiWrapper): ) @property - def available(self): + def available(self) -> bool: """Return if camera's API is responding.""" return self._wrap_errors <= MAX_ERRORS and not self._wrap_login_err @property - def available_flag(self): + def available_flag(self) -> threading.Event: """Return threading event flag that indicates if camera's API is responding.""" return self._wrap_event_flag - def _start_recovery(self): + def _start_recovery(self) -> None: self._wrap_event_flag.clear() dispatcher_send(self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)) self._unsub_recheck = track_time_interval( self._hass, self._wrap_test_online, RECHECK_INTERVAL ) - def command(self, *args, **kwargs): + def command(self, *args: Any, **kwargs: Any) -> Any: """amcrest.ApiWrapper.command wrapper to catch errors.""" try: ret = super().command(*args, **kwargs) @@ -184,6 +199,7 @@ class AmcrestChecker(ApiWrapper): self._wrap_errors = 0 self._wrap_login_err = False if was_offline: + assert self._unsub_recheck is not None self._unsub_recheck() self._unsub_recheck = None _LOGGER.error("%s camera back online", self._wrap_name) @@ -191,15 +207,19 @@ class AmcrestChecker(ApiWrapper): dispatcher_send(self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)) return ret - def _wrap_test_online(self, now): + def _wrap_test_online(self, now: datetime) -> None: """Test if camera is back online.""" _LOGGER.debug("Testing if %s back online", self._wrap_name) with suppress(AmcrestError): self.current_time # pylint: disable=pointless-statement -def _monitor_events(hass, name, api, event_codes): - event_codes = set(event_codes) +def _monitor_events( + hass: HomeAssistant, + name: str, + api: AmcrestChecker, + event_codes: set[str], +) -> None: while True: api.available_flag.wait() try: @@ -220,7 +240,12 @@ def _monitor_events(hass, name, api, event_codes): ) -def _start_event_monitor(hass, name, api, event_codes): +def _start_event_monitor( + hass: HomeAssistant, + name: str, + api: AmcrestChecker, + event_codes: set[str], +) -> None: thread = threading.Thread( target=_monitor_events, name=f"Amcrest {name}", @@ -230,14 +255,14 @@ def _start_event_monitor(hass, name, api, event_codes): thread.start() -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Amcrest IP Camera component.""" hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []}) for device in config[DOMAIN]: - name = device[CONF_NAME] - username = device[CONF_USERNAME] - password = device[CONF_PASSWORD] + name: str = device[CONF_NAME] + username: str = device[CONF_USERNAME] + password: str = device[CONF_PASSWORD] api = AmcrestChecker( hass, name, device[CONF_HOST], device[CONF_PORT], username, password @@ -253,7 +278,9 @@ def setup(hass, config): # currently aiohttp only works with basic authentication # only valid for mjpeg streaming if device[CONF_AUTHENTICATION] == HTTP_BASIC_AUTHENTICATION: - authentication = aiohttp.BasicAuth(username, password) + authentication: aiohttp.BasicAuth | None = aiohttp.BasicAuth( + username, password + ) else: authentication = None @@ -268,7 +295,7 @@ def setup(hass, config): discovery.load_platform(hass, CAMERA, DOMAIN, {CONF_NAME: name}, config) - event_codes = [] + event_codes = set() if binary_sensors: discovery.load_platform( hass, @@ -277,11 +304,13 @@ def setup(hass, config): {CONF_NAME: name, CONF_BINARY_SENSORS: binary_sensors}, config, ) - event_codes = [ + event_codes = { sensor.event_code for sensor in BINARY_SENSORS - if sensor.key in binary_sensors and not sensor.should_poll - ] + if sensor.key in binary_sensors + and not sensor.should_poll + and sensor.event_code is not None + } _start_event_monitor(hass, name, api, event_codes) @@ -293,10 +322,10 @@ def setup(hass, config): if not hass.data[DATA_AMCREST][DEVICES]: return False - def have_permission(user, entity_id): + def have_permission(user: User | None, entity_id: str) -> bool: return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL) - async def async_extract_from_service(call): + async def async_extract_from_service(call: ServiceCall) -> list[str]: if call.context.user_id: user = await hass.auth.async_get_user(call.context.user_id) if user is None: @@ -327,7 +356,7 @@ def setup(hass, config): entity_ids.append(entity_id) return entity_ids - async def async_service_handler(call): + async def async_service_handler(call: ServiceCall) -> None: args = [] for arg in CAMERA_SERVICES[call.service][2]: args.append(call.data[arg]) @@ -340,22 +369,13 @@ def setup(hass, config): return True +@dataclass class AmcrestDevice: """Representation of a base Amcrest discovery device.""" - def __init__( - self, - api, - authentication, - ffmpeg_arguments, - stream_source, - resolution, - control_light, - ): - """Initialize the entity.""" - self.api = api - self.authentication = authentication - self.ffmpeg_arguments = ffmpeg_arguments - self.stream_source = stream_source - self.resolution = resolution - self.control_light = control_light + api: AmcrestChecker + authentication: aiohttp.BasicAuth | None + ffmpeg_arguments: list[str] + stream_source: str + resolution: int + control_light: bool diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index fcbadc73147..93e5b17d548 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -5,6 +5,7 @@ from contextlib import suppress from dataclasses import dataclass from datetime import timedelta import logging +from typing import TYPE_CHECKING, Callable from amcrest import AmcrestError import voluptuous as vol @@ -17,8 +18,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle from .const import ( @@ -30,6 +33,9 @@ from .const import ( ) from .helpers import log_update_error, service_signal +if TYPE_CHECKING: + from . import AmcrestDevice + @dataclass class AmcrestSensorEntityDescription(BinarySensorEntityDescription): @@ -117,7 +123,7 @@ _EXCLUSIVE_OPTIONS = [ _UPDATE_MSG = "Updating %s binary sensor" -def check_binary_sensors(value): +def check_binary_sensors(value: list[str]) -> list[str]: """Validate binary sensor configurations.""" for exclusive_options in _EXCLUSIVE_OPTIONS: if len(set(value) & exclusive_options) > 1: @@ -127,7 +133,12 @@ def check_binary_sensors(value): return value -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up a binary sensor for an Amcrest IP Camera.""" if discovery_info is None: return @@ -148,21 +159,27 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AmcrestBinarySensor(BinarySensorEntity): """Binary sensor for Amcrest camera.""" - def __init__(self, name, device, entity_description): + def __init__( + self, + name: str, + device: AmcrestDevice, + entity_description: AmcrestSensorEntityDescription, + ) -> None: """Initialize entity.""" self._signal_name = name self._api = device.api - self.entity_description = entity_description + self.entity_description: AmcrestSensorEntityDescription = entity_description + self._attr_name = f"{name} {entity_description.name}" self._attr_should_poll = entity_description.should_poll - self._unsub_dispatcher = [] + self._unsub_dispatcher: list[Callable[[], None]] = [] @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self.entity_description.key == _ONLINE_KEY or self._api.available - def update(self): + def update(self) -> None: """Update entity.""" if self.entity_description.key == _ONLINE_KEY: self._update_online() @@ -170,7 +187,7 @@ class AmcrestBinarySensor(BinarySensorEntity): self._update_others() @Throttle(_ONLINE_SCAN_INTERVAL) - def _update_online(self): + def _update_online(self) -> None: if not (self._api.available or self.is_on): return _LOGGER.debug(_UPDATE_MSG, self.name) @@ -182,37 +199,41 @@ class AmcrestBinarySensor(BinarySensorEntity): self._api.current_time # pylint: disable=pointless-statement self._attr_is_on = self._api.available - def _update_others(self): + def _update_others(self) -> None: if not self.available: return _LOGGER.debug(_UPDATE_MSG, self.name) event_code = self.entity_description.event_code + if event_code is None: + _LOGGER.error("Binary sensor %s event code not set", self.name) + return + try: - self._attr_is_on = "channels" in self._api.event_channels_happened( - event_code - ) + self._attr_is_on = len(self._api.event_channels_happened(event_code)) > 0 except AmcrestError as error: log_update_error(_LOGGER, "update", self.name, "binary sensor", error) - async def async_on_demand_update(self): + async def async_on_demand_update(self) -> None: """Update state.""" if self.entity_description.key == _ONLINE_KEY: _LOGGER.debug(_UPDATE_MSG, self.name) self._attr_is_on = self._api.available self.async_write_ha_state() - return - self.async_schedule_update_ha_state(True) + else: + self.async_schedule_update_ha_state(True) @callback - def async_event_received(self, start): + def async_event_received(self, state: bool) -> None: """Update state from received event.""" _LOGGER.debug(_UPDATE_MSG, self.name) - self._attr_is_on = start + self._attr_is_on = state self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to signals.""" + assert self.hass is not None + self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, @@ -236,7 +257,7 @@ class AmcrestBinarySensor(BinarySensorEntity): ) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect from update signal.""" for unsub_dispatcher in self._unsub_dispatcher: unsub_dispatcher() diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index ac89c865862..772824864df 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -5,14 +5,17 @@ import asyncio from datetime import timedelta from functools import partial import logging +from typing import TYPE_CHECKING, Any, Callable +from aiohttp import web from amcrest import AmcrestError from haffmpeg.camera import CameraMjpeg import voluptuous as vol from homeassistant.components.camera import SUPPORT_ON_OFF, SUPPORT_STREAM, Camera -from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.components.ffmpeg import DATA_FFMPEG, FFmpegManager from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, @@ -20,6 +23,8 @@ from homeassistant.helpers.aiohttp_client import ( ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( CAMERA_WEB_SESSION_TIMEOUT, @@ -32,6 +37,9 @@ from .const import ( ) from .helpers import log_update_error, service_signal +if TYPE_CHECKING: + from . import AmcrestDevice + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=15) @@ -112,7 +120,12 @@ CAMERA_SERVICES = { _BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up an Amcrest IP Camera.""" if discovery_info is None: return @@ -133,7 +146,7 @@ class AmcrestCommandFailed(Exception): class AmcrestCam(Camera): """An implementation of an Amcrest IP camera.""" - def __init__(self, name, device, ffmpeg): + def __init__(self, name: str, device: AmcrestDevice, ffmpeg: FFmpegManager) -> None: """Initialize an Amcrest camera.""" super().__init__() self._name = name @@ -144,19 +157,19 @@ class AmcrestCam(Camera): self._resolution = device.resolution self._token = self._auth = device.authentication self._control_light = device.control_light - self._is_recording = False - self._motion_detection_enabled = None - self._brand = None - self._model = None - self._audio_enabled = None - self._motion_recording_enabled = None - self._color_bw = None - self._rtsp_url = None - self._snapshot_task = None - self._unsub_dispatcher = [] + self._is_recording: bool = False + self._motion_detection_enabled: bool = False + self._brand: str | None = None + self._model: str | None = None + self._audio_enabled: bool | None = None + self._motion_recording_enabled: bool | None = None + self._color_bw: str | None = None + self._rtsp_url: str | None = None + self._snapshot_task: asyncio.tasks.Task | None = None + self._unsub_dispatcher: list[Callable[[], None]] = [] self._update_succeeded = False - def _check_snapshot_ok(self): + def _check_snapshot_ok(self) -> None: available = self.available if not available or not self.is_on: _LOGGER.warning( @@ -166,7 +179,8 @@ class AmcrestCam(Camera): ) raise CannotSnapshot - async def _async_get_image(self): + async def _async_get_image(self) -> None: + assert self.hass is not None try: # Send the request to snap a picture and return raw jpg data # Snapshot command needs a much longer read timeout than other commands. @@ -179,7 +193,7 @@ class AmcrestCam(Camera): ) except AmcrestError as error: log_update_error(_LOGGER, "get image from", self.name, "camera", error) - return None + return finally: self._snapshot_task = None @@ -187,6 +201,7 @@ class AmcrestCam(Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" + assert self.hass is not None _LOGGER.debug("Take snapshot from %s", self._name) try: # Amcrest cameras only support one snapshot command at a time. @@ -207,8 +222,11 @@ class AmcrestCam(Camera): except CannotSnapshot: return None - async def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream( + self, request: web.Request + ) -> web.StreamResponse | None: """Return an MJPEG stream.""" + assert self.hass is not None # The snapshot implementation is handled by the parent class if self._stream_source == "snapshot": return await super().handle_async_mjpeg_stream(request) @@ -232,7 +250,7 @@ class AmcrestCam(Camera): return await async_aiohttp_proxy_web(self.hass, request, stream_coro) # streaming via ffmpeg - + assert self._rtsp_url is not None streaming_url = self._rtsp_url stream = CameraMjpeg(self._ffmpeg.binary) await stream.open_camera(streaming_url, extra_cmd=self._ffmpeg_arguments) @@ -259,12 +277,12 @@ class AmcrestCam(Camera): return True @property - def name(self): + def name(self) -> str: """Return the name of this camera.""" return self._name @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the Amcrest-specific camera state attributes.""" attr = {} if self._audio_enabled is not None: @@ -278,78 +296,80 @@ class AmcrestCam(Camera): return attr @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._api.available @property - def supported_features(self): + def supported_features(self) -> int: """Return supported features.""" return SUPPORT_ON_OFF | SUPPORT_STREAM # Camera property overrides @property - def is_recording(self): + def is_recording(self) -> bool: """Return true if the device is recording.""" return self._is_recording @property - def brand(self): + def brand(self) -> str | None: """Return the camera brand.""" return self._brand @property - def motion_detection_enabled(self): + def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return self._motion_detection_enabled @property - def model(self): + def model(self) -> str | None: """Return the camera model.""" return self._model - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the source of the stream.""" return self._rtsp_url @property - def is_on(self): + def is_on(self) -> bool: """Return true if on.""" return self.is_streaming # Other Entity method overrides - async def async_on_demand_update(self): + async def async_on_demand_update(self) -> None: """Update state.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to signals and add camera to list.""" - for service, params in CAMERA_SERVICES.items(): - self._unsub_dispatcher.append( - async_dispatcher_connect( - self.hass, - service_signal(service, self.entity_id), - getattr(self, params[1]), - ) + assert self.hass is not None + self._unsub_dispatcher.extend( + async_dispatcher_connect( + self.hass, + service_signal(service, self.entity_id), + getattr(self, callback_name), ) + for service, (_, callback_name, _) in CAMERA_SERVICES.items() + ) self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, - service_signal(SERVICE_UPDATE, self._name), + service_signal(SERVICE_UPDATE, self.name), self.async_on_demand_update, ) ) self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Remove camera from list and disconnect from signals.""" + assert self.hass is not None self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id) for unsub_dispatcher in self._unsub_dispatcher: unsub_dispatcher() - def update(self): + def update(self) -> None: """Update entity status.""" if not self.available or self._update_succeeded: if not self.available: @@ -388,66 +408,77 @@ class AmcrestCam(Camera): # Other Camera method overrides - def turn_off(self): + def turn_off(self) -> None: """Turn off camera.""" self._enable_video(False) - def turn_on(self): + def turn_on(self) -> None: """Turn on camera.""" self._enable_video(True) - def enable_motion_detection(self): + def enable_motion_detection(self) -> None: """Enable motion detection in the camera.""" self._enable_motion_detection(True) - def disable_motion_detection(self): + def disable_motion_detection(self) -> None: """Disable motion detection in camera.""" self._enable_motion_detection(False) # Additional Amcrest Camera service methods - async def async_enable_recording(self): + async def async_enable_recording(self) -> None: """Call the job and enable recording.""" + assert self.hass is not None await self.hass.async_add_executor_job(self._enable_recording, True) - async def async_disable_recording(self): + async def async_disable_recording(self) -> None: """Call the job and disable recording.""" + assert self.hass is not None await self.hass.async_add_executor_job(self._enable_recording, False) - async def async_enable_audio(self): + async def async_enable_audio(self) -> None: """Call the job and enable audio.""" + assert self.hass is not None await self.hass.async_add_executor_job(self._enable_audio, True) - async def async_disable_audio(self): + async def async_disable_audio(self) -> None: """Call the job and disable audio.""" + assert self.hass is not None await self.hass.async_add_executor_job(self._enable_audio, False) - async def async_enable_motion_recording(self): + async def async_enable_motion_recording(self) -> None: """Call the job and enable motion recording.""" + assert self.hass is not None await self.hass.async_add_executor_job(self._enable_motion_recording, True) - async def async_disable_motion_recording(self): + async def async_disable_motion_recording(self) -> None: """Call the job and disable motion recording.""" + assert self.hass is not None await self.hass.async_add_executor_job(self._enable_motion_recording, False) - async def async_goto_preset(self, preset): + async def async_goto_preset(self, preset: int) -> None: """Call the job and move camera to preset position.""" + assert self.hass is not None await self.hass.async_add_executor_job(self._goto_preset, preset) - async def async_set_color_bw(self, color_bw): + async def async_set_color_bw(self, color_bw: str) -> None: """Call the job and set camera color mode.""" + assert self.hass is not None await self.hass.async_add_executor_job(self._set_color_bw, color_bw) - async def async_start_tour(self): + async def async_start_tour(self) -> None: """Call the job and start camera tour.""" + assert self.hass is not None await self.hass.async_add_executor_job(self._start_tour, True) - async def async_stop_tour(self): + async def async_stop_tour(self) -> None: """Call the job and stop camera tour.""" + assert self.hass is not None await self.hass.async_add_executor_job(self._start_tour, False) - async def async_ptz_control(self, movement, travel_time): + async def async_ptz_control(self, movement: str, travel_time: float) -> None: """Move or zoom camera in specified direction.""" + assert self.hass is not None code = _ACTION[_MOV.index(movement)] kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0} @@ -471,11 +502,14 @@ class AmcrestCam(Camera): # Methods to send commands to Amcrest camera and handle errors - def _change_setting(self, value, attr, description, action="set"): + def _change_setting( + self, value: str | bool, description: str, attr: str | None = None + ) -> None: func = description.replace(" ", "_") description = f"camera {description} to {value}" - tries = 3 - while True: + action = "set" + max_tries = 3 + for tries in range(max_tries, 0, -1): try: getattr(self, f"_set_{func}")(value) new_value = getattr(self, f"_get_{func}")() @@ -493,90 +527,94 @@ class AmcrestCam(Camera): setattr(self, attr, new_value) self.schedule_update_ha_state() return - tries -= 1 - def _get_video(self): + def _get_video(self) -> bool: return self._api.video_enabled - def _set_video(self, enable): + def _set_video(self, enable: bool) -> None: self._api.video_enabled = enable - def _enable_video(self, enable): + def _enable_video(self, enable: bool) -> None: """Enable or disable camera video stream.""" # Given the way the camera's state is determined by # is_streaming and is_recording, we can't leave # recording on if video stream is being turned off. if self.is_recording and not enable: self._enable_recording(False) - self._change_setting(enable, "is_streaming", "video") + self._change_setting(enable, "video", "is_streaming") if self._control_light: self._change_light() - def _get_recording(self): + def _get_recording(self) -> bool: return self._api.record_mode == "Manual" - def _set_recording(self, enable): + def _set_recording(self, enable: bool) -> None: rec_mode = {"Automatic": 0, "Manual": 1} - self._api.record_mode = rec_mode["Manual" if enable else "Automatic"] + # The property has a str type, but setter has int type, which causes mypy confusion + self._api.record_mode = rec_mode["Manual" if enable else "Automatic"] # type: ignore[assignment] - def _enable_recording(self, enable): + def _enable_recording(self, enable: bool) -> None: """Turn recording on or off.""" # Given the way the camera's state is determined by # is_streaming and is_recording, we can't leave # video stream off if recording is being turned on. if not self.is_streaming and enable: self._enable_video(True) - self._change_setting(enable, "_is_recording", "recording") + self._change_setting(enable, "recording", "_is_recording") - def _get_motion_detection(self): + def _get_motion_detection(self) -> bool: return self._api.is_motion_detector_on() - def _set_motion_detection(self, enable): - self._api.motion_detection = str(enable).lower() + def _set_motion_detection(self, enable: bool) -> None: + # The property has a str type, but setter has bool type, which causes mypy confusion + self._api.motion_detection = enable # type: ignore[assignment] - def _enable_motion_detection(self, enable): + def _enable_motion_detection(self, enable: bool) -> None: """Enable or disable motion detection.""" - self._change_setting(enable, "_motion_detection_enabled", "motion detection") + self._change_setting(enable, "motion detection", "_motion_detection_enabled") - def _get_audio(self): + def _get_audio(self) -> bool: return self._api.audio_enabled - def _set_audio(self, enable): + def _set_audio(self, enable: bool) -> None: self._api.audio_enabled = enable - def _enable_audio(self, enable): + def _enable_audio(self, enable: bool) -> None: """Enable or disable audio stream.""" - self._change_setting(enable, "_audio_enabled", "audio") + self._change_setting(enable, "audio", "_audio_enabled") if self._control_light: self._change_light() - def _get_indicator_light(self): - return "true" in self._api.command( - "configManager.cgi?action=getConfig&name=LightGlobal" - ).content.decode("utf-8") + def _get_indicator_light(self) -> bool: + return ( + "true" + in self._api.command( + "configManager.cgi?action=getConfig&name=LightGlobal" + ).content.decode() + ) - def _set_indicator_light(self, enable): + def _set_indicator_light(self, enable: bool) -> None: self._api.command( f"configManager.cgi?action=setConfig&LightGlobal[0].Enable={str(enable).lower()}" ) - def _change_light(self): + def _change_light(self) -> None: """Enable or disable indicator light.""" self._change_setting( - self._audio_enabled or self.is_streaming, None, "indicator light" + self._audio_enabled or self.is_streaming, "indicator light" ) - def _get_motion_recording(self): + def _get_motion_recording(self) -> bool: return self._api.is_record_on_motion_detection() - def _set_motion_recording(self, enable): - self._api.motion_recording = str(enable).lower() + def _set_motion_recording(self, enable: bool) -> None: + self._api.motion_recording = enable - def _enable_motion_recording(self, enable): + def _enable_motion_recording(self, enable: bool) -> None: """Enable or disable motion recording.""" - self._change_setting(enable, "_motion_recording_enabled", "motion recording") + self._change_setting(enable, "motion recording", "_motion_recording_enabled") - def _goto_preset(self, preset): + def _goto_preset(self, preset: int) -> None: """Move camera position and zoom to preset.""" try: self._api.go_to_preset(preset_point_number=preset) @@ -585,17 +623,17 @@ class AmcrestCam(Camera): _LOGGER, "move", self.name, f"camera to preset {preset}", error ) - def _get_color_mode(self): + def _get_color_mode(self) -> str: return _CBW[self._api.day_night_color] - def _set_color_mode(self, cbw): + def _set_color_mode(self, cbw: str) -> None: self._api.day_night_color = _CBW.index(cbw) - def _set_color_bw(self, cbw): + def _set_color_bw(self, cbw: str) -> None: """Set camera color mode.""" - self._change_setting(cbw, "_color_bw", "color mode") + self._change_setting(cbw, "color mode", "_color_bw") - def _start_tour(self, start): + def _start_tour(self, start: bool) -> None: """Start camera tour.""" try: self._api.tour(start=start) diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py index ef0ae2db15b..ff1a283769d 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -1,15 +1,24 @@ """Helpers for amcrest component.""" +from __future__ import annotations + import logging from .const import DOMAIN -def service_signal(service, *args): +def service_signal(service: str, *args: str) -> str: """Encode signal.""" return "_".join([DOMAIN, service, *args]) -def log_update_error(logger, action, name, entity_type, error, level=logging.ERROR): +def log_update_error( + logger: logging.Logger, + action: str, + name: str | None, + entity_type: str, + error: Exception, + level: int = logging.ERROR, +) -> None: """Log an update error.""" logger.log( level, diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index 95a92b205f0..b916757f44a 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -3,16 +3,23 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import TYPE_CHECKING, Callable from amcrest import AmcrestError from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import CONF_NAME, CONF_SENSORS, PERCENTAGE +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DATA_AMCREST, DEVICES, SENSOR_SCAN_INTERVAL_SECS, SERVICE_UPDATE from .helpers import log_update_error, service_signal +if TYPE_CHECKING: + from . import AmcrestDevice + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS) @@ -37,7 +44,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up a sensor for an Amcrest IP Camera.""" if discovery_info is None: return @@ -58,21 +70,24 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AmcrestSensor(SensorEntity): """A sensor implementation for Amcrest IP camera.""" - def __init__(self, name, device, description: SensorEntityDescription): + def __init__( + self, name: str, device: AmcrestDevice, description: SensorEntityDescription + ) -> None: """Initialize a sensor for Amcrest camera.""" self.entity_description = description self._signal_name = name self._api = device.api - self._unsub_dispatcher = None + self._unsub_dispatcher: Callable[[], None] | None = None self._attr_name = f"{name} {description.name}" + self._attr_extra_state_attributes = {} @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._api.available - def update(self): + def update(self) -> None: """Get the latest data and updates the state.""" if not self.available: return @@ -108,18 +123,20 @@ class AmcrestSensor(SensorEntity): except AmcrestError as error: log_update_error(_LOGGER, "update", self.name, "sensor", error) - async def async_on_demand_update(self): + async def async_on_demand_update(self) -> None: """Update state.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to update signal.""" + assert self.hass is not None self._unsub_dispatcher = async_dispatcher_connect( self.hass, service_signal(SERVICE_UPDATE, self._signal_name), self.async_on_demand_update, ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect from update signal.""" + assert self._unsub_dispatcher is not None self._unsub_dispatcher() diff --git a/mypy.ini b/mypy.ini index 82ed7d6ae9d..94aae01bc70 100644 --- a/mypy.ini +++ b/mypy.ini @@ -176,6 +176,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.amcrest.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ampio.*] check_untyped_defs = true disallow_incomplete_defs = true From 504d23ac72f634f2a8ed82f0dbc9be15d4db9996 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 25 Aug 2021 13:37:08 +0200 Subject: [PATCH 774/903] Activate mypy for switchbot (#55196) * Please mypy. * Update homeassistant/components/switchbot/switch.py Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/switchbot/switch.py | 6 +++--- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index cff1a0d0edc..3fcf789da93 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -37,8 +37,8 @@ class SwitchBot(SwitchEntity, RestoreEntity): def __init__(self, mac, name, password) -> None: """Initialize the Switchbot.""" - self._state = None - self._last_run_success = None + self._state: bool | None = None + self._last_run_success: bool | None = None self._name = name self._mac = mac self._device = switchbot.Switchbot(mac=mac, password=password) @@ -75,7 +75,7 @@ class SwitchBot(SwitchEntity, RestoreEntity): @property def is_on(self) -> bool: """Return true if device is on.""" - return self._state + return bool(self._state) @property def unique_id(self) -> str: diff --git a/mypy.ini b/mypy.ini index 94aae01bc70..02a2800a801 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1609,9 +1609,6 @@ ignore_errors = true [mypy-homeassistant.components.stt.*] ignore_errors = true -[mypy-homeassistant.components.switchbot.*] -ignore_errors = true - [mypy-homeassistant.components.system_health.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 581b4865f7c..0026be479a4 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -125,7 +125,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.sonos.*", "homeassistant.components.spotify.*", "homeassistant.components.stt.*", - "homeassistant.components.switchbot.*", "homeassistant.components.system_health.*", "homeassistant.components.system_log.*", "homeassistant.components.tado.*", From 7f203069a41e2b2104d84b2ebe2a48a99968317b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 13:52:59 +0200 Subject: [PATCH 775/903] Use EntityDescription - mhz19 (#55187) * Use EntityDescription - mhz19 * Fix tests --- homeassistant/components/mhz19/sensor.py | 69 ++++++++++++++---------- tests/components/mhz19/test_sensor.py | 9 ++-- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/mhz19/sensor.py b/homeassistant/components/mhz19/sensor.py index ea90186e75a..a599a9f7dfb 100644 --- a/homeassistant/components/mhz19/sensor.py +++ b/homeassistant/components/mhz19/sensor.py @@ -1,11 +1,17 @@ """Support for CO2 sensor connected to a serial port.""" +from __future__ import annotations + from datetime import timedelta import logging from pmsensor import co2sensor import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_TEMPERATURE, CONCENTRATION_PARTS_PER_MILLION, @@ -29,16 +35,27 @@ ATTR_CO2_CONCENTRATION = "co2_concentration" SENSOR_TEMPERATURE = "temperature" SENSOR_CO2 = "co2" -SENSOR_TYPES = { - SENSOR_TEMPERATURE: ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - SENSOR_CO2: ["CO2", CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_CO2, + name="CO2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO2, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_SERIAL_DEVICE): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS, default=[SENSOR_CO2]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -58,43 +75,36 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return False data = MHZClient(co2sensor, config.get(CONF_SERIAL_DEVICE)) - dev = [] - name = config.get(CONF_NAME) + name = config[CONF_NAME] - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append(MHZ19Sensor(data, variable, name)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + MHZ19Sensor(data, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - add_entities(dev, True) - return True + add_entities(entities, True) class MHZ19Sensor(SensorEntity): """Representation of an CO2 sensor.""" - def __init__(self, mhz_client, sensor_type, name): + def __init__(self, mhz_client, name, description: SensorEntityDescription): """Initialize a new PM sensor.""" + self.entity_description = description self._mhz_client = mhz_client - self._sensor_type = sensor_type - self._name = name - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._ppm = None self._temperature = None - self._attr_device_class = SENSOR_TYPES[sensor_type][2] - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name}: {SENSOR_TYPES[self._sensor_type][0]}" + self._attr_name = f"{name}: {description.name}" @property def native_value(self): """Return the state of the sensor.""" - return self._ppm if self._sensor_type == SENSOR_CO2 else self._temperature - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + if self.entity_description.key == SENSOR_CO2: + return self._ppm + return self._temperature def update(self): """Read from sensor and update the state.""" @@ -107,9 +117,10 @@ class MHZ19Sensor(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" result = {} - if self._sensor_type == SENSOR_TEMPERATURE and self._ppm is not None: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMPERATURE and self._ppm is not None: result[ATTR_CO2_CONCENTRATION] = self._ppm - if self._sensor_type == SENSOR_CO2 and self._temperature is not None: + elif sensor_type == SENSOR_CO2 and self._temperature is not None: result[ATTR_TEMPERATURE] = self._temperature return result diff --git a/tests/components/mhz19/test_sensor.py b/tests/components/mhz19/test_sensor.py index 5af7338abc4..5fd3c8e9df1 100644 --- a/tests/components/mhz19/test_sensor.py +++ b/tests/components/mhz19/test_sensor.py @@ -43,10 +43,11 @@ async def test_setup_connected(hass): ): read_mh_z19_with_temperature.return_value = None mock_add = Mock() - assert mhz19.setup_platform( + mhz19.setup_platform( hass, { "platform": "mhz19", + "name": "name", "monitored_conditions": ["co2", "temperature"], mhz19.CONF_SERIAL_DEVICE: "test.serial", }, @@ -86,7 +87,7 @@ async def aiohttp_client_update_good_read(mock_function): async def test_co2_sensor(mock_function, hass): """Test CO2 sensor.""" client = mhz19.MHZClient(co2sensor, "test.serial") - sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_CO2, "name") + sensor = mhz19.MHZ19Sensor(client, "name", mhz19.SENSOR_TYPES[1]) sensor.hass = hass sensor.update() @@ -101,7 +102,7 @@ async def test_co2_sensor(mock_function, hass): async def test_temperature_sensor(mock_function, hass): """Test temperature sensor.""" client = mhz19.MHZClient(co2sensor, "test.serial") - sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_TEMPERATURE, "name") + sensor = mhz19.MHZ19Sensor(client, "name", mhz19.SENSOR_TYPES[0]) sensor.hass = hass sensor.update() @@ -117,7 +118,7 @@ async def test_temperature_sensor_f(mock_function, hass): """Test temperature sensor.""" with patch.object(hass.config.units, "temperature_unit", TEMP_FAHRENHEIT): client = mhz19.MHZClient(co2sensor, "test.serial") - sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_TEMPERATURE, "name") + sensor = mhz19.MHZ19Sensor(client, "name", mhz19.SENSOR_TYPES[0]) sensor.hass = hass sensor.update() From f4fbc083e63b28cb67374060a66feef0b564246b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 25 Aug 2021 13:54:36 +0200 Subject: [PATCH 776/903] Tasmota - Cleanup tests involving legacy fan speed (#55202) --- tests/components/tasmota/test_fan.py | 45 ++++------------------------ 1 file changed, 5 insertions(+), 40 deletions(-) diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index a64c5e9c5e4..202a6a5386b 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -9,6 +9,7 @@ from hatasmota.utils import ( get_topic_tele_will, ) import pytest +from voluptuous import MultipleInvalid from homeassistant.components import fan from homeassistant.components.tasmota.const import DEFAULT_PREFIX @@ -51,46 +52,38 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF - assert state.attributes["speed"] is None assert state.attributes["percentage"] is None - assert state.attributes["speed_list"] == ["off", "low", "medium", "high"] assert state.attributes["supported_features"] == fan.SUPPORT_SET_SPEED assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":1}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON - assert state.attributes["speed"] == "low" assert state.attributes["percentage"] == 33 async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":2}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON - assert state.attributes["speed"] == "medium" assert state.attributes["percentage"] == 66 async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":3}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON - assert state.attributes["speed"] == "high" assert state.attributes["percentage"] == 100 async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":0}') state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF - assert state.attributes["speed"] == "off" assert state.attributes["percentage"] == 0 async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"FanSpeed":1}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON - assert state.attributes["speed"] == "low" assert state.attributes["percentage"] == 33 async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"FanSpeed":0}') state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF - assert state.attributes["speed"] == "off" assert state.attributes["percentage"] == 0 @@ -132,34 +125,6 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): ) mqtt_mock.async_publish.reset_mock() - # Set speed and verify MQTT message is sent - await common.async_set_speed(hass, "fan.tasmota", fan.SPEED_OFF) - mqtt_mock.async_publish.assert_called_once_with( - "tasmota_49A3BC/cmnd/FanSpeed", "0", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - # Set speed and verify MQTT message is sent - await common.async_set_speed(hass, "fan.tasmota", fan.SPEED_LOW) - mqtt_mock.async_publish.assert_called_once_with( - "tasmota_49A3BC/cmnd/FanSpeed", "1", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - # Set speed and verify MQTT message is sent - await common.async_set_speed(hass, "fan.tasmota", fan.SPEED_MEDIUM) - mqtt_mock.async_publish.assert_called_once_with( - "tasmota_49A3BC/cmnd/FanSpeed", "2", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - # Set speed and verify MQTT message is sent - await common.async_set_speed(hass, "fan.tasmota", fan.SPEED_HIGH) - mqtt_mock.async_publish.assert_called_once_with( - "tasmota_49A3BC/cmnd/FanSpeed", "3", 0, False - ) - mqtt_mock.async_publish.reset_mock() - # Set speed percentage and verify MQTT message is sent await common.async_set_percentage(hass, "fan.tasmota", 0) mqtt_mock.async_publish.assert_called_once_with( @@ -188,7 +153,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): ) -async def test_invalid_fan_speed(hass, mqtt_mock, setup_tasmota): +async def test_invalid_fan_speed_percentage(hass, mqtt_mock, setup_tasmota): """Test the sending MQTT commands.""" config = copy.deepcopy(DEFAULT_CONFIG) config["if"] = 1 @@ -209,9 +174,9 @@ async def test_invalid_fan_speed(hass, mqtt_mock, setup_tasmota): mqtt_mock.async_publish.reset_mock() # Set an unsupported speed and verify MQTT message is not sent - with pytest.raises(ValueError) as excinfo: - await common.async_set_speed(hass, "fan.tasmota", "no_such_speed") - assert "no_such_speed" in str(excinfo.value) + with pytest.raises(MultipleInvalid) as excinfo: + await common.async_set_percentage(hass, "fan.tasmota", 101) + assert "value must be at most 100" in str(excinfo.value) mqtt_mock.async_publish.assert_not_called() From 7dd169b48ead68502d62d69806bdc4b8757737f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 25 Aug 2021 14:03:30 +0200 Subject: [PATCH 777/903] Utility meter, add STATE_CLASS_TOTAL_INCREASING (#54871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Utility meter, STATE_CLASS_TOTAL_INCREASING Signed-off-by: Daniel Hjelseth Høyer * update test Signed-off-by: Daniel Hjelseth Høyer * update test Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/utility_meter/sensor.py | 12 ++++++++++-- tests/components/utility_meter/test_sensor.py | 11 ++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 84533efdcf5..e8d938928dd 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -5,7 +5,11 @@ import logging import voluptuous as vol -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, @@ -330,7 +334,11 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): @property def state_class(self): """Return the device class of the sensor.""" - return STATE_CLASS_MEASUREMENT + return ( + STATE_CLASS_MEASUREMENT + if self._sensor_net_consumption + else STATE_CLASS_TOTAL_INCREASING + ) @property def native_unit_of_measurement(self): diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c5075aa322b..a2d15c595b0 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -3,7 +3,11 @@ from contextlib import contextmanager from datetime import timedelta from unittest.mock import patch -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.components.utility_meter.const import ( ATTR_TARIFF, ATTR_VALUE, @@ -165,6 +169,7 @@ async def test_device_class(hass): "utility_meter": { "energy_meter": { "source": "sensor.energy", + "net_consumption": True, }, "gas_meter": { "source": "sensor.gas", @@ -197,7 +202,7 @@ async def test_device_class(hass): assert state is not None assert state.state == "0" assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None hass.states.async_set( @@ -219,7 +224,7 @@ async def test_device_class(hass): assert state is not None assert state.state == "1" assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "some_archaic_unit" From 2271f3b5f9335517b93b27234a211d1d8e3113d8 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 25 Aug 2021 06:18:53 -0600 Subject: [PATCH 778/903] Clean up usage of EntityDescription in OpenUV (#55127) * Clean up usage of EntityDescription in OpenUV * Remove redundant typing * Code review * Update homeassistant/components/openuv/__init__.py Co-authored-by: Franck Nijhof * Black Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Co-authored-by: Franck Nijhof --- homeassistant/components/openuv/__init__.py | 8 ++--- .../components/openuv/binary_sensor.py | 32 ++++++++----------- homeassistant/components/openuv/sensor.py | 32 +++++++------------ 3 files changed, 29 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index d931049bb31..d14760d6cb1 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.service import verify_domain_control from .const import ( @@ -175,14 +175,14 @@ class OpenUV: class OpenUvEntity(Entity): """Define a generic OpenUV entity.""" - def __init__(self, openuv: OpenUV, sensor_type: str) -> None: + def __init__(self, openuv: OpenUV, description: EntityDescription) -> None: """Initialize.""" self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._attr_should_poll = False self._attr_unique_id = ( - f"{openuv.client.latitude}_{openuv.client.longitude}_{sensor_type}" + f"{openuv.client.latitude}_{openuv.client.longitude}_{description.key}" ) - self._sensor_type = sensor_type + self.entity_description = description self.openuv = openuv async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 12b1f0c82af..a632d212abd 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -1,11 +1,14 @@ """Support for OpenUV binary sensors.""" -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime, utcnow -from . import OpenUV, OpenUvEntity +from . import OpenUvEntity from .const import ( DATA_CLIENT, DATA_PROTECTION_WINDOW, @@ -19,7 +22,11 @@ ATTR_PROTECTION_WINDOW_ENDING_UV = "end_uv" ATTR_PROTECTION_WINDOW_STARTING_TIME = "start_time" ATTR_PROTECTION_WINDOW_STARTING_UV = "start_uv" -BINARY_SENSORS = {TYPE_PROTECTION_WINDOW: ("Protection Window", "mdi:sunglasses")} +BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW = BinarySensorEntityDescription( + key=TYPE_PROTECTION_WINDOW, + name="Protection Window", + icon="mdi:sunglasses", +) async def async_setup_entry( @@ -27,25 +34,14 @@ async def async_setup_entry( ) -> None: """Set up an OpenUV sensor based on a config entry.""" openuv = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - - binary_sensors = [] - for kind, attrs in BINARY_SENSORS.items(): - name, icon = attrs - binary_sensors.append(OpenUvBinarySensor(openuv, kind, name, icon)) - - async_add_entities(binary_sensors, True) + async_add_entities( + [OpenUvBinarySensor(openuv, BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW)] + ) class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): """Define a binary sensor for OpenUV.""" - def __init__(self, openuv: OpenUV, sensor_type: str, name: str, icon: str) -> None: - """Initialize the sensor.""" - super().__init__(openuv, sensor_type) - - self._attr_icon = icon - self._attr_name = name - @callback def update_from_latest_data(self) -> None: """Update the state.""" @@ -62,7 +58,7 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): LOGGER.info("Skipping update due to missing data: %s", key) return - if self._sensor_type == TYPE_PROTECTION_WINDOW: + if self.entity_description.key == TYPE_PROTECTION_WINDOW: from_dt = parse_datetime(data["from_time"]) to_dt = parse_datetime(data["to_time"]) diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 9769245c48f..bb04bda4cb4 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime -from . import OpenUV, OpenUvEntity +from . import OpenUvEntity from .const import ( DATA_CLIENT, DATA_UV, @@ -42,7 +42,7 @@ UV_LEVEL_HIGH = "High" UV_LEVEL_MODERATE = "Moderate" UV_LEVEL_LOW = "Low" -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( +SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_CURRENT_OZONE_LEVEL, name="Current Ozone Level", @@ -59,7 +59,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=TYPE_CURRENT_UV_LEVEL, name="Current UV Level", icon="mdi:weather-sunny", - native_unit_of_measurement=None, ), SensorEntityDescription( key=TYPE_MAX_UV_INDEX, @@ -111,23 +110,14 @@ async def async_setup_entry( ) -> None: """Set up a OpenUV sensor based on a config entry.""" openuv = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - - entities = [OpenUvSensor(openuv, description) for description in SENSOR_TYPES] - async_add_entities(entities, True) + async_add_entities( + [OpenUvSensor(openuv, description) for description in SENSOR_DESCRIPTIONS] + ) class OpenUvSensor(OpenUvEntity, SensorEntity): """Define a binary sensor for OpenUV.""" - def __init__( - self, - openuv: OpenUV, - description: SensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(openuv, description.key) - self.entity_description = description - @callback def update_from_latest_data(self) -> None: """Update the state.""" @@ -139,11 +129,11 @@ class OpenUvSensor(OpenUvEntity, SensorEntity): self._attr_available = True - if self._sensor_type == TYPE_CURRENT_OZONE_LEVEL: + if self.entity_description.key == TYPE_CURRENT_OZONE_LEVEL: self._attr_native_value = data["ozone"] - elif self._sensor_type == TYPE_CURRENT_UV_INDEX: + elif self.entity_description.key == TYPE_CURRENT_UV_INDEX: self._attr_native_value = data["uv"] - elif self._sensor_type == TYPE_CURRENT_UV_LEVEL: + elif self.entity_description.key == TYPE_CURRENT_UV_LEVEL: if data["uv"] >= 11: self._attr_native_value = UV_LEVEL_EXTREME elif data["uv"] >= 8: @@ -154,14 +144,14 @@ class OpenUvSensor(OpenUvEntity, SensorEntity): self._attr_native_value = UV_LEVEL_MODERATE else: self._attr_native_value = UV_LEVEL_LOW - elif self._sensor_type == TYPE_MAX_UV_INDEX: + elif self.entity_description.key == TYPE_MAX_UV_INDEX: self._attr_native_value = data["uv_max"] uv_max_time = parse_datetime(data["uv_max_time"]) if uv_max_time: self._attr_extra_state_attributes.update( {ATTR_MAX_UV_TIME: as_local(uv_max_time)} ) - elif self._sensor_type in ( + elif self.entity_description.key in ( TYPE_SAFE_EXPOSURE_TIME_1, TYPE_SAFE_EXPOSURE_TIME_2, TYPE_SAFE_EXPOSURE_TIME_3, @@ -170,5 +160,5 @@ class OpenUvSensor(OpenUvEntity, SensorEntity): TYPE_SAFE_EXPOSURE_TIME_6, ): self._attr_native_value = data["safe_exposure_time"][ - EXPOSURE_TYPE_MAP[self._sensor_type] + EXPOSURE_TYPE_MAP[self.entity_description.key] ] From d4064e70442b32cdd07d7f238d8d0c205999ff1f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 25 Aug 2021 14:49:37 +0200 Subject: [PATCH 779/903] Cancel entity timers. (#55141) --- homeassistant/components/modbus/base_platform.py | 3 ++- homeassistant/components/modbus/modbus.py | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 1e2f1056db2..d9ee58f3c38 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -87,9 +87,10 @@ class BasePlatform(Entity): async def async_base_added_to_hass(self): """Handle entity which will be added.""" if self._scan_interval > 0: - async_track_time_interval( + cancel_func = async_track_time_interval( self.hass, self.async_update, timedelta(seconds=self._scan_interval) ) + self._hub.entity_timers.append(cancel_func) class BaseStructPlatform(BasePlatform, RestoreEntity): diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 7cab51f7fe6..c2cae9f4ec3 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_TYPE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.event import async_call_later @@ -189,6 +189,8 @@ class ModbusHub: name: str + entity_timers: list[CALLBACK_TYPE] = [] + def __init__(self, hass, client_config): """Initialize the Modbus hub.""" @@ -288,6 +290,9 @@ class ModbusHub: if self._async_cancel_listener: self._async_cancel_listener() self._async_cancel_listener = None + for call in self.entity_timers: + call() + self.entity_timers = [] if self._client: try: self._client.close() From 1224d68d051deeef0c345686f5d493570661080c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 15:01:17 +0200 Subject: [PATCH 780/903] Remove redundant str cast - sensor value conversion (#55204) --- homeassistant/components/sensor/__init__.py | 2 +- tests/components/mhz19/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 73e6f1fa93a..658c840cd65 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -291,7 +291,7 @@ class SensorEntity(Entity): # Suppress ValueError (Could not convert sensor_value to float) with suppress(ValueError): temp = units.temperature(float(value), unit_of_measurement) - value = str(round(temp) if prec == 0 else round(temp, prec)) + value = round(temp) if prec == 0 else round(temp, prec) return value diff --git a/tests/components/mhz19/test_sensor.py b/tests/components/mhz19/test_sensor.py index 5fd3c8e9df1..fd494d6c099 100644 --- a/tests/components/mhz19/test_sensor.py +++ b/tests/components/mhz19/test_sensor.py @@ -122,4 +122,4 @@ async def test_temperature_sensor_f(mock_function, hass): sensor.hass = hass sensor.update() - assert sensor.state == "75" + assert sensor.state == 75 From 517fda1383fc4041699aa284eb0850d5fb989888 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Aug 2021 15:24:51 +0200 Subject: [PATCH 781/903] Fix last_reset in utility_meter (#55209) --- homeassistant/components/utility_meter/sensor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index e8d938928dd..e0bd33006d3 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -6,6 +6,7 @@ import logging import voluptuous as vol from homeassistant.components.sensor import ( + ATTR_LAST_RESET, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, @@ -58,7 +59,6 @@ ATTR_SOURCE_ID = "source" ATTR_STATUS = "status" ATTR_PERIOD = "meter_period" ATTR_LAST_PERIOD = "last_period" -ATTR_LAST_RESET = "last_reset" ATTR_TARIFF = "tariff" DEVICE_CLASS_MAP = { @@ -357,7 +357,6 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): ATTR_SOURCE_ID: self._sensor_source_id, ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, ATTR_LAST_PERIOD: self._last_period, - ATTR_LAST_RESET: self._last_reset.isoformat(), } if self._period is not None: state_attr[ATTR_PERIOD] = self._period @@ -369,3 +368,8 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): def icon(self): """Return the icon to use in the frontend, if any.""" return ICON + + @property + def last_reset(self): + """Return the time when the sensor was last reset.""" + return self._last_reset From 06604728c5704c21a27f5151193d04828f7e8bdf Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 25 Aug 2021 15:25:46 +0200 Subject: [PATCH 782/903] Remove should poll property from Xiaomi Miio fan platform (#55201) --- homeassistant/components/xiaomi_miio/fan.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 24b75122424..0d22dad32ea 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -276,11 +276,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Return the percentage based speed of the fan.""" return None - @property - def should_poll(self): - """Poll the device.""" - return True - @property def available(self): """Return true when state is known.""" From e633cc177ec660308a9e4735e00d28db1206690e Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Wed, 25 Aug 2021 15:33:26 +0200 Subject: [PATCH 783/903] ESPHome sensor use total_increasing state class (#55208) --- homeassistant/components/esphome/sensor.py | 87 +++------------------- 1 file changed, 12 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 3e8fbc19a4d..c0ea9f0f9c5 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -1,8 +1,6 @@ """Support for esphome sensors.""" from __future__ import annotations -from contextlib import suppress -from datetime import datetime import math from typing import cast @@ -20,13 +18,13 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_TIMESTAMP, DEVICE_CLASSES, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt from . import ( @@ -71,83 +69,14 @@ _STATE_CLASSES: EsphomeEnumMapper[SensorStateClass, str | None] = EsphomeEnumMap { SensorStateClass.NONE: None, SensorStateClass.MEASUREMENT: STATE_CLASS_MEASUREMENT, + SensorStateClass.TOTAL_INCREASING: STATE_CLASS_TOTAL_INCREASING, } ) -class EsphomeSensor( - EsphomeEntity[SensorInfo, SensorState], SensorEntity, RestoreEntity -): +class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): """A sensor implementation for esphome.""" - _old_state: float | None = None - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - if self._static_info.last_reset_type != LastResetType.AUTO: - return - - # Logic to restore old state for last_reset_type AUTO: - last_state = await self.async_get_last_state() - if last_state is None: - return - - if "last_reset" in last_state.attributes: - self._attr_last_reset = dt.as_utc( - datetime.fromisoformat(last_state.attributes["last_reset"]) - ) - - with suppress(ValueError): - self._old_state = float(last_state.state) - - @callback - def _on_state_update(self) -> None: - """Check last_reset when new state arrives.""" - if self._static_info.last_reset_type == LastResetType.NEVER: - self._attr_last_reset = dt.utc_from_timestamp(0) - - if self._static_info.last_reset_type != LastResetType.AUTO: - super()._on_state_update() - return - - # Last reset type AUTO logic for the last_reset property - # In this mode we automatically determine if an accumulator reset - # has taken place. - # We compare the last valid value (_old_state) with the new one. - # If the value has reset to 0 or has significantly reduced we say - # it has reset. - new_state: float | None = None - state = cast("str | None", self.state) - if state is not None: - with suppress(ValueError): - new_state = float(state) - - did_reset = False - if new_state is None: - # New state is not a float - we'll detect the reset once we get valid data again - did_reset = False - elif self._old_state is None: - # First measurement we ever got for this sensor, always a reset - did_reset = True - elif new_state == 0: - # don't set reset if both old and new are 0 - # we would already have detected the reset on the last state - did_reset = self._old_state != 0 - elif new_state < self._old_state: - did_reset = True - - # Set last_reset to now if we detected a reset - if did_reset: - self._attr_last_reset = dt.utcnow() - - if new_state is not None: - # Only write to old_state if the new one contains actual data - self._old_state = new_state - - super()._on_state_update() - @property def icon(self) -> str | None: """Return the icon.""" @@ -190,6 +119,14 @@ class EsphomeSensor( """Return the state class of this entity.""" if not self._static_info.state_class: return None + state_class = self._static_info.state_class + reset_type = self._static_info.last_reset_type + if ( + state_class == SensorStateClass.MEASUREMENT + and reset_type == LastResetType.AUTO + ): + # Legacy, last_reset_type auto was the equivalent to the TOTAL_INCREASING state class + return STATE_CLASS_TOTAL_INCREASING return _STATE_CLASSES.from_esphome(self._static_info.state_class) From 856f4ad740ea54cb95a7412fa7f1a2cf964d801f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Aug 2021 15:39:49 +0200 Subject: [PATCH 784/903] =?UTF-8?q?Fix=20Fj=C3=A4r=C3=A5skupan=20RSSI=20se?= =?UTF-8?q?nsor=20unit=20(#55210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/fjaraskupan/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py index 306517c4146..4252828c633 100644 --- a/homeassistant/components/fjaraskupan/sensor.py +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -55,7 +55,7 @@ class RssiSensor(CoordinatorEntity[State], SensorEntity): self._attr_name = f"{device_info['name']} Signal Strength" self._attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH self._attr_state_class = STATE_CLASS_MEASUREMENT - self._attr_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + self._attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT self._attr_entity_registry_enabled_default = False @property From 7f80781f9bfdbf3b7833f19527bdb04e1f238ef9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Aug 2021 15:44:35 +0200 Subject: [PATCH 785/903] Prevent setting _attr_unit_of_measurement in subclasses of SensorEntity (#55211) --- homeassistant/components/sensor/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 658c840cd65..fafaabbd217 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -167,6 +167,9 @@ class SensorEntity(Entity): _attr_native_value: StateType = None _attr_state_class: str | None _attr_state: None = None # Subclasses of SensorEntity should not set this + _attr_unit_of_measurement: None = ( + None # Subclasses of SensorEntity should not set this + ) _last_reset_reported = False _temperature_conversion_reported = False @@ -240,11 +243,12 @@ class SensorEntity(Entity): @property def unit_of_measurement(self) -> str | None: """Return the unit of measurement of the entity, after unit conversion.""" + # Support for _attr_unit_of_measurement will be removed in Home Assistant 2021.11 if ( hasattr(self, "_attr_unit_of_measurement") and self._attr_unit_of_measurement is not None ): - return self._attr_unit_of_measurement + return self._attr_unit_of_measurement # type: ignore native_unit_of_measurement = self.native_unit_of_measurement From 35ccad7904afcdc961793edb5afae52442530d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20Milanovi=C4=87?= Date: Wed, 25 Aug 2021 23:57:07 +1000 Subject: [PATCH 786/903] Ignore unsupported MeasureType-s from Withings (#55205) --- homeassistant/components/withings/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 646243e309d..b70b8b5ca1a 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -792,6 +792,7 @@ class DataManager: ) for group in groups for measure in group.measures + if measure.type in WITHINGS_MEASURE_TYPE_MAP } async def async_get_sleep_summary(self) -> dict[MeasureType, Any]: From 20d8c4da90379e6912f9e501d91833d059318eb0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Aug 2021 16:12:29 +0200 Subject: [PATCH 787/903] Report average of position and tilt_position for cover groups (#52713) --- homeassistant/components/group/cover.py | 59 +++++++----------- homeassistant/components/group/light.py | 83 +++++++------------------ homeassistant/components/group/util.py | 57 +++++++++++++++++ tests/components/group/test_cover.py | 43 +++++++++++-- 4 files changed, 140 insertions(+), 102 deletions(-) create mode 100644 homeassistant/components/group/util.py diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 397c7e609f3..3870ad3cca5 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -48,6 +48,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType from . import GroupEntity +from .util import attribute_equal, reduce_attribute KEY_OPEN_CLOSE = "open_close" KEY_STOP = "stop" @@ -266,49 +267,33 @@ class CoverGroup(GroupEntity, CoverEntity): continue if state.state == STATE_OPEN: self._attr_is_closed = False - break + continue if state.state == STATE_CLOSING: self._attr_is_closing = True - break + continue if state.state == STATE_OPENING: self._attr_is_opening = True - break + continue - self._attr_current_cover_position = None - if self._covers[KEY_POSITION]: - position: int | None = -1 - self._attr_current_cover_position = 0 if self.is_closed else 100 - for entity_id in self._covers[KEY_POSITION]: - state = self.hass.states.get(entity_id) - if state is None: - continue - pos = state.attributes.get(ATTR_CURRENT_POSITION) - if position == -1: - position = pos - elif position != pos: - self._attr_assumed_state = True - break - else: - if position != -1: - self._attr_current_cover_position = position + position_covers = self._covers[KEY_POSITION] + all_position_states = [self.hass.states.get(x) for x in position_covers] + position_states: list[State] = list(filter(None, all_position_states)) + self._attr_current_cover_position = reduce_attribute( + position_states, ATTR_CURRENT_POSITION + ) + self._attr_assumed_state |= not attribute_equal( + position_states, ATTR_CURRENT_POSITION + ) - self._attr_current_cover_tilt_position = None - if self._tilts[KEY_POSITION]: - position = -1 - self._attr_current_cover_tilt_position = 100 - for entity_id in self._tilts[KEY_POSITION]: - state = self.hass.states.get(entity_id) - if state is None: - continue - pos = state.attributes.get(ATTR_CURRENT_TILT_POSITION) - if position == -1: - position = pos - elif position != pos: - self._attr_assumed_state = True - break - else: - if position != -1: - self._attr_current_cover_tilt_position = position + tilt_covers = self._tilts[KEY_POSITION] + all_tilt_states = [self.hass.states.get(x) for x in tilt_covers] + tilt_states: list[State] = list(filter(None, all_tilt_states)) + self._attr_current_cover_tilt_position = reduce_attribute( + tilt_states, ATTR_CURRENT_TILT_POSITION + ) + self._attr_assumed_state |= not attribute_equal( + tilt_states, ATTR_CURRENT_TILT_POSITION + ) supported_features = 0 supported_features |= ( diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 96cad7a4914..a3a02ee6b9c 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -2,9 +2,8 @@ from __future__ import annotations from collections import Counter -from collections.abc import Iterator import itertools -from typing import Any, Callable, Set, cast +from typing import Any, Set, cast import voluptuous as vol @@ -51,6 +50,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType from . import GroupEntity +from .util import find_state_attributes, mean_tuple, reduce_attribute DEFAULT_NAME = "Light Group" @@ -183,36 +183,36 @@ class LightGroup(GroupEntity, light.LightEntity): self._attr_is_on = len(on_states) > 0 self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) - self._attr_brightness = _reduce_attribute(on_states, ATTR_BRIGHTNESS) + self._attr_brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS) - self._attr_hs_color = _reduce_attribute( - on_states, ATTR_HS_COLOR, reduce=_mean_tuple + self._attr_hs_color = reduce_attribute( + on_states, ATTR_HS_COLOR, reduce=mean_tuple ) - self._attr_rgb_color = _reduce_attribute( - on_states, ATTR_RGB_COLOR, reduce=_mean_tuple + self._attr_rgb_color = reduce_attribute( + on_states, ATTR_RGB_COLOR, reduce=mean_tuple ) - self._attr_rgbw_color = _reduce_attribute( - on_states, ATTR_RGBW_COLOR, reduce=_mean_tuple + self._attr_rgbw_color = reduce_attribute( + on_states, ATTR_RGBW_COLOR, reduce=mean_tuple ) - self._attr_rgbww_color = _reduce_attribute( - on_states, ATTR_RGBWW_COLOR, reduce=_mean_tuple + self._attr_rgbww_color = reduce_attribute( + on_states, ATTR_RGBWW_COLOR, reduce=mean_tuple ) - self._attr_xy_color = _reduce_attribute( - on_states, ATTR_XY_COLOR, reduce=_mean_tuple + self._attr_xy_color = reduce_attribute( + on_states, ATTR_XY_COLOR, reduce=mean_tuple ) - self._white_value = _reduce_attribute(on_states, ATTR_WHITE_VALUE) + self._white_value = reduce_attribute(on_states, ATTR_WHITE_VALUE) - self._attr_color_temp = _reduce_attribute(on_states, ATTR_COLOR_TEMP) - self._attr_min_mireds = _reduce_attribute( + self._attr_color_temp = reduce_attribute(on_states, ATTR_COLOR_TEMP) + self._attr_min_mireds = reduce_attribute( states, ATTR_MIN_MIREDS, default=154, reduce=min ) - self._attr_max_mireds = _reduce_attribute( + self._attr_max_mireds = reduce_attribute( states, ATTR_MAX_MIREDS, default=500, reduce=max ) self._attr_effect_list = None - all_effect_lists = list(_find_state_attributes(states, ATTR_EFFECT_LIST)) + all_effect_lists = list(find_state_attributes(states, ATTR_EFFECT_LIST)) if all_effect_lists: # Merge all effects from all effect_lists with a union merge. self._attr_effect_list = list(set().union(*all_effect_lists)) @@ -222,14 +222,14 @@ class LightGroup(GroupEntity, light.LightEntity): self._attr_effect_list.insert(0, "None") self._attr_effect = None - all_effects = list(_find_state_attributes(on_states, ATTR_EFFECT)) + all_effects = list(find_state_attributes(on_states, ATTR_EFFECT)) if all_effects: # Report the most common effect. effects_count = Counter(itertools.chain(all_effects)) self._attr_effect = effects_count.most_common(1)[0][0] self._attr_color_mode = None - all_color_modes = list(_find_state_attributes(on_states, ATTR_COLOR_MODE)) + all_color_modes = list(find_state_attributes(on_states, ATTR_COLOR_MODE)) if all_color_modes: # Report the most common color mode, select brightness and onoff last color_mode_count = Counter(itertools.chain(all_color_modes)) @@ -241,7 +241,7 @@ class LightGroup(GroupEntity, light.LightEntity): self._attr_supported_color_modes = None all_supported_color_modes = list( - _find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) + find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) ) if all_supported_color_modes: # Merge all color modes. @@ -250,49 +250,10 @@ class LightGroup(GroupEntity, light.LightEntity): ) self._attr_supported_features = 0 - for support in _find_state_attributes(states, ATTR_SUPPORTED_FEATURES): + for support in find_state_attributes(states, ATTR_SUPPORTED_FEATURES): # Merge supported features by emulating support for every feature # we find. self._attr_supported_features |= support # Bitwise-and the supported features with the GroupedLight's features # so that we don't break in the future when a new feature is added. self._attr_supported_features &= SUPPORT_GROUP_LIGHT - - -def _find_state_attributes(states: list[State], key: str) -> Iterator[Any]: - """Find attributes with matching key from states.""" - for state in states: - value = state.attributes.get(key) - if value is not None: - yield value - - -def _mean_int(*args: Any) -> int: - """Return the mean of the supplied values.""" - return int(sum(args) / len(args)) - - -def _mean_tuple(*args: Any) -> tuple[float | Any, ...]: - """Return the mean values along the columns of the supplied values.""" - return tuple(sum(x) / len(x) for x in zip(*args)) - - -def _reduce_attribute( - states: list[State], - key: str, - default: Any | None = None, - reduce: Callable[..., Any] = _mean_int, -) -> Any: - """Find the first attribute matching key from states. - - If none are found, return default. - """ - attrs = list(_find_state_attributes(states, key)) - - if not attrs: - return default - - if len(attrs) == 1: - return attrs[0] - - return reduce(*attrs) diff --git a/homeassistant/components/group/util.py b/homeassistant/components/group/util.py new file mode 100644 index 00000000000..7e284691049 --- /dev/null +++ b/homeassistant/components/group/util.py @@ -0,0 +1,57 @@ +"""Utility functions to combine state attributes from multiple entities.""" +from __future__ import annotations + +from collections.abc import Iterator +from itertools import groupby +from typing import Any, Callable + +from homeassistant.core import State + + +def find_state_attributes(states: list[State], key: str) -> Iterator[Any]: + """Find attributes with matching key from states.""" + for state in states: + value = state.attributes.get(key) + if value is not None: + yield value + + +def mean_int(*args: Any) -> int: + """Return the mean of the supplied values.""" + return int(sum(args) / len(args)) + + +def mean_tuple(*args: Any) -> tuple[float | Any, ...]: + """Return the mean values along the columns of the supplied values.""" + return tuple(sum(x) / len(x) for x in zip(*args)) + + +def attribute_equal(states: list[State], key: str) -> bool: + """Return True if all attributes found matching key from states are equal. + + Note: Returns True if no matching attribute is found. + """ + attrs = find_state_attributes(states, key) + grp = groupby(attrs) + return bool(next(grp, True) and not next(grp, False)) + + +def reduce_attribute( + states: list[State], + key: str, + default: Any | None = None, + reduce: Callable[..., Any] = mean_int, +) -> Any: + """Find the first attribute matching key from states. + + If none are found, return default. + """ + attrs = list(find_state_attributes(states, key)) + + if not attrs: + return default + + if len(attrs) == 1: + return attrs[0] + + return reduce(*attrs) diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 8a29274298b..758bc5e0dac 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -177,7 +177,7 @@ async def test_attributes(hass, setup_comp): assert state.state == STATE_OPEN assert state.attributes[ATTR_ASSUMED_STATE] is True assert state.attributes[ATTR_SUPPORTED_FEATURES] == 244 - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + assert state.attributes[ATTR_CURRENT_POSITION] == 85 # (70 + 100) / 2 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 hass.states.async_remove(DEMO_COVER) @@ -204,7 +204,7 @@ async def test_attributes(hass, setup_comp): assert state.attributes[ATTR_ASSUMED_STATE] is True assert state.attributes[ATTR_SUPPORTED_FEATURES] == 128 assert ATTR_CURRENT_POSITION not in state.attributes - assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 80 # (60 + 100) / 2 hass.states.async_remove(DEMO_COVER_TILT) hass.states.async_set(DEMO_TILT, STATE_CLOSED) @@ -367,8 +367,8 @@ async def test_stop_covers(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + assert state.state == STATE_OPENING + assert state.attributes[ATTR_CURRENT_POSITION] == 50 # (20 + 80) / 2 assert hass.states.get(DEMO_COVER).state == STATE_OPEN assert hass.states.get(DEMO_COVER_POS).attributes[ATTR_CURRENT_POSITION] == 20 @@ -542,6 +542,7 @@ async def test_is_opening_closing(hass, setup_comp): ) await hass.async_block_till_done() + # Both covers opening -> opening assert hass.states.get(DEMO_COVER_POS).state == STATE_OPENING assert hass.states.get(DEMO_COVER_TILT).state == STATE_OPENING assert hass.states.get(COVER_GROUP).state == STATE_OPENING @@ -555,6 +556,7 @@ async def test_is_opening_closing(hass, setup_comp): DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) + # Both covers closing -> closing assert hass.states.get(DEMO_COVER_POS).state == STATE_CLOSING assert hass.states.get(DEMO_COVER_TILT).state == STATE_CLOSING assert hass.states.get(COVER_GROUP).state == STATE_CLOSING @@ -562,11 +564,44 @@ async def test_is_opening_closing(hass, setup_comp): hass.states.async_set(DEMO_COVER_POS, STATE_OPENING, {ATTR_SUPPORTED_FEATURES: 11}) await hass.async_block_till_done() + # Closing + Opening -> Opening + assert hass.states.get(DEMO_COVER_TILT).state == STATE_CLOSING assert hass.states.get(DEMO_COVER_POS).state == STATE_OPENING assert hass.states.get(COVER_GROUP).state == STATE_OPENING hass.states.async_set(DEMO_COVER_POS, STATE_CLOSING, {ATTR_SUPPORTED_FEATURES: 11}) await hass.async_block_till_done() + # Both covers closing -> closing + assert hass.states.get(DEMO_COVER_TILT).state == STATE_CLOSING assert hass.states.get(DEMO_COVER_POS).state == STATE_CLOSING assert hass.states.get(COVER_GROUP).state == STATE_CLOSING + + # Closed + Closing -> Closing + hass.states.async_set(DEMO_COVER_POS, STATE_CLOSED, {ATTR_SUPPORTED_FEATURES: 11}) + await hass.async_block_till_done() + assert hass.states.get(DEMO_COVER_TILT).state == STATE_CLOSING + assert hass.states.get(DEMO_COVER_POS).state == STATE_CLOSED + assert hass.states.get(COVER_GROUP).state == STATE_CLOSING + + # Open + Closing -> Closing + hass.states.async_set(DEMO_COVER_POS, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 11}) + await hass.async_block_till_done() + assert hass.states.get(DEMO_COVER_TILT).state == STATE_CLOSING + assert hass.states.get(DEMO_COVER_POS).state == STATE_OPEN + assert hass.states.get(COVER_GROUP).state == STATE_CLOSING + + # Closed + Opening -> Closing + hass.states.async_set(DEMO_COVER_TILT, STATE_OPENING, {ATTR_SUPPORTED_FEATURES: 11}) + hass.states.async_set(DEMO_COVER_POS, STATE_CLOSED, {ATTR_SUPPORTED_FEATURES: 11}) + await hass.async_block_till_done() + assert hass.states.get(DEMO_COVER_TILT).state == STATE_OPENING + assert hass.states.get(DEMO_COVER_POS).state == STATE_CLOSED + assert hass.states.get(COVER_GROUP).state == STATE_OPENING + + # Open + Opening -> Closing + hass.states.async_set(DEMO_COVER_POS, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 11}) + await hass.async_block_till_done() + assert hass.states.get(DEMO_COVER_TILT).state == STATE_OPENING + assert hass.states.get(DEMO_COVER_POS).state == STATE_OPEN + assert hass.states.get(COVER_GROUP).state == STATE_OPENING From 53851cb1b4f31bfc8b66244657d9c8cfca7a1534 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 16:32:35 +0200 Subject: [PATCH 788/903] Remove temperature conversion - synology_dsm (#55214) --- homeassistant/components/synology_dsm/const.py | 17 +++++------------ homeassistant/components/synology_dsm/sensor.py | 14 -------------- 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 1c1f94c5d60..1fc6ba6e09b 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -26,6 +26,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, PERCENTAGE, + TEMP_CELSIUS, ) @@ -287,7 +288,7 @@ STORAGE_VOL_SENSORS: dict[str, EntityInfo] = { }, f"{SynoStorage.API_KEY}:volume_disk_temp_avg": { ATTR_NAME: "Average Disk Temp", - ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, ATTR_ICON: None, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: True, @@ -295,7 +296,7 @@ STORAGE_VOL_SENSORS: dict[str, EntityInfo] = { }, f"{SynoStorage.API_KEY}:volume_disk_temp_max": { ATTR_NAME: "Maximum Disk Temp", - ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, ATTR_ICON: None, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: False, @@ -321,7 +322,7 @@ STORAGE_DISK_SENSORS: dict[str, EntityInfo] = { }, f"{SynoStorage.API_KEY}:disk_temp": { ATTR_NAME: "Temperature", - ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, ATTR_ICON: None, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: True, @@ -332,7 +333,7 @@ STORAGE_DISK_SENSORS: dict[str, EntityInfo] = { INFORMATION_SENSORS: dict[str, EntityInfo] = { f"{SynoDSMInformation.API_KEY}:temperature": { ATTR_NAME: "temperature", - ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, ATTR_ICON: None, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: True, @@ -359,11 +360,3 @@ SURVEILLANCE_SWITCH: dict[str, EntityInfo] = { ATTR_STATE_CLASS: None, }, } - - -TEMP_SENSORS_KEYS = [ - "volume_disk_temp_avg", - "volume_disk_temp_max", - "disk_temp", - "temperature", -] diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 1ddc79c0afc..72ddb944b11 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -11,12 +11,9 @@ from homeassistant.const import ( DATA_MEGABYTES, DATA_RATE_KILOBYTES_PER_SECOND, DATA_TERABYTES, - PRECISION_TENTHS, - TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.temperature import display_temp from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import utcnow @@ -30,7 +27,6 @@ from .const import ( STORAGE_DISK_SENSORS, STORAGE_VOL_SENSORS, SYNO_API, - TEMP_SENSORS_KEYS, UTILISATION_SENSORS, EntityInfo, ) @@ -92,8 +88,6 @@ class SynoDSMSensor(SynologyDSMBaseEntity): @property def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" - if self.entity_type in TEMP_SENSORS_KEYS: - return self.hass.config.units.temperature_unit return self._unit @@ -143,10 +137,6 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor, SensorEntity) if self._unit == DATA_TERABYTES: return round(attr / 1024.0 ** 4, 2) - # Temperature - if self.entity_type in TEMP_SENSORS_KEYS: - return display_temp(self.hass, attr, TEMP_CELSIUS, PRECISION_TENTHS) - return attr @@ -172,10 +162,6 @@ class SynoDSMInfoSensor(SynoDSMSensor, SensorEntity): if attr is None: return None - # Temperature - if self.entity_type in TEMP_SENSORS_KEYS: - return display_temp(self.hass, attr, TEMP_CELSIUS, PRECISION_TENTHS) - if self.entity_type == "uptime": # reboot happened or entity creation if self._previous_uptime is None or self._previous_uptime > attr: From 6bc5c1c9af0160ccaf57fe084b522267f9dd022e Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 25 Aug 2021 08:36:25 -0600 Subject: [PATCH 789/903] Finish EntityDescription implementation for RainMachine (#55180) --- .coveragerc | 1 + .../components/rainmachine/__init__.py | 7 +- .../components/rainmachine/binary_sensor.py | 161 +++++++++--------- homeassistant/components/rainmachine/model.py | 9 + .../components/rainmachine/sensor.py | 62 ++----- .../components/rainmachine/switch.py | 96 +++++++---- 6 files changed, 178 insertions(+), 158 deletions(-) create mode 100644 homeassistant/components/rainmachine/model.py diff --git a/.coveragerc b/.coveragerc index bb3140d7969..e11a268217b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -845,6 +845,7 @@ omit = homeassistant/components/raincloud/* homeassistant/components/rainmachine/__init__.py homeassistant/components/rainmachine/binary_sensor.py + homeassistant/components/rainmachine/model.py homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/switch.py homeassistant/components/raspihats/* diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 8d3f9444f08..fac929e7e99 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -180,7 +181,7 @@ class RainMachineEntity(CoordinatorEntity): self, coordinator: DataUpdateCoordinator, controller: Controller, - entity_type: str, + description: EntityDescription, ) -> None: """Initialize.""" super().__init__(coordinator) @@ -200,9 +201,9 @@ class RainMachineEntity(CoordinatorEntity): # The colons are removed from the device MAC simply because that value # (unnecessarily) makes up the existing unique ID formula and we want to avoid # a breaking change: - self._attr_unique_id = f"{controller.mac.replace(':', '')}_{entity_type}" + self._attr_unique_id = f"{controller.mac.replace(':', '')}_{description.key}" self._controller = controller - self._entity_type = entity_type + self.entity_description = description @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index b666d9ed150..7e886dbad90 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -1,13 +1,14 @@ """This platform provides binary sensors for key RainMachine data.""" +from dataclasses import dataclass from functools import partial -from regenmaschine.controller import Controller - -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import RainMachineEntity from .const import ( @@ -18,6 +19,7 @@ from .const import ( DATA_RESTRICTIONS_UNIVERSAL, DOMAIN, ) +from .model import RainMachineSensorDescriptionMixin TYPE_FLOW_SENSOR = "flow_sensor" TYPE_FREEZE = "freeze" @@ -29,47 +31,75 @@ TYPE_RAINDELAY = "raindelay" TYPE_RAINSENSOR = "rainsensor" TYPE_WEEKDAY = "weekday" -BINARY_SENSORS = { - TYPE_FLOW_SENSOR: ("Flow Sensor", "mdi:water-pump", True, DATA_PROVISION_SETTINGS), - TYPE_FREEZE: ("Freeze Restrictions", "mdi:cancel", True, DATA_RESTRICTIONS_CURRENT), - TYPE_FREEZE_PROTECTION: ( - "Freeze Protection", - "mdi:weather-snowy", - True, - DATA_RESTRICTIONS_UNIVERSAL, + +@dataclass +class RainMachineBinarySensorDescription( + BinarySensorEntityDescription, RainMachineSensorDescriptionMixin +): + """Describe a RainMachine binary sensor.""" + + +BINARY_SENSOR_DESCRIPTIONS = ( + RainMachineBinarySensorDescription( + key=TYPE_FLOW_SENSOR, + name="Flow Sensor", + icon="mdi:water-pump", + api_category=DATA_PROVISION_SETTINGS, ), - TYPE_HOT_DAYS: ( - "Extra Water on Hot Days", - "mdi:thermometer-lines", - True, - DATA_RESTRICTIONS_UNIVERSAL, + RainMachineBinarySensorDescription( + key=TYPE_FREEZE, + name="Freeze Restrictions", + icon="mdi:cancel", + api_category=DATA_RESTRICTIONS_CURRENT, ), - TYPE_HOURLY: ( - "Hourly Restrictions", - "mdi:cancel", - False, - DATA_RESTRICTIONS_CURRENT, + RainMachineBinarySensorDescription( + key=TYPE_FREEZE_PROTECTION, + name="Freeze Protection", + icon="mdi:weather-snowy", + api_category=DATA_RESTRICTIONS_UNIVERSAL, ), - TYPE_MONTH: ("Month Restrictions", "mdi:cancel", False, DATA_RESTRICTIONS_CURRENT), - TYPE_RAINDELAY: ( - "Rain Delay Restrictions", - "mdi:cancel", - False, - DATA_RESTRICTIONS_CURRENT, + RainMachineBinarySensorDescription( + key=TYPE_HOT_DAYS, + name="Extra Water on Hot Days", + icon="mdi:thermometer-lines", + api_category=DATA_RESTRICTIONS_UNIVERSAL, ), - TYPE_RAINSENSOR: ( - "Rain Sensor Restrictions", - "mdi:cancel", - False, - DATA_RESTRICTIONS_CURRENT, + RainMachineBinarySensorDescription( + key=TYPE_HOURLY, + name="Hourly Restrictions", + icon="mdi:cancel", + entity_registry_enabled_default=False, + api_category=DATA_RESTRICTIONS_CURRENT, ), - TYPE_WEEKDAY: ( - "Weekday Restrictions", - "mdi:cancel", - False, - DATA_RESTRICTIONS_CURRENT, + RainMachineBinarySensorDescription( + key=TYPE_MONTH, + name="Month Restrictions", + icon="mdi:cancel", + entity_registry_enabled_default=False, + api_category=DATA_RESTRICTIONS_CURRENT, ), -} + RainMachineBinarySensorDescription( + key=TYPE_RAINDELAY, + name="Rain Delay Restrictions", + icon="mdi:cancel", + entity_registry_enabled_default=False, + api_category=DATA_RESTRICTIONS_CURRENT, + ), + RainMachineBinarySensorDescription( + key=TYPE_RAINSENSOR, + name="Rain Sensor Restrictions", + icon="mdi:cancel", + entity_registry_enabled_default=False, + api_category=DATA_RESTRICTIONS_CURRENT, + ), + RainMachineBinarySensorDescription( + key=TYPE_WEEKDAY, + name="Weekday Restrictions", + icon="mdi:cancel", + entity_registry_enabled_default=False, + api_category=DATA_RESTRICTIONS_CURRENT, + ), +) async def async_setup_entry( @@ -101,74 +131,49 @@ async def async_setup_entry( async_add_entities( [ - async_get_sensor(api_category)( - controller, sensor_type, name, icon, enabled_by_default - ) - for ( - sensor_type, - (name, icon, enabled_by_default, api_category), - ) in BINARY_SENSORS.items() + async_get_sensor(description.api_category)(controller, description) + for description in BINARY_SENSOR_DESCRIPTIONS ] ) -class RainMachineBinarySensor(RainMachineEntity, BinarySensorEntity): - """Define a general RainMachine binary sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - controller: Controller, - sensor_type: str, - name: str, - icon: str, - enabled_by_default: bool, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, controller, sensor_type) - - self._attr_entity_registry_enabled_default = enabled_by_default - self._attr_icon = icon - self._attr_name = name - - -class CurrentRestrictionsBinarySensor(RainMachineBinarySensor): +class CurrentRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity): """Define a binary sensor that handles current restrictions data.""" @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._entity_type == TYPE_FREEZE: + if self.entity_description.key == TYPE_FREEZE: self._attr_is_on = self.coordinator.data["freeze"] - elif self._entity_type == TYPE_HOURLY: + elif self.entity_description.key == TYPE_HOURLY: self._attr_is_on = self.coordinator.data["hourly"] - elif self._entity_type == TYPE_MONTH: + elif self.entity_description.key == TYPE_MONTH: self._attr_is_on = self.coordinator.data["month"] - elif self._entity_type == TYPE_RAINDELAY: + elif self.entity_description.key == TYPE_RAINDELAY: self._attr_is_on = self.coordinator.data["rainDelay"] - elif self._entity_type == TYPE_RAINSENSOR: + elif self.entity_description.key == TYPE_RAINSENSOR: self._attr_is_on = self.coordinator.data["rainSensor"] - elif self._entity_type == TYPE_WEEKDAY: + elif self.entity_description.key == TYPE_WEEKDAY: self._attr_is_on = self.coordinator.data["weekDay"] -class ProvisionSettingsBinarySensor(RainMachineBinarySensor): +class ProvisionSettingsBinarySensor(RainMachineEntity, BinarySensorEntity): """Define a binary sensor that handles provisioning data.""" @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._entity_type == TYPE_FLOW_SENSOR: + if self.entity_description.key == TYPE_FLOW_SENSOR: self._attr_is_on = self.coordinator.data["system"].get("useFlowSensor") -class UniversalRestrictionsBinarySensor(RainMachineBinarySensor): +class UniversalRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity): """Define a binary sensor that handles universal restrictions data.""" @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._entity_type == TYPE_FREEZE_PROTECTION: + if self.entity_description.key == TYPE_FREEZE_PROTECTION: self._attr_is_on = self.coordinator.data["freezeProtectEnabled"] - elif self._entity_type == TYPE_HOT_DAYS: + elif self.entity_description.key == TYPE_HOT_DAYS: self._attr_is_on = self.coordinator.data["hotDaysExtraWatering"] diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py new file mode 100644 index 00000000000..cd66c05025b --- /dev/null +++ b/homeassistant/components/rainmachine/model.py @@ -0,0 +1,9 @@ +"""Define RainMachine data models.""" +from dataclasses import dataclass + + +@dataclass +class RainMachineSensorDescriptionMixin: + """Define an entity description mixin for binary and regular sensors.""" + + api_category: str diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 269bd6bcd4b..f990dd5c672 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -4,8 +4,6 @@ from __future__ import annotations from dataclasses import dataclass from functools import partial -from regenmaschine.controller import Controller - from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -15,7 +13,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import RainMachineEntity from .const import ( @@ -25,6 +22,7 @@ from .const import ( DATA_RESTRICTIONS_UNIVERSAL, DOMAIN, ) +from .model import RainMachineSensorDescriptionMixin TYPE_FLOW_SENSOR_CLICK_M3 = "flow_sensor_clicks_cubic_meter" TYPE_FLOW_SENSOR_CONSUMED_LITERS = "flow_sensor_consumed_liters" @@ -34,21 +32,14 @@ TYPE_FREEZE_TEMP = "freeze_protect_temp" @dataclass -class RainmachineRequiredKeysMixin: - """Mixin for required keys.""" - - api_category: str - - -@dataclass -class RainmachineSensorEntityDescription( - SensorEntityDescription, RainmachineRequiredKeysMixin +class RainMachineSensorEntityDescription( + SensorEntityDescription, RainMachineSensorDescriptionMixin ): - """Describes Rainmachine sensor entity.""" + """Describe a RainMachine sensor.""" -SENSOR_TYPES: tuple[RainmachineSensorEntityDescription, ...] = ( - RainmachineSensorEntityDescription( +SENSOR_DESCRIPTIONS = ( + RainMachineSensorEntityDescription( key=TYPE_FLOW_SENSOR_CLICK_M3, name="Flow Sensor Clicks", icon="mdi:water-pump", @@ -56,7 +47,7 @@ SENSOR_TYPES: tuple[RainmachineSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, api_category=DATA_PROVISION_SETTINGS, ), - RainmachineSensorEntityDescription( + RainMachineSensorEntityDescription( key=TYPE_FLOW_SENSOR_CONSUMED_LITERS, name="Flow Sensor Consumed Liters", icon="mdi:water-pump", @@ -64,7 +55,7 @@ SENSOR_TYPES: tuple[RainmachineSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, api_category=DATA_PROVISION_SETTINGS, ), - RainmachineSensorEntityDescription( + RainMachineSensorEntityDescription( key=TYPE_FLOW_SENSOR_START_INDEX, name="Flow Sensor Start Index", icon="mdi:water-pump", @@ -72,7 +63,7 @@ SENSOR_TYPES: tuple[RainmachineSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, api_category=DATA_PROVISION_SETTINGS, ), - RainmachineSensorEntityDescription( + RainMachineSensorEntityDescription( key=TYPE_FLOW_SENSOR_WATERING_CLICKS, name="Flow Sensor Clicks", icon="mdi:water-pump", @@ -80,13 +71,12 @@ SENSOR_TYPES: tuple[RainmachineSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, api_category=DATA_PROVISION_SETTINGS, ), - RainmachineSensorEntityDescription( + RainMachineSensorEntityDescription( key=TYPE_FREEZE_TEMP, name="Freeze Protect Temperature", icon="mdi:thermometer", native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, - entity_registry_enabled_default=True, api_category=DATA_RESTRICTIONS_UNIVERSAL, ), ) @@ -116,38 +106,22 @@ async def async_setup_entry( async_add_entities( [ async_get_sensor(description.api_category)(controller, description) - for description in SENSOR_TYPES + for description in SENSOR_DESCRIPTIONS ] ) -class RainMachineSensor(RainMachineEntity, SensorEntity): - """Define a general RainMachine sensor.""" - - entity_description: RainmachineSensorEntityDescription - - def __init__( - self, - coordinator: DataUpdateCoordinator, - controller: Controller, - description: RainmachineSensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator, controller, description.key) - self.entity_description = description - - -class ProvisionSettingsSensor(RainMachineSensor): +class ProvisionSettingsSensor(RainMachineEntity, SensorEntity): """Define a sensor that handles provisioning data.""" @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._entity_type == TYPE_FLOW_SENSOR_CLICK_M3: + if self.entity_description.key == TYPE_FLOW_SENSOR_CLICK_M3: self._attr_native_value = self.coordinator.data["system"].get( "flowSensorClicksPerCubicMeter" ) - elif self._entity_type == TYPE_FLOW_SENSOR_CONSUMED_LITERS: + elif self.entity_description.key == TYPE_FLOW_SENSOR_CONSUMED_LITERS: clicks = self.coordinator.data["system"].get("flowSensorWateringClicks") clicks_per_m3 = self.coordinator.data["system"].get( "flowSensorClicksPerCubicMeter" @@ -157,21 +131,21 @@ class ProvisionSettingsSensor(RainMachineSensor): self._attr_native_value = (clicks * 1000) / clicks_per_m3 else: self._attr_native_value = None - elif self._entity_type == TYPE_FLOW_SENSOR_START_INDEX: + elif self.entity_description.key == TYPE_FLOW_SENSOR_START_INDEX: self._attr_native_value = self.coordinator.data["system"].get( "flowSensorStartIndex" ) - elif self._entity_type == TYPE_FLOW_SENSOR_WATERING_CLICKS: + elif self.entity_description.key == TYPE_FLOW_SENSOR_WATERING_CLICKS: self._attr_native_value = self.coordinator.data["system"].get( "flowSensorWateringClicks" ) -class UniversalRestrictionsSensor(RainMachineSensor): +class UniversalRestrictionsSensor(RainMachineEntity, SensorEntity): """Define a sensor that handles universal restrictions data.""" @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._entity_type == TYPE_FREEZE_TEMP: + if self.entity_description.key == TYPE_FREEZE_TEMP: self._attr_native_value = self.coordinator.data["freezeProtectTemp"] diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 361a737218d..a4d4bce2383 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Coroutine +from dataclasses import dataclass from datetime import datetime from typing import Any @@ -9,7 +10,7 @@ from regenmaschine.controller import Controller from regenmaschine.errors import RequestError import voluptuous as vol -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID from homeassistant.core import HomeAssistant, callback @@ -115,6 +116,20 @@ SWITCH_TYPE_PROGRAM = "program" SWITCH_TYPE_ZONE = "zone" +@dataclass +class RainMachineSwitchDescriptionMixin: + """Define an entity description mixin for switches.""" + + uid: int + + +@dataclass +class RainMachineSwitchDescription( + SwitchEntityDescription, RainMachineSwitchDescriptionMixin +): + """Describe a RainMachine switch.""" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -166,18 +181,34 @@ async def async_setup_entry( ] zones_coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][DATA_ZONES] - entities: list[RainMachineProgram | RainMachineZone] = [] - - for uid, program in programs_coordinator.data.items(): - entities.append( - RainMachineProgram( - programs_coordinator, controller, uid, program["name"], entry + entities: list[RainMachineProgram | RainMachineZone] = [ + RainMachineProgram( + programs_coordinator, + controller, + entry, + RainMachineSwitchDescription( + key=f"RainMachineProgram_{uid}", + name=program["name"], + uid=uid, + ), + ) + for uid, program in programs_coordinator.data.items() + ] + entities.extend( + [ + RainMachineZone( + zones_coordinator, + controller, + entry, + RainMachineSwitchDescription( + key=f"RainMachineZone_{uid}", + name=zone["name"], + uid=uid, + ), ) - ) - for uid, zone in zones_coordinator.data.items(): - entities.append( - RainMachineZone(zones_coordinator, controller, uid, zone["name"], entry) - ) + for uid, zone in zones_coordinator.data.items() + ] + ) async_add_entities(entities) @@ -186,35 +217,28 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): """A class to represent a generic RainMachine switch.""" _attr_icon = DEFAULT_ICON + entity_description: RainMachineSwitchDescription def __init__( self, coordinator: DataUpdateCoordinator, controller: Controller, - uid: int, - name: str, entry: ConfigEntry, + description: RainMachineSwitchDescription, ) -> None: """Initialize a generic RainMachine switch.""" - super().__init__(coordinator, controller, type(self).__name__) + super().__init__(coordinator, controller, description) self._attr_is_on = False - self._attr_name = name - self._data = coordinator.data[uid] + self._data = coordinator.data[self.entity_description.uid] self._entry = entry self._is_active = True - self._uid = uid @property def available(self) -> bool: """Return True if entity is available.""" return super().available and self._is_active - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{super().unique_id}_{self._uid}" - async def _async_run_switch_coroutine(self, api_coro: Coroutine) -> None: """Run a coroutine to toggle the switch.""" try: @@ -222,7 +246,7 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): except RequestError as err: LOGGER.error( 'Error while toggling %s "%s": %s', - self._entity_type, + self.entity_description.key, self.unique_id, err, ) @@ -231,7 +255,7 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): if resp["statusCode"] != 0: LOGGER.error( 'Error while toggling %s "%s": %s', - self._entity_type, + self.entity_description.key, self.unique_id, resp["message"], ) @@ -301,7 +325,7 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): @callback def update_from_latest_data(self) -> None: """Update the state.""" - self._data = self.coordinator.data[self._uid] + self._data = self.coordinator.data[self.entity_description.uid] self._is_active = self._data["active"] @@ -316,13 +340,13 @@ class RainMachineProgram(RainMachineSwitch): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the program off.""" await self._async_run_switch_coroutine( - self._controller.programs.stop(self._uid) + self._controller.programs.stop(self.entity_description.uid) ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the program on.""" await self._async_run_switch_coroutine( - self._controller.programs.start(self._uid) + self._controller.programs.start(self.entity_description.uid) ) @callback @@ -341,10 +365,14 @@ class RainMachineProgram(RainMachineSwitch): self._attr_extra_state_attributes.update( { - ATTR_ID: self._uid, + ATTR_ID: self.entity_description.uid, ATTR_NEXT_RUN: next_run, - ATTR_SOAK: self.coordinator.data[self._uid].get("soak"), - ATTR_STATUS: RUN_STATUS_MAP[self.coordinator.data[self._uid]["status"]], + ATTR_SOAK: self.coordinator.data[self.entity_description.uid].get( + "soak" + ), + ATTR_STATUS: RUN_STATUS_MAP[ + self.coordinator.data[self.entity_description.uid]["status"] + ], ATTR_ZONES: ", ".join(z["name"] for z in self.zones), } ) @@ -355,13 +383,15 @@ class RainMachineZone(RainMachineSwitch): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" - await self._async_run_switch_coroutine(self._controller.zones.stop(self._uid)) + await self._async_run_switch_coroutine( + self._controller.zones.stop(self.entity_description.uid) + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the zone on.""" await self._async_run_switch_coroutine( self._controller.zones.start( - self._uid, + self.entity_description.uid, self._entry.options[CONF_ZONE_RUN_TIME], ) ) From 72410044cd3c224b5a19582503a887fb8fcadfb1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Aug 2021 16:44:27 +0200 Subject: [PATCH 790/903] Remove temperature conversion - sht31 (#55213) --- homeassistant/components/sht31/sensor.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sht31/sensor.py b/homeassistant/components/sht31/sensor.py index 1b1e1427e51..e5f77700409 100644 --- a/homeassistant/components/sht31/sensor.py +++ b/homeassistant/components/sht31/sensor.py @@ -13,11 +13,9 @@ from homeassistant.const import ( CONF_NAME, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - PRECISION_TENTHS, TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.temperature import display_temp from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -121,20 +119,12 @@ class SHTSensorTemperature(SHTSensor): """Representation of a temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self.hass.config.units.temperature_unit + _attr_native_unit_of_measurement = TEMP_CELSIUS def update(self): """Fetch temperature from the sensor.""" super().update() - temp_celsius = self._sensor.temperature - if temp_celsius is not None: - self._state = display_temp( - self.hass, temp_celsius, TEMP_CELSIUS, PRECISION_TENTHS - ) + self._state = self._sensor.temperature class SHTSensorHumidity(SHTSensor): From bd0af57ef2c2ce4646556482b8adf9a4878285a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Aug 2021 09:47:39 -0500 Subject: [PATCH 791/903] Support device triggers in HomeKit (#53869) --- homeassistant/components/homekit/__init__.py | 107 +++++++++++--- .../components/homekit/accessories.py | 24 +++- .../components/homekit/aidmanager.py | 6 +- .../components/homekit/config_flow.py | 43 ++++-- homeassistant/components/homekit/const.py | 6 + homeassistant/components/homekit/strings.json | 3 +- .../components/homekit/translations/en.json | 5 +- .../components/homekit/type_triggers.py | 89 ++++++++++++ tests/components/homekit/conftest.py | 44 +++++- tests/components/homekit/test_config_flow.py | 135 ++++++++++++++++++ tests/components/homekit/test_homekit.py | 61 ++++++-- .../components/homekit/test_type_triggers.py | 57 ++++++++ 12 files changed, 522 insertions(+), 58 deletions(-) create mode 100644 homeassistant/components/homekit/type_triggers.py create mode 100644 tests/components/homekit/test_type_triggers.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 58f3ab14ca9..19298a9f814 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -8,7 +8,7 @@ from aiohttp import web from pyhap.const import STANDALONE_AID import voluptuous as vol -from homeassistant.components import network, zeroconf +from homeassistant.components import device_automation, network, zeroconf from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_MOTION, @@ -28,6 +28,7 @@ from homeassistant.const import ( ATTR_MANUFACTURER, ATTR_MODEL, ATTR_SW_VERSION, + CONF_DEVICES, CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, @@ -99,6 +100,7 @@ from .const import ( SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, ) +from .type_triggers import DeviceTriggerAccessory from .util import ( accessory_friendly_name, dismiss_setup_message, @@ -158,6 +160,7 @@ BRIDGE_SCHEMA = vol.All( vol.Optional(CONF_FILTER, default={}): BASE_FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, vol.Optional(CONF_ZEROCONF_DEFAULT_INTERFACE): cv.boolean, + vol.Optional(CONF_DEVICES): cv.ensure_list, }, extra=vol.ALLOW_EXTRA, ), @@ -237,8 +240,9 @@ def _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf): data = conf.copy() options = {} for key in CONFIG_OPTIONS: - options[key] = data[key] - del data[key] + if key in data: + options[key] = data[key] + del data[key] hass.config_entries.async_update_entry(entry, data=data, options=options) return True @@ -277,6 +281,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy() auto_start = options.get(CONF_AUTO_START, DEFAULT_AUTO_START) entity_filter = FILTER_SCHEMA(options.get(CONF_FILTER, {})) + devices = options.get(CONF_DEVICES, []) homekit = HomeKit( hass, @@ -290,6 +295,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: advertise_ip, entry.entry_id, entry.title, + devices=devices, ) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -492,6 +498,7 @@ class HomeKit: advertise_ip=None, entry_id=None, entry_title=None, + devices=None, ): """Initialize a HomeKit object.""" self.hass = hass @@ -505,6 +512,7 @@ class HomeKit: self._entry_id = entry_id self._entry_title = entry_title self._homekit_mode = homekit_mode + self._devices = devices or [] self.aid_storage = None self.status = STATUS_READY @@ -594,13 +602,7 @@ class HomeKit: def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" - # The bridge itself counts as an accessory - if len(self.bridge.accessories) + 1 >= MAX_DEVICES: - _LOGGER.warning( - "Cannot add %s as this would exceed the %d device limit. Consider using the filter option", - state.entity_id, - MAX_DEVICES, - ) + if self._would_exceed_max_devices(state.entity_id): return if state_needs_accessory_mode(state): @@ -631,6 +633,42 @@ class HomeKit: ) return None + def _would_exceed_max_devices(self, name): + """Check if adding another devices would reach the limit and log.""" + # The bridge itself counts as an accessory + if len(self.bridge.accessories) + 1 >= MAX_DEVICES: + _LOGGER.warning( + "Cannot add %s as this would exceed the %d device limit. Consider using the filter option", + name, + MAX_DEVICES, + ) + return True + return False + + def add_bridge_triggers_accessory(self, device, device_triggers): + """Add device automation triggers to the bridge.""" + if self._would_exceed_max_devices(device.name): + return + + aid = self.aid_storage.get_or_allocate_aid(device.id, device.id) + # If an accessory cannot be created or added due to an exception + # of any kind (usually in pyhap) it should not prevent + # the rest of the accessories from being created + config = {} + self._fill_config_from_device_registry_entry(device, config) + self.bridge.add_accessory( + DeviceTriggerAccessory( + self.hass, + self.driver, + device.name, + None, + aid, + config, + device_id=device.id, + device_triggers=device_triggers, + ) + ) + def remove_bridge_accessory(self, aid): """Try adding accessory to bridge if configured beforehand.""" acc = self.bridge.accessories.pop(aid, None) @@ -778,12 +816,31 @@ class HomeKit: ) return acc - @callback - def _async_create_bridge_accessory(self, entity_states): + async def _async_create_bridge_accessory(self, entity_states): """Create a HomeKit bridge with accessories. (bridge mode).""" self.bridge = HomeBridge(self.hass, self.driver, self._name) for state in entity_states: self.add_bridge_accessory(state) + dev_reg = device_registry.async_get(self.hass) + if self._devices: + valid_device_ids = [] + for device_id in self._devices: + if not dev_reg.async_get(device_id): + _LOGGER.warning( + "HomeKit %s cannot add device %s because it is missing from the device registry", + self._name, + device_id, + ) + else: + valid_device_ids.append(device_id) + for device_id, device_triggers in ( + await device_automation.async_get_device_automations( + self.hass, "trigger", valid_device_ids + ) + ).items(): + self.add_bridge_triggers_accessory( + dev_reg.async_get(device_id), device_triggers + ) return self.bridge async def _async_create_accessories(self): @@ -792,7 +849,7 @@ class HomeKit: if self._homekit_mode == HOMEKIT_MODE_ACCESSORY: acc = self._async_create_single_accessory(entity_states) else: - acc = self._async_create_bridge_accessory(entity_states) + acc = await self._async_create_bridge_accessory(entity_states) if acc is None: return False @@ -875,15 +932,8 @@ class HomeKit: """Set attributes that will be used for homekit device info.""" ent_cfg = self._config.setdefault(entity_id, {}) if ent_reg_ent.device_id: - dev_reg_ent = dev_reg.async_get(ent_reg_ent.device_id) - if dev_reg_ent is not None: - # Handle missing devices - if dev_reg_ent.manufacturer: - ent_cfg[ATTR_MANUFACTURER] = dev_reg_ent.manufacturer - if dev_reg_ent.model: - ent_cfg[ATTR_MODEL] = dev_reg_ent.model - if dev_reg_ent.sw_version: - ent_cfg[ATTR_SW_VERSION] = dev_reg_ent.sw_version + if dev_reg_ent := dev_reg.async_get(ent_reg_ent.device_id): + self._fill_config_from_device_registry_entry(dev_reg_ent, ent_cfg) if ATTR_MANUFACTURER not in ent_cfg: try: integration = await async_get_integration( @@ -893,6 +943,19 @@ class HomeKit: except IntegrationNotFound: ent_cfg[ATTR_INTEGRATION] = ent_reg_ent.platform + def _fill_config_from_device_registry_entry(self, device_entry, config): + """Populate a config dict from the registry.""" + if device_entry.manufacturer: + config[ATTR_MANUFACTURER] = device_entry.manufacturer + if device_entry.model: + config[ATTR_MODEL] = device_entry.model + if device_entry.sw_version: + config[ATTR_SW_VERSION] = device_entry.sw_version + if device_entry.config_entries: + first_entry = list(device_entry.config_entries)[0] + if entry := self.hass.config_entries.async_get_entry(first_entry): + config[ATTR_INTEGRATION] = entry.domain + class HomeKitPairingQRView(HomeAssistantView): """Display the homekit pairing code at a protected url.""" diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 2cd63facf24..8298cdd9c83 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -224,6 +224,7 @@ class HomeAccessory(Accessory): config, *args, category=CATEGORY_OTHER, + device_id=None, **kwargs, ): """Initialize a Accessory object.""" @@ -231,18 +232,29 @@ class HomeAccessory(Accessory): driver=driver, display_name=name[:MAX_NAME_LENGTH], aid=aid, *args, **kwargs ) self.config = config or {} - domain = split_entity_id(entity_id)[0].replace("_", " ") + if device_id: + self.device_id = device_id + serial_number = device_id + domain = None + else: + self.device_id = None + serial_number = entity_id + domain = split_entity_id(entity_id)[0].replace("_", " ") if self.config.get(ATTR_MANUFACTURER) is not None: manufacturer = self.config[ATTR_MANUFACTURER] elif self.config.get(ATTR_INTEGRATION) is not None: manufacturer = self.config[ATTR_INTEGRATION].replace("_", " ").title() - else: + elif domain: manufacturer = f"{MANUFACTURER} {domain}".title() + else: + manufacturer = MANUFACTURER if self.config.get(ATTR_MODEL) is not None: model = self.config[ATTR_MODEL] - else: + elif domain: model = domain.title() + else: + model = MANUFACTURER sw_version = None if self.config.get(ATTR_SW_VERSION) is not None: sw_version = format_sw_version(self.config[ATTR_SW_VERSION]) @@ -252,7 +264,7 @@ class HomeAccessory(Accessory): self.set_info_service( manufacturer=manufacturer[:MAX_MANUFACTURER_LENGTH], model=model[:MAX_MODEL_LENGTH], - serial_number=entity_id[:MAX_SERIAL_LENGTH], + serial_number=serial_number[:MAX_SERIAL_LENGTH], firmware_revision=sw_version[:MAX_VERSION_LENGTH], ) @@ -260,6 +272,10 @@ class HomeAccessory(Accessory): self.entity_id = entity_id self.hass = hass self._subscriptions = [] + + if device_id: + return + self._char_battery = None self._char_charging = None self._char_low_battery = None diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 5af5559b2ef..ddf3c7c564e 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -94,12 +94,12 @@ class AccessoryAidStorage: """Generate a stable aid for an entity id.""" entity = self._entity_registry.async_get(entity_id) if not entity: - return self._get_or_allocate_aid(None, entity_id) + return self.get_or_allocate_aid(None, entity_id) sys_unique_id = get_system_unique_id(entity) - return self._get_or_allocate_aid(sys_unique_id, entity_id) + return self.get_or_allocate_aid(sys_unique_id, entity_id) - def _get_or_allocate_aid(self, unique_id: str, entity_id: str): + def get_or_allocate_aid(self, unique_id: str, entity_id: str): """Allocate (and return) a new aid for an accessory.""" if unique_id and unique_id in self.allocations: return self.allocations[unique_id] diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 1ec53079179..fdad10f873f 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -9,6 +9,7 @@ import string import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import device_automation from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN @@ -16,6 +17,7 @@ from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_FRIENDLY_NAME, + CONF_DEVICES, CONF_DOMAINS, CONF_ENTITIES, CONF_ENTITY_ID, @@ -23,6 +25,7 @@ from homeassistant.const import ( CONF_PORT, ) from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.helpers import device_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, @@ -318,20 +321,31 @@ class OptionsFlowHandler(config_entries.OptionsFlow): if key in self.hk_options: del self.hk_options[key] + if ( + self.show_advanced_options + and self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE + ): + self.hk_options[CONF_DEVICES] = user_input[CONF_DEVICES] + return self.async_create_entry(title="", data=self.hk_options) + data_schema = { + vol.Optional( + CONF_AUTO_START, + default=self.hk_options.get(CONF_AUTO_START, DEFAULT_AUTO_START), + ): bool + } + + if self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE: + all_supported_devices = await _async_get_supported_devices(self.hass) + devices = self.hk_options.get(CONF_DEVICES, []) + data_schema[vol.Optional(CONF_DEVICES, default=devices)] = cv.multi_select( + all_supported_devices + ) + return self.async_show_form( step_id="advanced", - data_schema=vol.Schema( - { - vol.Optional( - CONF_AUTO_START, - default=self.hk_options.get( - CONF_AUTO_START, DEFAULT_AUTO_START - ), - ): bool - } - ), + data_schema=vol.Schema(data_schema), ) async def async_step_cameras(self, user_input=None): @@ -412,7 +426,6 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self.included_cameras = set() self.hk_options[CONF_FILTER] = entity_filter - if self.included_cameras: return await self.async_step_cameras() @@ -481,6 +494,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow): ) +async def _async_get_supported_devices(hass): + """Return all supported devices.""" + results = await device_automation.async_get_device_automations(hass, "trigger") + dev_reg = device_registry.async_get(hass) + unsorted = {device_id: dev_reg.async_get(device_id).name for device_id in results} + return dict(sorted(unsorted.items(), key=lambda item: item[1])) + + def _async_get_matching_entities(hass, domains=None): """Fetch all entities or entities in the given domains.""" return { diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 30b2a1c2597..4638c9f3b62 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,5 +1,7 @@ """Constants used be the HomeKit component.""" +from homeassistant.const import CONF_DEVICES + # #### Misc #### DEBOUNCE_TIMEOUT = 0.5 DEVICE_PRECISION_LEEWAY = 6 @@ -136,6 +138,7 @@ SERV_MOTION_SENSOR = "MotionSensor" SERV_OCCUPANCY_SENSOR = "OccupancySensor" SERV_OUTLET = "Outlet" SERV_SECURITY_SYSTEM = "SecuritySystem" +SERV_SERVICE_LABEL = "ServiceLabel" SERV_SMOKE_SENSOR = "SmokeSensor" SERV_SPEAKER = "Speaker" SERV_STATELESS_PROGRAMMABLE_SWITCH = "StatelessProgrammableSwitch" @@ -205,6 +208,8 @@ CHAR_ROTATION_DIRECTION = "RotationDirection" CHAR_ROTATION_SPEED = "RotationSpeed" CHAR_SATURATION = "Saturation" CHAR_SERIAL_NUMBER = "SerialNumber" +CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex" +CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace" CHAR_SLEEP_DISCOVER_MODE = "SleepDiscoveryMode" CHAR_SMOKE_DETECTED = "SmokeDetected" CHAR_STATUS_LOW_BATTERY = "StatusLowBattery" @@ -292,6 +297,7 @@ CONFIG_OPTIONS = [ CONF_SAFE_MODE, CONF_ENTITY_CONFIG, CONF_HOMEKIT_MODE, + CONF_DEVICES, ] # ### Maximum Lengths ### diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 3c9671c93e2..69cff3bfcc3 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -30,9 +30,10 @@ }, "advanced": { "data": { + "devices": "Devices (Triggers)", "auto_start": "Autostart (disable if you are calling the homekit.start service manually)" }, - "description": "These settings only need to be adjusted if HomeKit is not functional.", + "description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.", "title": "Advanced Configuration" } } diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index cee1e64ad56..564709cb9c1 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -21,9 +21,10 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (disable if you are calling the homekit.start service manually)" + "auto_start": "Autostart (disable if you are calling the homekit.start service manually)", + "devices": "Devices (Triggers)" }, - "description": "These settings only need to be adjusted if HomeKit is not functional.", + "description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.", "title": "Advanced Configuration" }, "cameras": { diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py new file mode 100644 index 00000000000..6d5f67f9915 --- /dev/null +++ b/homeassistant/components/homekit/type_triggers.py @@ -0,0 +1,89 @@ +"""Class to hold all sensor accessories.""" +import logging + +from pyhap.const import CATEGORY_SENSOR + +from homeassistant.helpers.trigger import async_initialize_triggers + +from .accessories import TYPES, HomeAccessory +from .const import ( + CHAR_NAME, + CHAR_PROGRAMMABLE_SWITCH_EVENT, + CHAR_SERVICE_LABEL_INDEX, + CHAR_SERVICE_LABEL_NAMESPACE, + SERV_SERVICE_LABEL, + SERV_STATELESS_PROGRAMMABLE_SWITCH, +) + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register("DeviceTriggerAccessory") +class DeviceTriggerAccessory(HomeAccessory): + """Generate a Programmable switch.""" + + def __init__(self, *args, device_triggers=None, device_id=None): + """Initialize a Programmable switch accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR, device_id=device_id) + self._device_triggers = device_triggers + self._remove_triggers = None + self.triggers = [] + for idx, trigger in enumerate(device_triggers): + type_ = trigger.get("type") + subtype = trigger.get("subtype") + trigger_name = ( + f"{type_.title()} {subtype.title()}" if subtype else type_.title() + ) + serv_stateless_switch = self.add_preload_service( + SERV_STATELESS_PROGRAMMABLE_SWITCH, + [CHAR_NAME, CHAR_SERVICE_LABEL_INDEX], + ) + self.triggers.append( + serv_stateless_switch.configure_char( + CHAR_PROGRAMMABLE_SWITCH_EVENT, + value=0, + valid_values={"Trigger": 0}, + ) + ) + serv_stateless_switch.configure_char(CHAR_NAME, value=trigger_name) + serv_stateless_switch.configure_char( + CHAR_SERVICE_LABEL_INDEX, value=idx + 1 + ) + serv_service_label = self.add_preload_service(SERV_SERVICE_LABEL) + serv_service_label.configure_char(CHAR_SERVICE_LABEL_NAMESPACE, value=1) + serv_stateless_switch.add_linked_service(serv_service_label) + + async def async_trigger(self, run_variables, context=None, skip_condition=False): + """Trigger button press. + + This method is a coroutine. + """ + reason = "" + if "trigger" in run_variables and "description" in run_variables["trigger"]: + reason = f' by {run_variables["trigger"]["description"]}' + _LOGGER.debug("Button triggered%s - %s", reason, run_variables) + idx = int(run_variables["trigger"]["idx"]) + self.triggers[idx].set_value(0) + + # Attach the trigger using the helper in async run + # and detach it in async stop + async def run(self): + """Handle accessory driver started event.""" + self._remove_triggers = await async_initialize_triggers( + self.hass, + self._device_triggers, + self.async_trigger, + "homekit", + self.display_name, + _LOGGER.log, + ) + + async def stop(self): + """Handle accessory driver stop event.""" + if self._remove_triggers: + self._remove_triggers() + + @property + def available(self): + """Return available.""" + return True diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index 5441bcc195c..5e2acbcd9db 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -1,12 +1,15 @@ """HomeKit session fixtures.""" +from contextlib import suppress +import os from unittest.mock import patch from pyhap.accessory_driver import AccessoryDriver import pytest +from homeassistant.components.device_tracker.legacy import YAML_DEVICES from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED -from tests.common import async_capture_events +from tests.common import async_capture_events, mock_device_registry, mock_registry @pytest.fixture @@ -24,7 +27,46 @@ def hk_driver(loop): yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop) +@pytest.fixture +def mock_hap(loop, mock_zeroconf): + """Return a custom AccessoryDriver instance for HomeKit accessory init.""" + with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( + "pyhap.accessory_driver.AccessoryEncoder" + ), patch("pyhap.accessory_driver.HAPServer.async_stop"), patch( + "pyhap.accessory_driver.HAPServer.async_start" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.publish" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.async_stop" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ): + yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop) + + @pytest.fixture def events(hass): """Yield caught homekit_changed events.""" return async_capture_events(hass, EVENT_HOMEKIT_CHANGED) + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture(name="entity_reg") +def entity_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def demo_cleanup(hass): + """Clean up device tracker demo file.""" + yield + with suppress(FileNotFoundError): + os.remove(hass.config.path(YAML_DEVICES)) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index f3707f9f71e..af803d50cf4 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.homekit.const import DOMAIN, SHORT_BRIDGE_NAME from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -314,6 +315,7 @@ async def test_options_flow_exclude_mode_advanced(auto_start, hass): assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { "auto_start": auto_start, + "devices": [], "mode": "bridge", "filter": { "exclude_domains": [], @@ -365,6 +367,138 @@ async def test_options_flow_exclude_mode_basic(hass): } +async def test_options_flow_devices( + mock_hap, hass, demo_cleanup, device_reg, entity_reg +): + """Test devices can be bridged.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + demo_config_entry = MockConfigEntry(domain="domain") + demo_config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "persistent_notification", {}) + assert await async_setup_component(hass, "demo", {"demo": {}}) + assert await async_setup_component(hass, "homekit", {"homekit": {}}) + + hass.states.async_set("climate.old", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": True} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"domains": ["fan", "vacuum", "climate"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "include_exclude" + + entry = entity_reg.async_get("light.ceiling_lights") + assert entry is not None + device_id = entry.device_id + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entities": ["climate.old"], + "include_exclude_mode": "exclude", + }, + ) + + with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"auto_start": True, "devices": [device_id]}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "devices": [device_id], + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": ["climate.old"], + "include_domains": ["fan", "vacuum", "climate"], + "include_entities": [], + }, + } + + +async def test_options_flow_devices_preserved_when_advanced_off(mock_hap, hass): + """Test devices are preserved if they were added in advanced mode but it was turned off.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + options={ + "devices": ["1fabcabcabcabcabcabcabcabcabc"], + "filter": { + "include_domains": [ + "fan", + "humidifier", + "vacuum", + "media_player", + "climate", + "alarm_control_panel", + ], + "exclude_entities": ["climate.front_gate"], + }, + }, + ) + config_entry.add_to_hass(hass) + + demo_config_entry = MockConfigEntry(domain="domain") + demo_config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "persistent_notification", {}) + assert await async_setup_component(hass, "homekit", {"homekit": {}}) + + hass.states.async_set("climate.old", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": False} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"domains": ["fan", "vacuum", "climate"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "include_exclude" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entities": ["climate.old"], + "include_exclude_mode": "exclude", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "devices": ["1fabcabcabcabcabcabcabcabcabc"], + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": ["climate.old"], + "include_domains": ["fan", "vacuum", "climate"], + "include_entities": [], + }, + } + + async def test_options_flow_include_mode_basic(hass): """Test config flow options in include mode.""" @@ -646,6 +780,7 @@ async def test_options_flow_blocked_when_from_yaml(hass): data={CONF_NAME: "mock_name", CONF_PORT: 12345}, options={ "auto_start": True, + "devices": [], "filter": { "include_domains": [ "fan", diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 17c03ba1dcd..4976985fa15 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -37,6 +37,7 @@ from homeassistant.components.homekit.const import ( SERVICE_HOMEKIT_START, SERVICE_HOMEKIT_UNPAIR, ) +from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( @@ -70,7 +71,7 @@ from homeassistant.util import json as json_util from .util import PATH_HOMEKIT, async_init_entry, async_init_integration -from tests.common import MockConfigEntry, mock_device_registry, mock_registry +from tests.common import MockConfigEntry IP_ADDRESS = "127.0.0.1" @@ -101,19 +102,7 @@ def always_patch_driver(hk_driver): """Load the hk_driver fixture.""" -@pytest.fixture(name="device_reg") -def device_reg_fixture(hass): - """Return an empty, loaded, registry.""" - return mock_device_registry(hass) - - -@pytest.fixture(name="entity_reg") -def entity_reg_fixture(hass): - """Return an empty, loaded, registry.""" - return mock_registry(hass) - - -def _mock_homekit(hass, entry, homekit_mode, entity_filter=None): +def _mock_homekit(hass, entry, homekit_mode, entity_filter=None, devices=None): return HomeKit( hass=hass, name=BRIDGE_NAME, @@ -126,6 +115,7 @@ def _mock_homekit(hass, entry, homekit_mode, entity_filter=None): advertise_ip=None, entry_id=entry.entry_id, entry_title=entry.title, + devices=devices, ) @@ -178,6 +168,7 @@ async def test_setup_min(hass, mock_zeroconf): None, entry.entry_id, entry.title, + devices=[], ) # Test auto start enabled @@ -214,6 +205,7 @@ async def test_setup_auto_start_disabled(hass, mock_zeroconf): None, entry.entry_id, entry.title, + devices=[], ) # Test auto_start disabled @@ -602,6 +594,41 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroc assert not hk_driver_start.called +async def test_homekit_start_with_a_device( + hass, hk_driver, mock_zeroconf, demo_cleanup, device_reg, entity_reg +): + """Test HomeKit start method with a device.""" + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + assert await async_setup_component(hass, "demo", {"demo": {}}) + await hass.async_block_till_done() + + reg_entry = entity_reg.async_get("light.ceiling_lights") + assert reg_entry is not None + device_id = reg_entry.device_id + await async_init_entry(hass, entry) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, None, devices=[device_id]) + homekit.driver = hk_driver + + with patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=Exception), patch( + f"{PATH_HOMEKIT}.show_setup_message" + ) as mock_setup_msg: + await homekit.async_start() + + await hass.async_block_till_done() + mock_setup_msg.assert_called_with( + hass, entry.entry_id, "Mock Title (Home Assistant Bridge)", ANY, ANY + ) + assert homekit.status == STATUS_RUNNING + + assert isinstance( + list(homekit.driver.accessory.accessories.values())[0], DeviceTriggerAccessory + ) + await homekit.async_stop() + + async def test_homekit_stop(hass): """Test HomeKit stop method.""" entry = await async_init_integration(hass) @@ -1141,6 +1168,7 @@ async def test_homekit_finds_linked_batteries( "manufacturer": "Tesla", "model": "Powerwall 2", "sw_version": "0.16.0", + "platform": "test", "linked_battery_charging_sensor": "binary_sensor.powerwall_battery_charging", "linked_battery_sensor": "sensor.powerwall_battery", }, @@ -1250,6 +1278,7 @@ async def test_yaml_updates_update_config_entry_for_name(hass, mock_zeroconf): None, entry.entry_id, entry.title, + devices=[], ) # Test auto start enabled @@ -1416,6 +1445,7 @@ async def test_homekit_finds_linked_motion_sensors( { "manufacturer": "Ubq", "model": "Camera Server", + "platform": "test", "sw_version": "0.16.0", "linked_motion_sensor": "binary_sensor.camera_motion_sensor", }, @@ -1480,6 +1510,7 @@ async def test_homekit_finds_linked_humidity_sensors( { "manufacturer": "Home Assistant", "model": "Smart Brainy Clever Humidifier", + "platform": "test", "sw_version": "0.16.1", "linked_humidity_sensor": "sensor.humidifier_humidity_sensor", }, @@ -1518,6 +1549,7 @@ async def test_reload(hass, mock_zeroconf): None, entry.entry_id, entry.title, + devices=[], ) yaml_path = os.path.join( _get_fixtures_base_path(), @@ -1556,6 +1588,7 @@ async def test_reload(hass, mock_zeroconf): None, entry.entry_id, entry.title, + devices=[], ) diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py new file mode 100644 index 00000000000..4a265858cb3 --- /dev/null +++ b/tests/components/homekit/test_type_triggers.py @@ -0,0 +1,57 @@ +"""Test different accessory types: Triggers (Programmable Switches).""" + +from unittest.mock import MagicMock + +from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, async_get_device_automations + + +async def test_programmable_switch_button_fires_on_trigger( + hass, hk_driver, events, demo_cleanup, device_reg, entity_reg +): + """Test that DeviceTriggerAccessory fires the programmable switch event on trigger.""" + hk_driver.publish = MagicMock() + + demo_config_entry = MockConfigEntry(domain="domain") + demo_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "demo", {"demo": {}}) + await hass.async_block_till_done() + hass.states.async_set("light.ceiling_lights", STATE_OFF) + await hass.async_block_till_done() + + entry = entity_reg.async_get("light.ceiling_lights") + assert entry is not None + device_id = entry.device_id + + device_triggers = await async_get_device_automations(hass, "trigger", device_id) + acc = DeviceTriggerAccessory( + hass, + hk_driver, + "DeviceTriggerAccessory", + None, + 1, + None, + device_id=device_id, + device_triggers=device_triggers, + ) + await acc.run() + await hass.async_block_till_done() + + assert acc.entity_id is None + assert acc.device_id is device_id + assert acc.available is True + + hk_driver.publish.reset_mock() + hass.states.async_set("light.ceiling_lights", STATE_ON) + await hass.async_block_till_done() + hk_driver.publish.assert_called_once() + + hk_driver.publish.reset_mock() + hass.states.async_set("light.ceiling_lights", STATE_OFF) + await hass.async_block_till_done() + hk_driver.publish.assert_called_once() + + await acc.stop() From 3a0a8da648d886fc055bfa8b7eabfce792d9ff2c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 25 Aug 2021 17:32:48 +0200 Subject: [PATCH 792/903] Change logging to do rollover() instead of rotate() (#55177) * Change to rollover from rotate. * Remove test log files. --- .gitignore | 2 +- homeassistant/bootstrap.py | 4 ++-- tests/test_bootstrap.py | 5 +++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 20c1991c45d..bdc4c24c5b0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ config/* config2/* tests/testing_config/deps -tests/testing_config/home-assistant.log +tests/testing_config/home-assistant.log* # hass-release data/ diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 2e3b5522c3f..f1136123999 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -332,7 +332,7 @@ def async_enable_logging( not err_path_exists and os.access(err_dir, os.W_OK) ): - err_handler: logging.FileHandler + err_handler: logging.handlers.RotatingFileHandler | logging.handlers.TimedRotatingFileHandler if log_rotate_days: err_handler = logging.handlers.TimedRotatingFileHandler( err_log_path, when="midnight", backupCount=log_rotate_days @@ -342,7 +342,7 @@ def async_enable_logging( err_log_path, backupCount=1 ) - err_handler.rotate(err_log_path, f"{err_log_path[:-4]}.previous.log") + err_handler.doRollover() err_handler.setLevel(logging.INFO if verbose else logging.WARNING) err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 2bdbab11c32..929cbbf6e81 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1,6 +1,7 @@ """Test the bootstrapping.""" # pylint: disable=protected-access import asyncio +import glob import os from unittest.mock import Mock, patch @@ -69,6 +70,10 @@ async def test_async_enable_logging(hass): log_file="test.log", ) mock_async_activate_log_queue_handler.assert_called_once() + for f in glob.glob("test.log*"): + os.remove(f) + for f in glob.glob("testing_config/home-assistant.log*"): + os.remove(f) async def test_load_hassio(hass): From 80cfd5993905e964881f2c4013906823d9b459ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Aug 2021 17:41:33 +0200 Subject: [PATCH 793/903] Implement color_mode support for mysensors (#52068) --- homeassistant/components/mysensors/light.py | 166 ++++++++------------ 1 file changed, 66 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index e1f4dd3d1e0..0c80cf31c9a 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -1,17 +1,17 @@ """Support for MySensors lights.""" from __future__ import annotations -from typing import Any +from typing import Any, Tuple, cast from homeassistant.components import mysensors from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_HS_COLOR, - ATTR_WHITE_VALUE, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, DOMAIN, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_WHITE_VALUE, LightEntity, ) from homeassistant.config_entries import ConfigEntry @@ -19,15 +19,12 @@ from homeassistant.const import STATE_OFF, STATE_ON 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.util.color import rgb_hex_to_rgb_list from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .device import MySensorsDevice from .helpers import on_unload -SUPPORT_MYSENSORS_RGBW = SUPPORT_COLOR | SUPPORT_WHITE_VALUE - async def async_setup_entry( hass: HomeAssistant, @@ -69,24 +66,6 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): """Initialize a MySensors Light.""" super().__init__(*args) self._state: bool | None = None - self._brightness: int | None = None - self._hs: tuple[int, int] | None = None - self._white: int | None = None - - @property - def brightness(self) -> int | None: - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def hs_color(self) -> tuple[int, int] | None: - """Return the hs color value [int, int].""" - return self._hs - - @property - def white_value(self) -> int | None: - """Return the white value of this light between 0..255.""" - return self._white @property def is_on(self) -> bool: @@ -114,7 +93,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): if ( ATTR_BRIGHTNESS not in kwargs - or kwargs[ATTR_BRIGHTNESS] == self._brightness + or kwargs[ATTR_BRIGHTNESS] == self._attr_brightness or set_req.V_DIMMER not in self._values ): return @@ -126,49 +105,9 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): if self.assumed_state: # optimistically assume that light has changed state - self._brightness = brightness + self._attr_brightness = brightness self._values[set_req.V_DIMMER] = percent - def _turn_on_rgb_and_w(self, hex_template: str, **kwargs: Any) -> None: - """Turn on RGB or RGBW child device.""" - assert self._hs - rgb = list(color_util.color_hs_to_RGB(*self._hs)) - white = self._white - hex_color = self._values.get(self.value_type) - hs_color: tuple[float, float] | None = kwargs.get(ATTR_HS_COLOR) - new_rgb: tuple[int, int, int] | None - if hs_color is not None: - new_rgb = color_util.color_hs_to_RGB(*hs_color) - else: - new_rgb = None - new_white: int | None = kwargs.get(ATTR_WHITE_VALUE) - - if new_rgb is None and new_white is None: - return - if new_rgb is not None: - rgb = list(new_rgb) - if hex_template == "%02x%02x%02x%02x": - if new_white is not None: - rgb.append(new_white) - elif white is not None: - rgb.append(white) - else: - rgb.append(0) - hex_color = hex_template % tuple(rgb) - if len(rgb) > 3: - white = rgb.pop() - self.gateway.set_child_value( - self.node_id, self.child_id, self.value_type, hex_color, ack=1 - ) - - if self.assumed_state: - # optimistically assume that light has changed state - # pylint: disable=no-value-for-parameter - # https://github.com/PyCQA/pylint/issues/4546 - self._hs = color_util.color_RGB_to_hs(*rgb) # type: ignore[assignment] - self._white = white - self._values[self.value_type] = hex_color - async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT @@ -190,27 +129,16 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): """Update the controller with values from dimmer child.""" value_type = self.gateway.const.SetReq.V_DIMMER if value_type in self._values: - self._brightness = round(255 * int(self._values[value_type]) / 100) - if self._brightness == 0: + self._attr_brightness = round(255 * int(self._values[value_type]) / 100) + if self._attr_brightness == 0: self._state = False - @callback - def _async_update_rgb_or_w(self) -> None: - """Update the controller with values from RGB or RGBW child.""" - value = self._values[self.value_type] - color_list = rgb_hex_to_rgb_list(value) - if len(color_list) > 3: - self._white = color_list.pop() - self._hs = color_util.color_RGB_to_hs(*color_list) # type: ignore[assignment] - class MySensorsLightDimmer(MySensorsLight): """Dimmer child class to MySensorsLight.""" - @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORT_BRIGHTNESS + _attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + _attr_color_mode = COLOR_MODE_BRIGHTNESS async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -229,22 +157,33 @@ class MySensorsLightDimmer(MySensorsLight): class MySensorsLightRGB(MySensorsLight): """RGB child class to MySensorsLight.""" - @property - def supported_features(self) -> int: - """Flag supported features.""" - set_req = self.gateway.const.SetReq - if set_req.V_DIMMER in self._values: - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR - return SUPPORT_COLOR + _attr_supported_color_modes = {COLOR_MODE_RGB} + _attr_color_mode = COLOR_MODE_RGB async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - self._turn_on_rgb_and_w("%02x%02x%02x", **kwargs) + self._turn_on_rgb(**kwargs) if self.assumed_state: self.async_write_ha_state() + def _turn_on_rgb(self, **kwargs: Any) -> None: + """Turn on RGB child device.""" + hex_color = self._values.get(self.value_type) + new_rgb: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) + if new_rgb is None: + return + hex_color = "%02x%02x%02x" % new_rgb + self.gateway.set_child_value( + self.node_id, self.child_id, self.value_type, hex_color, ack=1 + ) + + if self.assumed_state: + # optimistically assume that light has changed state + self._attr_rgb_color = new_rgb + self._values[self.value_type] = hex_color + async def async_update(self) -> None: """Update the controller with the latest value from a sensor.""" await super().async_update() @@ -252,22 +191,49 @@ class MySensorsLightRGB(MySensorsLight): self._async_update_dimmer() self._async_update_rgb_or_w() + @callback + def _async_update_rgb_or_w(self) -> None: + """Update the controller with values from RGB child.""" + value = self._values[self.value_type] + self._attr_rgb_color = cast( + Tuple[int, int, int], tuple(rgb_hex_to_rgb_list(value)) + ) + class MySensorsLightRGBW(MySensorsLightRGB): """RGBW child class to MySensorsLightRGB.""" - @property - def supported_features(self) -> int: - """Flag supported features.""" - set_req = self.gateway.const.SetReq - if set_req.V_DIMMER in self._values: - return SUPPORT_BRIGHTNESS | SUPPORT_MYSENSORS_RGBW - return SUPPORT_MYSENSORS_RGBW + _attr_supported_color_modes = {COLOR_MODE_RGBW} + _attr_color_mode = COLOR_MODE_RGBW async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - self._turn_on_rgb_and_w("%02x%02x%02x%02x", **kwargs) + self._turn_on_rgbw(**kwargs) if self.assumed_state: self.async_write_ha_state() + + def _turn_on_rgbw(self, **kwargs: Any) -> None: + """Turn on RGBW child device.""" + hex_color = self._values.get(self.value_type) + new_rgbw: tuple[int, int, int, int] | None = kwargs.get(ATTR_RGBW_COLOR) + if new_rgbw is None: + return + hex_color = "%02x%02x%02x%02x" % new_rgbw + self.gateway.set_child_value( + self.node_id, self.child_id, self.value_type, hex_color, ack=1 + ) + + if self.assumed_state: + # optimistically assume that light has changed state + self._attr_rgbw_color = new_rgbw + self._values[self.value_type] = hex_color + + @callback + def _async_update_rgb_or_w(self) -> None: + """Update the controller with values from RGBW child.""" + value = self._values[self.value_type] + self._attr_rgbw_color = cast( + Tuple[int, int, int, int], tuple(rgb_hex_to_rgb_list(value)) + ) From 5c6451d11bdcb54632a07709eedf6765f6c8aa2b Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Wed, 25 Aug 2021 17:41:48 +0200 Subject: [PATCH 794/903] Address review of Nanoleaf Config Flow (#55215) --- .../components/nanoleaf/config_flow.py | 10 +- homeassistant/components/nanoleaf/light.py | 16 +- tests/components/nanoleaf/test_config_flow.py | 300 ++++++++++-------- 3 files changed, 174 insertions(+), 152 deletions(-) diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 831fa238b67..0bd7975bbed 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -9,7 +9,6 @@ from pynanoleaf import InvalidToken, Nanoleaf, NotAuthorizingNewTokens, Unavaila import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import persistent_notification from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.typing import DiscoveryInfoType @@ -162,9 +161,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="invalid_token") except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with Nanoleaf at %s with token %s", - self.nanoleaf.host, - self.nanoleaf.token, + "Unknown error connecting with Nanoleaf at %s", self.nanoleaf.host ) return self.async_abort(reason="unknown") name = info["name"] @@ -189,11 +186,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job( os.remove, self.hass.config.path(CONFIG_FILE) ) - persistent_notification.async_create( - self.hass, - "All Nanoleaf devices from the discovery integration are imported. If you used the discovery integration only for Nanoleaf you can remove it from your configuration.yaml", - f"Imported Nanoleaf {name}", - ) return self.async_create_entry( title=name, diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 0a5288dc390..b50edf82179 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -59,23 +59,25 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Import Nanoleaf light platform.""" - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: config[CONF_HOST], CONF_TOKEN: config[CONF_TOKEN]}, + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: config[CONF_HOST], CONF_TOKEN: config[CONF_TOKEN]}, + ) ) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nanoleaf light.""" data = hass.data[DOMAIN][entry.entry_id] - add_entities([NanoleafLight(data[DEVICE], data[NAME], data[SERIAL_NO])], True) + async_add_entities([NanoleafLight(data[DEVICE], data[NAME], data[SERIAL_NO])], True) class NanoleafLight(LightEntity): diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 065cb4b5bb1..a84d97fda2a 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -1,7 +1,11 @@ """Test the Nanoleaf config flow.""" +from __future__ import annotations + from unittest.mock import patch from pynanoleaf import InvalidToken, NotAuthorizingNewTokens, Unavailable +from pynanoleaf.pynanoleaf import NanoleafError +import pytest from homeassistant import config_entries from homeassistant.components.nanoleaf.const import DOMAIN @@ -10,14 +14,15 @@ from homeassistant.core import HomeAssistant TEST_NAME = "Canvas ADF9" TEST_HOST = "192.168.0.100" +TEST_OTHER_HOST = "192.168.0.200" TEST_TOKEN = "R34F1c92FNv3pcZs4di17RxGqiLSwHM" TEST_OTHER_TOKEN = "Qs4dxGcHR34l29RF1c92FgiLQBt3pcM" TEST_DEVICE_ID = "5E:2E:EA:XX:XX:XX" TEST_OTHER_DEVICE_ID = "5E:2E:EA:YY:YY:YY" -async def test_user_unavailable_user_step(hass: HomeAssistant) -> None: - """Test we handle Unavailable errors when host is not available in user step.""" +async def test_user_unavailable_user_step_link_step(hass: HomeAssistant) -> None: + """Test we handle Unavailable in user and link step.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -36,12 +41,6 @@ async def test_user_unavailable_user_step(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} assert not result2["last_step"] - -async def test_user_unavailable_link_step(hass: HomeAssistant) -> None: - """Test we abort if the device becomes unavailable in the link step.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", return_value=None, @@ -67,8 +66,18 @@ async def test_user_unavailable_link_step(hass: HomeAssistant) -> None: assert result3["reason"] == "cannot_connect" -async def test_user_unavailable_setup_finish(hass: HomeAssistant) -> None: - """Test we abort if the device becomes unavailable during setup_finish.""" +@pytest.mark.parametrize( + "error, reason", + [ + (Unavailable("message"), "cannot_connect"), + (InvalidToken("message"), "invalid_token"), + (Exception, "unknown"), + ], +) +async def test_user_error_setup_finish( + hass: HomeAssistant, error: Exception, reason: str +) -> None: + """Test abort flow if on error in setup_finish.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -90,62 +99,84 @@ async def test_user_unavailable_setup_finish(hass: HomeAssistant) -> None: return_value=None, ), patch( "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - side_effect=Unavailable("message"), + side_effect=error, ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {}, ) assert result3["type"] == "abort" - assert result3["reason"] == "cannot_connect" + assert result3["reason"] == reason -async def test_user_not_authorizing_new_tokens(hass: HomeAssistant) -> None: - """Test we handle NotAuthorizingNewTokens errors.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] is None - assert not result["last_step"] - assert result["step_id"] == "user" - +async def test_user_not_authorizing_new_tokens_user_step_link_step( + hass: HomeAssistant, +) -> None: + """Test we handle NotAuthorizingNewTokens in user step and link step.""" with patch( - "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=NotAuthorizingNewTokens("message"), - ): + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + ) as mock_nanoleaf, patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + return_value={"name": TEST_NAME}, + ), patch( + "homeassistant.components.nanoleaf.async_setup_entry", return_value=True + ) as mock_setup_entry: + nanoleaf = mock_nanoleaf.return_value + nanoleaf.authorize.side_effect = NotAuthorizingNewTokens( + "Not authorizing new tokens" + ) + nanoleaf.host = TEST_HOST + nanoleaf.token = TEST_TOKEN + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + assert result["step_id"] == "user" + assert not result["last_step"] + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_HOST: TEST_HOST, }, ) - assert result2["type"] == "form" - assert result2["errors"] is None - assert result2["step_id"] == "link" + assert result2["type"] == "form" + assert result2["errors"] is None + assert result2["step_id"] == "link" - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - ) - assert result3["type"] == "form" - assert result3["errors"] is None - assert result3["step_id"] == "link" + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + assert result3["type"] == "form" + assert result3["errors"] is None + assert result3["step_id"] == "link" - with patch( - "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=NotAuthorizingNewTokens("message"), - ): - result4 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result4["type"] == "form" + assert result4["errors"] == {"base": "not_allowing_new_tokens"} + assert result4["step_id"] == "link" + + nanoleaf.authorize.side_effect = None + nanoleaf.authorize.return_value = None + + result5 = await hass.config_entries.flow.async_configure( result["flow_id"], {}, ) - assert result4["type"] == "form" - assert result4["step_id"] == "link" - assert result4["errors"] == {"base": "not_allowing_new_tokens"} + assert result5["type"] == "create_entry" + assert result5["title"] == TEST_NAME + assert result5["data"] == { + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_exception(hass: HomeAssistant) -> None: - """Test we handle Exception errors.""" +async def test_user_exception_user_step(hass: HomeAssistant) -> None: + """Test we handle Exception errors in user step.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -203,35 +234,18 @@ async def test_user_exception(hass: HomeAssistant) -> None: assert result5["reason"] == "unknown" -async def test_zeroconf_discovery(hass: HomeAssistant) -> None: - """Test zeroconfig discovery flow init.""" - zeroconf = "_nanoleafms._tcp.local" - with patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - return_value={"name": TEST_NAME}, - ), patch( - "homeassistant.components.nanoleaf.config_flow.load_json", - return_value={}, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "host": TEST_HOST, - "name": f"{TEST_NAME}.{zeroconf}", - "type": zeroconf, - "properties": {"id": TEST_DEVICE_ID}, - }, - ) - assert result["type"] == "form" - assert result["step_id"] == "link" - - -async def test_homekit_discovery_link_unavailable( - hass: HomeAssistant, +@pytest.mark.parametrize( + "source, type_in_discovery_info", + [ + (config_entries.SOURCE_HOMEKIT, "_hap._tcp.local"), + (config_entries.SOURCE_ZEROCONF, "_nanoleafms._tcp.local"), + (config_entries.SOURCE_ZEROCONF, "_nanoleafapi._tcp.local."), + ], +) +async def test_discovery_link_unavailable( + hass: HomeAssistant, source: type, type_in_discovery_info: str ) -> None: - """Test homekit discovery and abort if device is unavailable.""" - homekit = "_hap._tcp.local" + """Test discovery and abort if device is unavailable.""" with patch( "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", return_value={"name": TEST_NAME}, @@ -241,11 +255,11 @@ async def test_homekit_discovery_link_unavailable( ): result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, + context={"source": source}, data={ "host": TEST_HOST, - "name": f"{TEST_NAME}.{homekit}", - "type": homekit, + "name": f"{TEST_NAME}.{type_in_discovery_info}", + "type": type_in_discovery_info, "properties": {"id": TEST_DEVICE_ID}, }, ) @@ -293,11 +307,21 @@ async def test_import_config(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_config_invalid_token(hass: HomeAssistant) -> None: - """Test configuration import with invalid token.""" +@pytest.mark.parametrize( + "error, reason", + [ + (Unavailable("message"), "cannot_connect"), + (InvalidToken("message"), "invalid_token"), + (Exception, "unknown"), + ], +) +async def test_import_config_error( + hass: HomeAssistant, error: NanoleafError, reason: str +) -> None: + """Test configuration import with errors in setup_finish.""" with patch( "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - side_effect=InvalidToken("message"), + side_effect=error, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -305,25 +329,70 @@ async def test_import_config_invalid_token(hass: HomeAssistant) -> None: data={CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, ) assert result["type"] == "abort" - assert result["reason"] == "invalid_token" + assert result["reason"] == reason -async def test_import_last_discovery_integration_host_zeroconf( +@pytest.mark.parametrize( + "source, type_in_discovery", + [ + (config_entries.SOURCE_HOMEKIT, "_hap._tcp.local"), + (config_entries.SOURCE_ZEROCONF, "_nanoleafms._tcp.local"), + (config_entries.SOURCE_ZEROCONF, "_nanoleafapi._tcp.local"), + ], +) +@pytest.mark.parametrize( + "nanoleaf_conf_file, remove_config", + [ + ({TEST_DEVICE_ID: {"token": TEST_TOKEN}}, True), + ({TEST_HOST: {"token": TEST_TOKEN}}, True), + ( + { + TEST_DEVICE_ID: {"token": TEST_TOKEN}, + TEST_HOST: {"token": TEST_OTHER_TOKEN}, + }, + True, + ), + ( + { + TEST_DEVICE_ID: {"token": TEST_TOKEN}, + TEST_OTHER_HOST: {"token": TEST_OTHER_TOKEN}, + }, + False, + ), + ( + { + TEST_OTHER_DEVICE_ID: {"token": TEST_OTHER_TOKEN}, + TEST_HOST: {"token": TEST_TOKEN}, + }, + False, + ), + ], +) +async def test_import_discovery_integration( hass: HomeAssistant, + source: str, + type_in_discovery: str, + nanoleaf_conf_file: dict[str, dict[str, str]], + remove_config: bool, ) -> None: """ - Test discovery integration import from < 2021.4 (host) with zeroconf. + Test discovery integration import. - Device is last in Nanoleaf config file. + Test with different discovery flow sources and corresponding types. + Test with different .nanoleaf_conf files with device_id (>= 2021.4), host (< 2021.4) and combination. + Test removing the .nanoleaf_conf file if it was the only device in the file. + Test updating the .nanoleaf_conf file if it was not the only device in the file. """ - zeroconf = "_nanoleafapi._tcp.local" with patch( "homeassistant.components.nanoleaf.config_flow.load_json", - return_value={TEST_HOST: {"token": TEST_TOKEN}}, + return_value=dict(nanoleaf_conf_file), ), patch( "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", return_value={"name": TEST_NAME}, ), patch( + "homeassistant.components.nanoleaf.config_flow.save_json", + return_value=None, + ) as mock_save_json, patch( "homeassistant.components.nanoleaf.config_flow.os.remove", return_value=None, ) as mock_remove, patch( @@ -332,68 +401,27 @@ async def test_import_last_discovery_integration_host_zeroconf( ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, + context={"source": source}, data={ "host": TEST_HOST, - "name": f"{TEST_NAME}.{zeroconf}", - "type": zeroconf, + "name": f"{TEST_NAME}.{type_in_discovery}", + "type": type_in_discovery, "properties": {"id": TEST_DEVICE_ID}, }, ) - assert result["type"] == "create_entry" assert result["title"] == TEST_NAME assert result["data"] == { CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, } - mock_remove.assert_called_once() - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_not_last_discovery_integration_device_id_homekit( - hass: HomeAssistant, -) -> None: - """ - Test discovery integration import from >= 2021.4 (device_id) with homekit. - - Device is not the only one in the Nanoleaf config file. - """ - homekit = "_hap._tcp.local" - with patch( - "homeassistant.components.nanoleaf.config_flow.load_json", - return_value={ - TEST_DEVICE_ID: {"token": TEST_TOKEN}, - TEST_OTHER_DEVICE_ID: {"token": TEST_OTHER_TOKEN}, - }, - ), patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - return_value={"name": TEST_NAME}, - ), patch( - "homeassistant.components.nanoleaf.config_flow.save_json", - return_value=None, - ) as mock_save_json, patch( - "homeassistant.components.nanoleaf.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data={ - "host": TEST_HOST, - "name": f"{TEST_NAME}.{homekit}", - "type": homekit, - "properties": {"id": TEST_DEVICE_ID}, - }, - ) - - assert result["type"] == "create_entry" - assert result["title"] == TEST_NAME - assert result["data"] == { - CONF_HOST: TEST_HOST, - CONF_TOKEN: TEST_TOKEN, - } - mock_save_json.assert_called_once() + + if remove_config: + mock_save_json.assert_not_called() + mock_remove.assert_called_once() + else: + mock_save_json.assert_called_once() + mock_remove.assert_not_called() + await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 From b167e05a56da2ef66f972d7dd2649161fae08a62 Mon Sep 17 00:00:00 2001 From: Ben Edmunds Date: Wed, 25 Aug 2021 16:42:40 +0100 Subject: [PATCH 795/903] Sonos add bass & treble EQ option (#53978) --- .../components/sonos/media_player.py | 23 ++++++++++++++++++- homeassistant/components/sonos/services.yaml | 16 +++++++++++++ homeassistant/components/sonos/speaker.py | 10 ++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 0948e971baf..4fdc5c6f320 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -125,6 +125,8 @@ ATTR_NIGHT_SOUND = "night_sound" ATTR_SPEECH_ENHANCE = "speech_enhance" ATTR_QUEUE_POSITION = "queue_position" ATTR_STATUS_LIGHT = "status_light" +ATTR_EQ_BASS = "bass_level" +ATTR_EQ_TREBLE = "treble_level" async def async_setup_entry( @@ -221,7 +223,6 @@ async def async_setup_entry( { vol.Required(ATTR_ALARM_ID): cv.positive_int, vol.Optional(ATTR_TIME): cv.time, - vol.Optional(ATTR_VOLUME): cv.small_float, vol.Optional(ATTR_ENABLED): cv.boolean, vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, }, @@ -236,6 +237,12 @@ async def async_setup_entry( vol.Optional(ATTR_NIGHT_SOUND): cv.boolean, vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean, vol.Optional(ATTR_STATUS_LIGHT): cv.boolean, + vol.Optional(ATTR_EQ_BASS): vol.All( + vol.Coerce(int), vol.Range(min=-10, max=10) + ), + vol.Optional(ATTR_EQ_TREBLE): vol.All( + vol.Coerce(int), vol.Range(min=-10, max=10) + ), }, "set_option", ) @@ -615,6 +622,8 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): night_sound: bool | None = None, speech_enhance: bool | None = None, status_light: bool | None = None, + bass_level: int | None = None, + treble_level: int | None = None, ) -> None: """Modify playback options.""" if buttons_enabled is not None: @@ -632,6 +641,12 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if status_light is not None: self.soco.status_light = status_light + if bass_level is not None: + self.soco.bass = bass_level + + if treble_level is not None: + self.soco.treble = treble_level + @soco_error() def play_queue(self, queue_position: int = 0) -> None: """Start playing the queue.""" @@ -649,6 +664,12 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ATTR_SONOS_GROUP: self.speaker.sonos_group_entities } + if self.speaker.bass_level is not None: + attributes[ATTR_EQ_BASS] = self.speaker.bass_level + + if self.speaker.treble_level is not None: + attributes[ATTR_EQ_TREBLE] = self.speaker.treble_level + if self.speaker.night_mode is not None: attributes[ATTR_NIGHT_SOUND] = self.speaker.night_mode diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index 76bc656f990..9858eb7f8ed 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -121,6 +121,22 @@ set_option: description: Enable Status (LED) Light selector: boolean: + bass_level: + name: Bass Level + description: Bass level for EQ. + selector: + number: + min: -10 + max: 10 + mode: box + treble_level: + name: Treble Level + description: Treble level for EQ. + selector: + number: + min: -10 + max: 10 + mode: box play_queue: name: Play queue diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 9febace5e8c..6f37739a17a 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -190,6 +190,8 @@ class SonosSpeaker: self.muted: bool | None = None self.night_mode: bool | None = None self.dialog_mode: bool | None = None + self.bass_level: int | None = None + self.treble_level: int | None = None # Grouping self.coordinator: SonosSpeaker | None = None @@ -460,6 +462,12 @@ class SonosSpeaker: if "dialog_level" in variables: self.dialog_mode = variables["dialog_level"] == "1" + if "bass_level" in variables: + self.bass_level = variables["bass_level"] + + if "treble_level" in variables: + self.treble_level = variables["treble_level"] + self.async_write_entity_states() # @@ -982,6 +990,8 @@ class SonosSpeaker: self.muted = self.soco.mute self.night_mode = self.soco.night_mode self.dialog_mode = self.soco.dialog_mode + self.bass_level = self.soco.bass + self.treble_level = self.soco.treble def update_media(self, event: SonosEvent | None = None) -> None: """Update information about currently playing media.""" From b97d131fb36567903f4f2c1237cb5710ea45a359 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 25 Aug 2021 19:09:01 +0200 Subject: [PATCH 796/903] Add support for Xiaomi Miio pedestal fans (#55114) Co-authored-by: Teemu R. --- CODEOWNERS | 2 +- .../components/xiaomi_miio/__init__.py | 9 + homeassistant/components/xiaomi_miio/const.py | 39 +++- homeassistant/components/xiaomi_miio/fan.py | 203 ++++++++++++++++++ .../components/xiaomi_miio/manifest.json | 2 +- .../components/xiaomi_miio/number.py | 68 +++++- .../components/xiaomi_miio/select.py | 30 +++ .../components/xiaomi_miio/sensor.py | 30 ++- .../components/xiaomi_miio/switch.py | 10 + 9 files changed, 386 insertions(+), 7 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d3c1dc4d33d..121d1875202 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -585,7 +585,7 @@ homeassistant/components/worldclock/* @fabaff homeassistant/components/xbox/* @hunterjm homeassistant/components/xbox_live/* @MartinHjelmare homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi -homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG +homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG @bieniu homeassistant/components/xiaomi_tv/* @simse homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yale_smart_alarm/* @gjohansson-ST diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 0559cab9461..cde597432df 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -11,6 +11,8 @@ from miio import ( AirPurifier, AirPurifierMiot, DeviceException, + Fan, + FanP5, ) from miio.gateway.gateway import GatewayException @@ -29,8 +31,10 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODEL_FAN_P5, MODELS_AIR_MONITOR, MODELS_FAN, + MODELS_FAN_MIIO, MODELS_HUMIDIFIER, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, @@ -141,6 +145,11 @@ async def async_create_miio_device_and_coordinator( device = AirPurifier(host, token) elif model.startswith("zhimi.airfresh."): device = AirFresh(host, token) + # Pedestal fans + elif model == MODEL_FAN_P5: + device = FanP5(host, token) + elif model in MODELS_FAN_MIIO: + device = Fan(host, token, model=model) else: _LOGGER.error( "Unsupported device found! Please create an issue at " diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index af32e8daafa..b670582c069 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -58,6 +58,24 @@ MODEL_AIRHUMIDIFIER_MJJSQ = "deerma.humidifier.mjjsq" MODEL_AIRFRESH_VA2 = "zhimi.airfresh.va2" +MODEL_FAN_P5 = "dmaker.fan.p5" +MODEL_FAN_SA1 = "zhimi.fan.sa1" +MODEL_FAN_V2 = "zhimi.fan.v2" +MODEL_FAN_V3 = "zhimi.fan.v3" +MODEL_FAN_ZA1 = "zhimi.fan.za1" +MODEL_FAN_ZA3 = "zhimi.fan.za3" +MODEL_FAN_ZA4 = "zhimi.fan.za4" + +MODELS_FAN_MIIO = [ + MODEL_FAN_P5, + MODEL_FAN_SA1, + MODEL_FAN_V2, + MODEL_FAN_V3, + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, +] + MODELS_PURIFIER_MIOT = [ MODEL_AIRPURIFIER_3, MODEL_AIRPURIFIER_3H, @@ -124,7 +142,7 @@ MODELS_SWITCH = [ "chuangmi.plug.hmi205", "chuangmi.plug.hmi206", ] -MODELS_FAN = MODELS_PURIFIER_MIIO + MODELS_PURIFIER_MIOT +MODELS_FAN = MODELS_PURIFIER_MIIO + MODELS_PURIFIER_MIOT + MODELS_FAN_MIIO MODELS_HUMIDIFIER = ( MODELS_HUMIDIFIER_MIOT + MODELS_HUMIDIFIER_MIIO + MODELS_HUMIDIFIER_MJJSQ ) @@ -208,6 +226,9 @@ FEATURE_SET_DRY = 2048 FEATURE_SET_FAN_LEVEL = 4096 FEATURE_SET_MOTOR_SPEED = 8192 FEATURE_SET_CLEAN = 16384 +FEATURE_SET_OSCILLATION_ANGLE = 32768 +FEATURE_SET_OSCILLATION_ANGLE_MAX_140 = 65536 +FEATURE_SET_DELAY_OFF_COUNTDOWN = 131072 FEATURE_FLAGS_AIRPURIFIER_MIIO = ( FEATURE_SET_BUZZER @@ -281,3 +302,19 @@ FEATURE_FLAGS_AIRFRESH = ( | FEATURE_RESET_FILTER | FEATURE_SET_EXTRA_FEATURES ) + +FEATURE_FLAGS_FAN_P5 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_OSCILLATION_ANGLE_MAX_140 + | FEATURE_SET_LED + | FEATURE_SET_DELAY_OFF_COUNTDOWN +) + +FEATURE_FLAGS_FAN = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_OSCILLATION_ANGLE + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_SET_DELAY_OFF_COUNTDOWN +) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 0d22dad32ea..19d85ced2dc 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -7,9 +7,15 @@ import math from miio.airfresh import OperationMode as AirfreshOperationMode from miio.airpurifier import OperationMode as AirpurifierOperationMode from miio.airpurifier_miot import OperationMode as AirpurifierMiotOperationMode +from miio.fan import ( + MoveDirection as FanMoveDirection, + OperationMode as FanOperationMode, +) import voluptuous as vol from homeassistant.components.fan import ( + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, @@ -33,6 +39,8 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_PRO, FEATURE_FLAGS_AIRPURIFIER_PRO_V7, FEATURE_FLAGS_AIRPURIFIER_V3, + FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_P5, FEATURE_RESET_FILTER, FEATURE_SET_EXTRA_FEATURES, KEY_COORDINATOR, @@ -42,6 +50,8 @@ from .const import ( MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V3, + MODEL_FAN_P5, + MODELS_FAN_MIIO, MODELS_PURIFIER_MIOT, SERVICE_RESET_FILTER, SERVICE_SET_EXTRA_FEATURES, @@ -57,6 +67,9 @@ CONF_MODEL = "model" ATTR_MODEL = "model" +ATTR_MODE_NATURE = "Nature" +ATTR_MODE_NORMAL = "Normal" + # Air Purifier ATTR_BRIGHTNESS = "brightness" ATTR_FAN_LEVEL = "fan_level" @@ -159,6 +172,11 @@ SERVICE_TO_METHOD = { }, } +FAN_DIRECTIONS_MAP = { + "forward": "right", + "reverse": "left", +} + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Fan from a config entry.""" @@ -187,6 +205,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entity = XiaomiAirPurifier(name, device, config_entry, unique_id, coordinator) elif model.startswith("zhimi.airfresh."): entity = XiaomiAirFresh(name, device, config_entry, unique_id, coordinator) + elif model == MODEL_FAN_P5: + entity = XiaomiFanP5(name, device, config_entry, unique_id, coordinator) + elif model in MODELS_FAN_MIIO: + entity = XiaomiFan(name, device, config_entry, unique_id, coordinator) else: return @@ -669,3 +691,184 @@ class XiaomiAirFresh(XiaomiGenericDevice): "Resetting the filter lifetime of the miio device failed.", self._device.reset_filter, ) + + +class XiaomiFan(XiaomiGenericDevice): + """Representation of a Xiaomi Fan.""" + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the plug switch.""" + super().__init__(name, device, entry, unique_id, coordinator) + + if self._model == MODEL_FAN_P5: + self._device_features = FEATURE_FLAGS_FAN_P5 + self._preset_modes = [mode.name for mode in FanOperationMode] + else: + self._device_features = FEATURE_FLAGS_FAN + self._preset_modes = [ATTR_MODE_NATURE, ATTR_MODE_NORMAL] + self._nature_mode = False + self._supported_features = ( + SUPPORT_SET_SPEED + | SUPPORT_OSCILLATE + | SUPPORT_PRESET_MODE + | SUPPORT_DIRECTION + ) + self._preset_mode = None + self._oscillating = None + self._percentage = None + + @property + def preset_mode(self): + """Get the active preset mode.""" + return ATTR_MODE_NATURE if self._nature_mode else ATTR_MODE_NORMAL + + @property + def percentage(self): + """Return the current speed as a percentage.""" + return self._percentage + + @property + def oscillating(self): + """Return whether or not the fan is currently oscillating.""" + return self._oscillating + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._available = True + self._state = self.coordinator.data.is_on + self._oscillating = self.coordinator.data.oscillate + self._nature_mode = self.coordinator.data.natural_speed != 0 + if self.coordinator.data.is_on: + if self._nature_mode: + self._percentage = self.coordinator.data.natural_speed + else: + self._percentage = self.coordinator.data.direct_speed + else: + self._percentage = 0 + + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + if preset_mode not in self.preset_modes: + _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) + return + + if preset_mode == ATTR_MODE_NATURE: + await self._try_command( + "Setting natural fan speed percentage of the miio device failed.", + self._device.set_natural_speed, + self._percentage, + ) + else: + await self._try_command( + "Setting direct fan speed percentage of the miio device failed.", + self._device.set_direct_speed, + self._percentage, + ) + + self._preset_mode = preset_mode + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the percentage of the fan.""" + if percentage == 0: + self._percentage = 0 + await self.async_turn_off() + return + + if self._nature_mode: + await self._try_command( + "Setting fan speed percentage of the miio device failed.", + self._device.set_natural_speed, + percentage, + ) + else: + await self._try_command( + "Setting fan speed percentage of the miio device failed.", + self._device.set_direct_speed, + percentage, + ) + self._percentage = percentage + + if not self.is_on: + await self.async_turn_on() + else: + self.async_write_ha_state() + + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + await self._try_command( + "Setting oscillate on/off of the miio device failed.", + self._device.set_oscillate, + oscillating, + ) + self._oscillating = oscillating + self.async_write_ha_state() + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + if self._oscillating: + await self.async_oscillate(oscillating=False) + + await self._try_command( + "Setting move direction of the miio device failed.", + self._device.set_rotate, + FanMoveDirection(FAN_DIRECTIONS_MAP[direction]), + ) + + +class XiaomiFanP5(XiaomiFan): + """Representation of a Xiaomi Fan P5.""" + + @property + def preset_mode(self): + """Get the active preset mode.""" + return self._preset_mode + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._available = True + self._state = self.coordinator.data.is_on + self._preset_mode = self.coordinator.data.mode.name + self._oscillating = self.coordinator.data.oscillate + if self.coordinator.data.is_on: + self._percentage = self.coordinator.data.speed + else: + self._percentage = 0 + + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + if preset_mode not in self.preset_modes: + _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) + return + await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, + FanOperationMode[preset_mode], + ) + self._preset_mode = preset_mode + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the percentage of the fan.""" + if percentage == 0: + self._percentage = 0 + await self.async_turn_off() + return + + await self._try_command( + "Setting fan speed percentage of the miio device failed.", + self._device.set_speed, + percentage, + ) + self._percentage = percentage + + if not self.is_on: + await self.async_turn_on() + else: + self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 6d3c5e50be8..18aa7f75ce1 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.7"], - "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], + "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling" } diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index e2043be4886..af5f29306a0 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from enum import Enum from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import DEGREE, TIME_MINUTES from homeassistant.core import callback from .const import ( @@ -22,9 +23,14 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_PRO_V7, FEATURE_FLAGS_AIRPURIFIER_V1, FEATURE_FLAGS_AIRPURIFIER_V3, + FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_P5, + FEATURE_SET_DELAY_OFF_COUNTDOWN, FEATURE_SET_FAN_LEVEL, FEATURE_SET_FAVORITE_LEVEL, FEATURE_SET_MOTOR_SPEED, + FEATURE_SET_OSCILLATION_ANGLE, + FEATURE_SET_OSCILLATION_ANGLE_MAX_140, FEATURE_SET_VOLUME, KEY_COORDINATOR, KEY_DEVICE, @@ -37,14 +43,23 @@ from .const import ( MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3, + MODEL_FAN_P5, + MODEL_FAN_SA1, + MODEL_FAN_V2, + MODEL_FAN_V3, + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, MODELS_PURIFIER_MIIO, MODELS_PURIFIER_MIOT, ) from .device import XiaomiCoordinatedMiioEntity +ATTR_DELAY_OFF_COUNTDOWN = "delay_off_countdown" ATTR_FAN_LEVEL = "fan_level" ATTR_FAVORITE_LEVEL = "favorite_level" ATTR_MOTOR_SPEED = "motor_speed" +ATTR_OSCILLATION_ANGLE = "angle" ATTR_VOLUME = "volume" @@ -98,9 +113,40 @@ NUMBER_TYPES = { step=1, method="async_set_volume", ), + FEATURE_SET_OSCILLATION_ANGLE: XiaomiMiioNumberDescription( + key=ATTR_OSCILLATION_ANGLE, + name="Oscillation Angle", + icon="mdi:angle-acute", + unit_of_measurement=DEGREE, + min_value=1, + max_value=120, + step=1, + method="async_set_oscillation_angle", + ), + FEATURE_SET_OSCILLATION_ANGLE_MAX_140: XiaomiMiioNumberDescription( + key=ATTR_OSCILLATION_ANGLE, + name="Oscillation Angle", + icon="mdi:angle-acute", + unit_of_measurement=DEGREE, + min_value=30, + max_value=140, + step=30, + method="async_set_oscillation_angle", + ), + FEATURE_SET_DELAY_OFF_COUNTDOWN: XiaomiMiioNumberDescription( + key=ATTR_DELAY_OFF_COUNTDOWN, + name="Delay Off Countdown", + icon="mdi:fan-off", + unit_of_measurement=TIME_MINUTES, + min_value=0, + max_value=480, + step=1, + method="async_set_delay_off_countdown", + ), } MODEL_TO_FEATURES_MAP = { + MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH, MODEL_AIRHUMIDIFIER_CA1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, MODEL_AIRHUMIDIFIER_CA4: FEATURE_FLAGS_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, @@ -109,7 +155,13 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, - MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH, + MODEL_FAN_P5: FEATURE_FLAGS_FAN_P5, + MODEL_FAN_SA1: FEATURE_FLAGS_FAN, + MODEL_FAN_V2: FEATURE_FLAGS_FAN, + MODEL_FAN_V3: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA1: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA3: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA4: FEATURE_FLAGS_FAN, } @@ -227,3 +279,17 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): self._device.set_volume, volume, ) + + async def async_set_oscillation_angle(self, angle: int): + """Set the volume.""" + return await self._try_command( + "Setting angle of the miio device failed.", self._device.set_angle, angle + ) + + async def async_set_delay_off_countdown(self, delay_off_countdown: int): + """Set the delay off countdown.""" + return await self._try_command( + "Setting delay off miio device failed.", + self._device.delay_off, + delay_off_countdown * 60, + ) diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 9cb57e5d3d8..b43291dfeef 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -9,6 +9,7 @@ from miio.airhumidifier import LedBrightness as AirhumidifierLedBrightness from miio.airhumidifier_miot import LedBrightness as AirhumidifierMiotLedBrightness from miio.airpurifier import LedBrightness as AirpurifierLedBrightness from miio.airpurifier_miot import LedBrightness as AirpurifierMiotLedBrightness +from miio.fan import LedBrightness as FanLedBrightness from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import callback @@ -24,6 +25,12 @@ from .const import ( MODEL_AIRFRESH_VA2, MODEL_AIRPURIFIER_M1, MODEL_AIRPURIFIER_M2, + MODEL_FAN_SA1, + MODEL_FAN_V2, + MODEL_FAN_V3, + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_PURIFIER_MIOT, @@ -78,6 +85,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entity_class = XiaomiAirPurifierMiotSelector elif model == MODEL_AIRFRESH_VA2: entity_class = XiaomiAirFreshSelector + elif model in ( + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, + MODEL_FAN_SA1, + MODEL_FAN_V2, + MODEL_FAN_V3, + ): + entity_class = XiaomiFanSelector else: return @@ -210,6 +226,20 @@ class XiaomiAirPurifierMiotSelector(XiaomiAirHumidifierSelector): self.async_write_ha_state() +class XiaomiFanSelector(XiaomiAirHumidifierSelector): + """Representation of a Xiaomi Fan (MIIO protocol) selector.""" + + async def async_set_led_brightness(self, brightness: str) -> None: + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + FanLedBrightness(LED_BRIGHTNESS_MAP[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] + self.async_write_ha_state() + + class XiaomiAirFreshSelector(XiaomiAirHumidifierSelector): """Representation of a Xiaomi Air Fresh selector.""" diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index a505c23498c..63535e88a2d 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, CONF_HOST, CONF_TOKEN, + DEVICE_CLASS_BATTERY, DEVICE_CLASS_CO2, DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, @@ -59,6 +60,12 @@ from .const import ( MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V2, MODEL_AIRPURIFIER_V3, + MODEL_FAN_P5, + MODEL_FAN_V2, + MODEL_FAN_V3, + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, @@ -76,6 +83,7 @@ UNIT_LUMEN = "lm" ATTR_ACTUAL_SPEED = "actual_speed" ATTR_AIR_QUALITY = "air_quality" ATTR_AQI = "aqi" +ATTR_BATTERY = "battery" ATTR_CARBON_DIOXIDE = "co2" ATTR_CHARGING = "charging" ATTR_DISPLAY_CLOCK = "display_clock" @@ -219,6 +227,13 @@ SENSOR_TYPES = { state_class=STATE_CLASS_TOTAL_INCREASING, entity_registry_enabled_default=False, ), + ATTR_BATTERY: XiaomiMiioSensorDescription( + key=ATTR_BATTERY, + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), } HUMIDIFIER_MIIO_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE, ATTR_WATER_LEVEL) @@ -301,15 +316,22 @@ AIRFRESH_SENSORS = ( ATTR_PM25, ATTR_TEMPERATURE, ) +FAN_V2_V3_SENSORS = ( + ATTR_BATTERY, + ATTR_HUMIDITY, + ATTR_TEMPERATURE, +) MODEL_TO_SENSORS_MAP = { + MODEL_AIRFRESH_VA2: AIRFRESH_SENSORS, MODEL_AIRHUMIDIFIER_CA1: HUMIDIFIER_CA1_CB1_SENSORS, MODEL_AIRHUMIDIFIER_CB1: HUMIDIFIER_CA1_CB1_SENSORS, + MODEL_AIRPURIFIER_PRO: PURIFIER_PRO_SENSORS, + MODEL_AIRPURIFIER_PRO_V7: PURIFIER_PRO_V7_SENSORS, MODEL_AIRPURIFIER_V2: PURIFIER_V2_SENSORS, MODEL_AIRPURIFIER_V3: PURIFIER_V3_SENSORS, - MODEL_AIRPURIFIER_PRO_V7: PURIFIER_PRO_V7_SENSORS, - MODEL_AIRPURIFIER_PRO: PURIFIER_PRO_SENSORS, - MODEL_AIRFRESH_VA2: AIRFRESH_SENSORS, + MODEL_FAN_V2: FAN_V2_V3_SENSORS, + MODEL_FAN_V3: FAN_V2_V3_SENSORS, } @@ -351,6 +373,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = config_entry.data[CONF_MODEL] device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE) sensors = [] + if model in (MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, MODEL_FAN_P5): + return if model in MODEL_TO_SENSORS_MAP: sensors = MODEL_TO_SENSORS_MAP[model] elif model in MODELS_HUMIDIFIER_MIOT: diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 84503664498..c40711f5266 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -44,6 +44,8 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_PRO_V7, FEATURE_FLAGS_AIRPURIFIER_V1, FEATURE_FLAGS_AIRPURIFIER_V3, + FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_P5, FEATURE_SET_AUTO_DETECT, FEATURE_SET_BUZZER, FEATURE_SET_CHILD_LOCK, @@ -63,6 +65,10 @@ from .const import ( MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3, + MODEL_FAN_P5, + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, MODELS_FAN, MODELS_HUMIDIFIER, MODELS_HUMIDIFIER_MJJSQ, @@ -156,6 +162,10 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, + MODEL_FAN_P5: FEATURE_FLAGS_FAN_P5, + MODEL_FAN_ZA1: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA3: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA4: FEATURE_FLAGS_FAN, } From ec3bfcea46d10aa2dd7ed5c6356fcfc6732ae8d5 Mon Sep 17 00:00:00 2001 From: Giuseppe Iannello Date: Wed, 25 Aug 2021 19:12:31 +0200 Subject: [PATCH 797/903] Support for EnergyStorageTrait for vacuum cleaners (#55134) --- .../components/google_assistant/trait.py | 55 +++++++++++++++ .../components/google_assistant/test_trait.py | 69 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index e89ccaf80c4..11ae379e16a 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -28,6 +28,7 @@ from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING from homeassistant.components.media_player.const import MEDIA_TYPE_CHANNEL from homeassistant.const import ( ATTR_ASSUMED_STATE, + ATTR_BATTERY_LEVEL, ATTR_CODE, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -106,6 +107,7 @@ TRAIT_TRANSPORT_CONTROL = f"{PREFIX_TRAITS}TransportControl" TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState" TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel" TRAIT_LOCATOR = f"{PREFIX_TRAITS}Locator" +TRAIT_ENERGYSTORAGE = f"{PREFIX_TRAITS}EnergyStorage" PREFIX_COMMANDS = "action.devices.commands." COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff" @@ -148,6 +150,7 @@ COMMAND_REVERSE = f"{PREFIX_COMMANDS}Reverse" COMMAND_SET_HUMIDITY = f"{PREFIX_COMMANDS}SetHumidity" COMMAND_SELECT_CHANNEL = f"{PREFIX_COMMANDS}selectChannel" COMMAND_LOCATE = f"{PREFIX_COMMANDS}Locate" +COMMAND_CHARGE = f"{PREFIX_COMMANDS}Charge" TRAITS = [] @@ -609,6 +612,58 @@ class LocatorTrait(_Trait): ) +class EnergyStorageTrait(_Trait): + """Trait to offer EnergyStorage functionality. + + https://developers.google.com/actions/smarthome/traits/energystorage + """ + + name = TRAIT_ENERGYSTORAGE + commands = [COMMAND_CHARGE] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + return domain == vacuum.DOMAIN and features & vacuum.SUPPORT_BATTERY + + def sync_attributes(self): + """Return EnergyStorage attributes for a sync request.""" + return { + "isRechargeable": True, + "queryOnlyEnergyStorage": True, + } + + def query_attributes(self): + """Return EnergyStorage query attributes.""" + battery_level = self.state.attributes.get(ATTR_BATTERY_LEVEL) + if battery_level == 100: + descriptive_capacity_remaining = "FULL" + elif 75 <= battery_level < 100: + descriptive_capacity_remaining = "HIGH" + elif 50 <= battery_level < 75: + descriptive_capacity_remaining = "MEDIUM" + elif 25 <= battery_level < 50: + descriptive_capacity_remaining = "LOW" + elif 0 <= battery_level < 25: + descriptive_capacity_remaining = "CRITICALLY_LOW" + return { + "descriptiveCapacityRemaining": descriptive_capacity_remaining, + "capacityRemaining": [{"rawValue": battery_level, "unit": "PERCENTAGE"}], + "capacityUntilFull": [ + {"rawValue": 100 - battery_level, "unit": "PERCENTAGE"} + ], + "isCharging": self.state.state == vacuum.STATE_DOCKED, + "isPluggedIn": self.state.state == vacuum.STATE_DOCKED, + } + + async def execute(self, command, data, params, challenge): + """Execute a dock command.""" + raise SmartHomeError( + ERR_FUNCTION_NOT_SUPPORTED, + "Controlling charging of a vacuum is not yet supported", + ) + + @register_trait class StartStopTrait(_Trait): """Trait to offer StartStop functionality. diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 4ee9ee2b035..f9261fcba3f 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -34,6 +34,7 @@ from homeassistant.components.media_player.const import ( from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, + ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_MODE, @@ -387,6 +388,74 @@ async def test_locate_vacuum(hass): assert err.value.code == const.ERR_FUNCTION_NOT_SUPPORTED +async def test_energystorage_vacuum(hass): + """Test EnergyStorage trait support for vacuum domain.""" + assert helpers.get_google_type(vacuum.DOMAIN, None) is not None + assert trait.EnergyStorageTrait.supported( + vacuum.DOMAIN, vacuum.SUPPORT_BATTERY, None, None + ) + + trt = trait.EnergyStorageTrait( + hass, + State( + "vacuum.bla", + vacuum.STATE_DOCKED, + { + ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_BATTERY, + ATTR_BATTERY_LEVEL: 100, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "isRechargeable": True, + "queryOnlyEnergyStorage": True, + } + + assert trt.query_attributes() == { + "descriptiveCapacityRemaining": "FULL", + "capacityRemaining": [{"rawValue": 100, "unit": "PERCENTAGE"}], + "capacityUntilFull": [{"rawValue": 0, "unit": "PERCENTAGE"}], + "isCharging": True, + "isPluggedIn": True, + } + + trt = trait.EnergyStorageTrait( + hass, + State( + "vacuum.bla", + vacuum.STATE_CLEANING, + { + ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_BATTERY, + ATTR_BATTERY_LEVEL: 20, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "isRechargeable": True, + "queryOnlyEnergyStorage": True, + } + + assert trt.query_attributes() == { + "descriptiveCapacityRemaining": "CRITICALLY_LOW", + "capacityRemaining": [{"rawValue": 20, "unit": "PERCENTAGE"}], + "capacityUntilFull": [{"rawValue": 80, "unit": "PERCENTAGE"}], + "isCharging": False, + "isPluggedIn": False, + } + + with pytest.raises(helpers.SmartHomeError) as err: + await trt.execute(trait.COMMAND_CHARGE, BASIC_DATA, {"charge": True}, {}) + assert err.value.code == const.ERR_FUNCTION_NOT_SUPPORTED + + with pytest.raises(helpers.SmartHomeError) as err: + await trt.execute(trait.COMMAND_CHARGE, BASIC_DATA, {"charge": False}, {}) + assert err.value.code == const.ERR_FUNCTION_NOT_SUPPORTED + + async def test_startstop_vacuum(hass): """Test startStop trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None From d4b506e5e46f3c233e863fed8b0e536f7a51cce6 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Wed, 25 Aug 2021 19:25:39 +0200 Subject: [PATCH 798/903] Add tests for Rituals init, sensor and switch (#52406) --- .coveragerc | 4 - .../rituals_perfume_genie/common.py | 94 ++++++++++++++++ .../rituals_perfume_genie/test_init.py | 34 ++++++ .../rituals_perfume_genie/test_sensor.py | 88 +++++++++++++++ .../rituals_perfume_genie/test_switch.py | 104 ++++++++++++++++++ 5 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 tests/components/rituals_perfume_genie/common.py create mode 100644 tests/components/rituals_perfume_genie/test_init.py create mode 100644 tests/components/rituals_perfume_genie/test_sensor.py create mode 100644 tests/components/rituals_perfume_genie/test_switch.py diff --git a/.coveragerc b/.coveragerc index e11a268217b..70a74e0a356 100644 --- a/.coveragerc +++ b/.coveragerc @@ -865,12 +865,8 @@ omit = homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py homeassistant/components/rituals_perfume_genie/binary_sensor.py - homeassistant/components/rituals_perfume_genie/entity.py homeassistant/components/rituals_perfume_genie/number.py homeassistant/components/rituals_perfume_genie/select.py - homeassistant/components/rituals_perfume_genie/sensor.py - homeassistant/components/rituals_perfume_genie/switch.py - homeassistant/components/rituals_perfume_genie/__init__.py homeassistant/components/rocketchat/notify.py homeassistant/components/roomba/__init__.py homeassistant/components/roomba/binary_sensor.py diff --git a/tests/components/rituals_perfume_genie/common.py b/tests/components/rituals_perfume_genie/common.py new file mode 100644 index 00000000000..35555e2b842 --- /dev/null +++ b/tests/components/rituals_perfume_genie/common.py @@ -0,0 +1,94 @@ +"""Common methods used across tests for Rituals Perfume Genie.""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def mock_config_entry(uniqe_id: str, entry_id: str = "an_entry_id") -> MockConfigEntry: + """Return a mock Config Entry for the Rituals Perfume Genie integration.""" + return MockConfigEntry( + domain=DOMAIN, + title="name@example.com", + unique_id=uniqe_id, + data={ACCOUNT_HASH: "an_account_hash"}, + entry_id=entry_id, + ) + + +def mock_diffuser( + hublot: str, + available: bool = True, + battery_percentage: int | Exception = 100, + charging: bool | Exception = True, + fill: str = "90-100%", + has_battery: bool = True, + has_cartridge: bool = True, + is_on: bool = True, + name: str = "Genie", + perfume: str = "Ritual of Sakura", + version: str = "4.0", + wifi_percentage: int = 75, +) -> MagicMock: + """Return a mock Diffuser initialized with the given data.""" + diffuser_mock = MagicMock() + diffuser_mock.available = available + diffuser_mock.battery_percentage = battery_percentage + diffuser_mock.charging = charging + diffuser_mock.fill = fill + diffuser_mock.has_battery = has_battery + diffuser_mock.has_cartridge = has_cartridge + diffuser_mock.hublot = hublot + diffuser_mock.is_on = is_on + diffuser_mock.name = name + diffuser_mock.perfume = perfume + diffuser_mock.turn_off = AsyncMock() + diffuser_mock.turn_on = AsyncMock() + diffuser_mock.update_data = AsyncMock() + diffuser_mock.version = version + diffuser_mock.wifi_percentage = wifi_percentage + return diffuser_mock + + +def mock_diffuser_v1_battery_cartridge(): + """Create and return a mock version 1 Diffuser with battery and a cartridge.""" + return mock_diffuser(hublot="lot123v1") + + +def mock_diffuser_v2_no_battery_no_cartridge(): + """Create and return a mock version 2 Diffuser without battery and cartridge.""" + return mock_diffuser( + hublot="lot123v2", + battery_percentage=Exception(), + charging=Exception(), + has_battery=False, + has_cartridge=False, + name="Genie V2", + perfume="No Cartridge", + version="5.0", + ) + + +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_diffusers: list[MagicMock] = [mock_diffuser(hublot="lot123")], +) -> None: + """Initialize the Rituals Perfume Genie integration with the given Config Entry and Diffuser list.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.rituals_perfume_genie.Account.get_devices", + return_value=mock_diffusers, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert hass.data[DOMAIN] + + await hass.async_block_till_done() diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py new file mode 100644 index 00000000000..887417a41f8 --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -0,0 +1,34 @@ +"""Tests for the Rituals Perfume Genie integration.""" +from unittest.mock import patch + +import aiohttp + +from homeassistant.components.rituals_perfume_genie.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .common import init_integration, mock_config_entry + + +async def test_config_entry_not_ready(hass: HomeAssistant): + """Test the Rituals configuration entry setup if connection to Rituals is missing.""" + config_entry = mock_config_entry(uniqe_id="id_123_not_ready") + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.rituals_perfume_genie.Account.get_devices", + side_effect=aiohttp.ClientError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_unload(hass: HomeAssistant) -> None: + """Test the Rituals Perfume Genie configuration entry setup and unloading.""" + config_entry = mock_config_entry(uniqe_id="id_123_unload") + await init_integration(hass, config_entry) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert config_entry.entry_id not in hass.data[DOMAIN] diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py new file mode 100644 index 00000000000..477353d3b83 --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -0,0 +1,88 @@ +"""Tests for the Rituals Perfume Genie sensor platform.""" +from homeassistant.components.rituals_perfume_genie.sensor import ( + BATTERY_SUFFIX, + FILL_SUFFIX, + PERFUME_SUFFIX, + WIFI_SUFFIX, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_SIGNAL_STRENGTH, + PERCENTAGE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry + +from .common import ( + init_integration, + mock_config_entry, + mock_diffuser_v1_battery_cartridge, + mock_diffuser_v2_no_battery_no_cartridge, +) + + +async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> None: + """Test the creation and values of the Rituals Perfume Genie sensors.""" + config_entry = mock_config_entry(uniqe_id="id_123_sensor_test_diffuser_v1") + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + registry = entity_registry.async_get(hass) + hublot = diffuser.hublot + + state = hass.states.get("sensor.genie_perfume") + assert state + assert state.state == diffuser.perfume + assert state.attributes.get(ATTR_ICON) == "mdi:tag-text" + + entry = registry.async_get("sensor.genie_perfume") + assert entry + assert entry.unique_id == f"{hublot}{PERFUME_SUFFIX}" + + state = hass.states.get("sensor.genie_fill") + assert state + assert state.state == diffuser.fill + assert state.attributes.get(ATTR_ICON) == "mdi:beaker" + + entry = registry.async_get("sensor.genie_fill") + assert entry + assert entry.unique_id == f"{hublot}{FILL_SUFFIX}" + + state = hass.states.get("sensor.genie_battery") + assert state + assert state.state == str(diffuser.battery_percentage) + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_BATTERY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + + entry = registry.async_get("sensor.genie_battery") + assert entry + assert entry.unique_id == f"{hublot}{BATTERY_SUFFIX}" + + state = hass.states.get("sensor.genie_wifi") + assert state + assert state.state == str(diffuser.wifi_percentage) + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SIGNAL_STRENGTH + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + + entry = registry.async_get("sensor.genie_wifi") + assert entry + assert entry.unique_id == f"{hublot}{WIFI_SUFFIX}" + + +async def test_sensors_diffuser_v2_no_battery_no_cartridge(hass: HomeAssistant) -> None: + """Test the creation and values of the Rituals Perfume Genie sensors.""" + config_entry = mock_config_entry(uniqe_id="id_123_sensor_test_diffuser_v2") + + await init_integration( + hass, config_entry, [mock_diffuser_v2_no_battery_no_cartridge()] + ) + + state = hass.states.get("sensor.genie_v2_perfume") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:tag-remove" + + state = hass.states.get("sensor.genie_v2_fill") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:beaker-question" diff --git a/tests/components/rituals_perfume_genie/test_switch.py b/tests/components/rituals_perfume_genie/test_switch.py new file mode 100644 index 00000000000..a2691da0e0e --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_switch.py @@ -0,0 +1,104 @@ +"""Tests for the Rituals Perfume Genie switch platform.""" +from __future__ import annotations + +from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.rituals_perfume_genie.const import COORDINATORS, DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component + +from .common import ( + init_integration, + mock_config_entry, + mock_diffuser_v1_battery_cartridge, +) + + +async def test_switch_entity(hass: HomeAssistant) -> None: + """Test the creation and values of the Rituals Perfume Genie diffuser switch.""" + config_entry = mock_config_entry(uniqe_id="id_123_switch_set_state_test") + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + + registry = entity_registry.async_get(hass) + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ICON) == "mdi:fan" + + entry = registry.async_get("switch.genie") + assert entry + assert entry.unique_id == diffuser.hublot + + +async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: + """Test handling a coordinator update.""" + config_entry = mock_config_entry(uniqe_id="id_123_switch_set_state_test") + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS]["lot123v1"] + diffuser.is_on = False + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_ON + + call_count_before_update = diffuser.update_data.call_count + + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["switch.genie"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_OFF + + assert coordinator.last_update_success + assert diffuser.update_data.call_count == call_count_before_update + 1 + + +async def test_set_switch_state(hass: HomeAssistant) -> None: + """Test changing the diffuser switch entity state.""" + config_entry = mock_config_entry(uniqe_id="id_123_switch_set_state_test") + await init_integration(hass, config_entry, [mock_diffuser_v1_battery_cartridge()]) + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.genie"}, + blocking=True, + ) + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.genie"}, + blocking=True, + ) + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_ON From e062d7aec0808d593d9134c1a54c56d2979a408c Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 25 Aug 2021 18:29:34 +0100 Subject: [PATCH 799/903] Honeywell Lyric - Entity Descriptions (#54956) Co-authored-by: Paulus Schoutsen --- homeassistant/components/lyric/__init__.py | 20 +- homeassistant/components/lyric/climate.py | 18 +- homeassistant/components/lyric/sensor.py | 301 ++++++++------------- 3 files changed, 135 insertions(+), 204 deletions(-) diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 4afb66f7173..07c5bfeaf89 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -140,18 +140,12 @@ class LyricEntity(CoordinatorEntity): location: LyricLocation, device: LyricDevice, key: str, - name: str, - icon: str | None, ) -> None: """Initialize the Honeywell Lyric entity.""" super().__init__(coordinator) self._key = key - self._name = name - self._icon = icon self._location = location self._mac_id = device.macID - self._device_name = device.name - self._device_model = device.deviceModel self._update_thermostat = coordinator.data.update_thermostat @property @@ -159,16 +153,6 @@ class LyricEntity(CoordinatorEntity): """Return the unique ID for this entity.""" return self._key - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - @property def location(self) -> LyricLocation: """Get the Lyric Location.""" @@ -189,6 +173,6 @@ class LyricDeviceEntity(LyricEntity): return { "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, "manufacturer": "Honeywell", - "model": self._device_model, - "name": self._device_name, + "model": self.device.deviceModel, + "name": self.device.name, } diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 955afe140c9..940740bd397 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -8,7 +8,7 @@ from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation import voluptuous as vol -from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate import ClimateEntity, ClimateEntityDescription from homeassistant.components.climate.const import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -99,7 +99,14 @@ async def async_setup_entry( for device in location.devices: entities.append( LyricClimate( - coordinator, location, device, hass.config.units.temperature_unit + coordinator, + ClimateEntityDescription( + key=f"{device.macID}_thermostat", + name=device.name, + ), + location, + device, + hass.config.units.temperature_unit, ) ) @@ -117,9 +124,13 @@ async def async_setup_entry( class LyricClimate(LyricDeviceEntity, ClimateEntity): """Defines a Honeywell Lyric climate entity.""" + coordinator: DataUpdateCoordinator + entity_description: ClimateEntityDescription + def __init__( self, coordinator: DataUpdateCoordinator, + description: ClimateEntityDescription, location: LyricLocation, device: LyricDevice, temperature_unit: str, @@ -148,9 +159,8 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): location, device, f"{device.macID}_thermostat", - device.name, - None, ) + self.entity_description = description @property def supported_features(self) -> int: diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 868b6262ddc..b5b0ffdeb3d 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -1,10 +1,16 @@ """Support for Honeywell Lyric sensor platform.""" +from dataclasses import dataclass from datetime import datetime, timedelta +from typing import Callable, cast from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, @@ -12,6 +18,7 @@ from homeassistant.const import ( DEVICE_CLASS_TIMESTAMP, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -33,6 +40,22 @@ LYRIC_SETPOINT_STATUS_NAMES = { } +@dataclass +class LyricSensorEntityDescription(SensorEntityDescription): + """Class describing Honeywell Lyric sensor entities.""" + + value: Callable[[LyricDevice], StateType] = round + + +def get_datetime_from_future_time(time: str) -> datetime: + """Get datetime from future time provided.""" + time = dt_util.parse_time(time) + now = dt_util.utcnow() + if time <= now.time(): + now = now + timedelta(days=1) + return dt_util.as_utc(datetime.combine(now.date(), time)) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ) -> None: @@ -41,212 +64,126 @@ async def async_setup_entry( entities = [] + def get_setpoint_status(status: str, time: str) -> str: + if status == PRESET_HOLD_UNTIL: + return f"Held until {time}" + return LYRIC_SETPOINT_STATUS_NAMES.get(status, None) + for location in coordinator.data.locations: for device in location.devices: - cls_list = [] if device.indoorTemperature: - cls_list.append(LyricIndoorTemperatureSensor) - if device.outdoorTemperature: - cls_list.append(LyricOutdoorTemperatureSensor) - if device.displayedOutdoorHumidity: - cls_list.append(LyricOutdoorHumiditySensor) - if device.changeableValues: - if device.changeableValues.nextPeriodTime: - cls_list.append(LyricNextPeriodSensor) - if device.changeableValues.thermostatSetpointStatus: - cls_list.append(LyricSetpointStatusSensor) - for cls in cls_list: entities.append( - cls( + LyricSensor( coordinator, + LyricSensorEntityDescription( + key=f"{device.macID}_indoor_temperature", + name="Indoor Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=hass.config.units.temperature_unit, + value=lambda device: device.indoorTemperature, + ), location, device, - hass.config.units.temperature_unit, ) ) + if device.outdoorTemperature: + entities.append( + LyricSensor( + coordinator, + LyricSensorEntityDescription( + key=f"{device.macID}_outdoor_temperature", + name="Outdoor Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=hass.config.units.temperature_unit, + value=lambda device: device.outdoorTemperature, + ), + location, + device, + ) + ) + if device.displayedOutdoorHumidity: + entities.append( + LyricSensor( + coordinator, + LyricSensorEntityDescription( + key=f"{device.macID}_outdoor_humidity", + name="Outdoor Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement="%", + value=lambda device: device.displayedOutdoorHumidity, + ), + location, + device, + ) + ) + if device.changeableValues: + if device.changeableValues.nextPeriodTime: + entities.append( + LyricSensor( + coordinator, + LyricSensorEntityDescription( + key=f"{device.macID}_next_period_time", + name="Next Period Time", + device_class=DEVICE_CLASS_TIMESTAMP, + value=lambda device: get_datetime_from_future_time( + device.changeableValues.nextPeriodTime + ).isoformat(), + ), + location, + device, + ) + ) + if device.changeableValues.thermostatSetpointStatus: + entities.append( + LyricSensor( + coordinator, + LyricSensorEntityDescription( + key=f"{device.macID}_setpoint_status", + name="Setpoint Status", + icon="mdi:thermostat", + value=lambda device: get_setpoint_status( + device.changeableValues.thermostatSetpointStatus, + device.changeableValues.nextPeriodTime, + ), + ), + location, + device, + ) + ) async_add_entities(entities, True) class LyricSensor(LyricDeviceEntity, SensorEntity): - """Defines a Honeywell Lyric sensor.""" + """Define a Honeywell Lyric sensor.""" + + coordinator: DataUpdateCoordinator + entity_description: LyricSensorEntityDescription def __init__( self, coordinator: DataUpdateCoordinator, + description: LyricSensorEntityDescription, location: LyricLocation, device: LyricDevice, - key: str, - name: str, - icon: str, - device_class: str = None, - unit_of_measurement: str = None, ) -> None: - """Initialize Honeywell Lyric sensor.""" - self._device_class = device_class - self._unit_of_measurement = unit_of_measurement - - super().__init__(coordinator, location, device, key, name, icon) - - @property - def device_class(self) -> str: - """Return the device class of the sensor.""" - return self._device_class - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - -class LyricIndoorTemperatureSensor(LyricSensor): - """Defines a Honeywell Lyric sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - location: LyricLocation, - device: LyricDevice, - unit_of_measurement: str = None, - ) -> None: - """Initialize Honeywell Lyric sensor.""" - + """Initialize.""" super().__init__( coordinator, location, device, - f"{device.macID}_indoor_temperature", - "Indoor Temperature", - None, - DEVICE_CLASS_TEMPERATURE, - unit_of_measurement, + description.key, ) + self.entity_description = description @property - def native_value(self) -> str: - """Return the state of the sensor.""" - return self.device.indoorTemperature - - -class LyricOutdoorTemperatureSensor(LyricSensor): - """Defines a Honeywell Lyric sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - location: LyricLocation, - device: LyricDevice, - unit_of_measurement: str = None, - ) -> None: - """Initialize Honeywell Lyric sensor.""" - - super().__init__( - coordinator, - location, - device, - f"{device.macID}_outdoor_temperature", - "Outdoor Temperature", - None, - DEVICE_CLASS_TEMPERATURE, - unit_of_measurement, - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - return self.device.outdoorTemperature - - -class LyricOutdoorHumiditySensor(LyricSensor): - """Defines a Honeywell Lyric sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - location: LyricLocation, - device: LyricDevice, - unit_of_measurement: str = None, - ) -> None: - """Initialize Honeywell Lyric sensor.""" - - super().__init__( - coordinator, - location, - device, - f"{device.macID}_outdoor_humidity", - "Outdoor Humidity", - None, - DEVICE_CLASS_HUMIDITY, - "%", - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - return self.device.displayedOutdoorHumidity - - -class LyricNextPeriodSensor(LyricSensor): - """Defines a Honeywell Lyric sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - location: LyricLocation, - device: LyricDevice, - unit_of_measurement: str = None, - ) -> None: - """Initialize Honeywell Lyric sensor.""" - - super().__init__( - coordinator, - location, - device, - f"{device.macID}_next_period_time", - "Next Period Time", - None, - DEVICE_CLASS_TIMESTAMP, - ) - - @property - def native_value(self) -> datetime: - """Return the state of the sensor.""" - device = self.device - time = dt_util.parse_time(device.changeableValues.nextPeriodTime) - now = dt_util.utcnow() - if time <= now.time(): - now = now + timedelta(days=1) - return dt_util.as_utc(datetime.combine(now.date(), time)) - - -class LyricSetpointStatusSensor(LyricSensor): - """Defines a Honeywell Lyric sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - location: LyricLocation, - device: LyricDevice, - unit_of_measurement: str = None, - ) -> None: - """Initialize Honeywell Lyric sensor.""" - - super().__init__( - coordinator, - location, - device, - f"{device.macID}_setpoint_status", - "Setpoint Status", - "mdi:thermostat", - None, - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - device = self.device - if device.changeableValues.thermostatSetpointStatus == PRESET_HOLD_UNTIL: - return f"Held until {device.changeableValues.nextPeriodTime}" - return LYRIC_SETPOINT_STATUS_NAMES.get( - device.changeableValues.thermostatSetpointStatus, "Unknown" - ) + def native_value(self) -> StateType: + """Return the state.""" + device: LyricDevice = self.device + try: + return cast(StateType, self.entity_description.value(device)) + except TypeError: + return None From 8407ad01d463adafe5241336506f0179c4f85abf Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 25 Aug 2021 14:21:24 -0400 Subject: [PATCH 800/903] Add select platform to template integration (#54835) --- .../components/template/binary_sensor.py | 2 +- homeassistant/components/template/config.py | 10 +- homeassistant/components/template/const.py | 1 + homeassistant/components/template/select.py | 199 ++++++++++++++ .../components/template/trigger_entity.py | 19 +- tests/components/template/test_select.py | 258 ++++++++++++++++++ 6 files changed, 483 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/template/select.py create mode 100644 tests/components/template/test_select.py diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 4d316388eae..9a4c3f93f63 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -358,7 +358,7 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity): for key in (CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF): if isinstance(config.get(key), template.Template): - self._to_render.append(key) + self._to_render_simple.append(key) self._parse_result.add(key) self._delay_cancel = None diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 165420bf404..22b0dc4a5b1 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -4,13 +4,18 @@ import logging import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config import async_log_exception, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import async_validate_trigger_config -from . import binary_sensor as binary_sensor_platform, sensor as sensor_platform +from . import ( + binary_sensor as binary_sensor_platform, + select as select_platform, + sensor as sensor_platform, +) from .const import CONF_TRIGGER, DOMAIN PACKAGE_MERGE_HINT = "list" @@ -31,6 +36,9 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA ), + vol.Optional(SELECT_DOMAIN): vol.All( + cv.ensure_list, [select_platform.SELECT_SCHEMA] + ), } ) diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 31896e930e4..44b55581e9d 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -15,6 +15,7 @@ PLATFORMS = [ "fan", "light", "lock", + "select", "sensor", "switch", "vacuum", diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py new file mode 100644 index 00000000000..944c80cbfa4 --- /dev/null +++ b/homeassistant/components/template/select.py @@ -0,0 +1,199 @@ +"""Support for selects which integrates with other components.""" +from __future__ import annotations + +import contextlib +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.select import SelectEntity +from homeassistant.components.select.const import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, +) +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID +from homeassistant.core import Config, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.script import Script +from homeassistant.helpers.template import Template, TemplateError + +from . import TriggerUpdateCoordinator +from .const import CONF_AVAILABILITY +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity + +_LOGGER = logging.getLogger(__name__) + +CONF_SELECT_OPTION = "select_option" + +DEFAULT_NAME = "Template Select" +DEFAULT_OPTIMISTIC = False + +SELECT_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, + vol.Required(ATTR_OPTIONS): cv.template, + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +async def _async_create_entities( + hass: HomeAssistant, entities: list[dict[str, Any]], unique_id_prefix: str | None +) -> list[TemplateSelect]: + """Create the Template select.""" + for entity in entities: + unique_id = entity.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + + return [ + TemplateSelect( + hass, + entity.get(CONF_NAME, DEFAULT_NAME), + entity[CONF_STATE], + entity.get(CONF_AVAILABILITY), + entity[CONF_SELECT_OPTION], + entity[ATTR_OPTIONS], + entity.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC), + unique_id, + ) + ] + + +async def async_setup_platform( + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up the template select.""" + if discovery_info is None: + _LOGGER.warning( + "Template number entities can only be configured under template:" + ) + return + + if "coordinator" in discovery_info: + async_add_entities( + TriggerSelectEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + + async_add_entities( + await _async_create_entities( + hass, discovery_info["entities"], discovery_info["unique_id"] + ) + ) + + +class TemplateSelect(TemplateEntity, SelectEntity): + """Representation of a template select.""" + + def __init__( + self, + hass: HomeAssistant, + name_template: Template | None, + value_template: Template, + availability_template: Template | None, + command_select_option: dict[str, Any], + options_template: Template, + optimistic: bool, + unique_id: str | None, + ) -> None: + """Initialize the select.""" + super().__init__(availability_template=availability_template) + self._attr_name = DEFAULT_NAME + name_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_name = name_template.async_render(parse_result=False) + self._name_template = name_template + self._value_template = value_template + domain = __name__.split(".")[-2] + self._command_select_option = Script( + hass, command_select_option, self._attr_name, domain + ) + self._options_template = options_template + self._attr_assumed_state = self._optimistic = optimistic + self._attr_unique_id = unique_id + self._attr_options = None + self._attr_current_option = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.add_template_attribute( + "_attr_current_option", + self._value_template, + validator=cv.string, + none_on_template_error=True, + ) + self.add_template_attribute( + "_attr_options", + self._options_template, + validator=vol.All(cv.ensure_list, [cv.string]), + none_on_template_error=True, + ) + if self._name_template and not self._name_template.is_static: + self.add_template_attribute("_attr_name", self._name_template, cv.string) + await super().async_added_to_hass() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if self._optimistic: + self._attr_current_option = option + self.async_write_ha_state() + await self._command_select_option.async_run( + {ATTR_OPTION: option}, context=self._context + ) + + +class TriggerSelectEntity(TriggerEntity, SelectEntity): + """Select entity based on trigger data.""" + + domain = SELECT_DOMAIN + extra_template_keys = (CONF_STATE,) + extra_template_keys_complex = (ATTR_OPTIONS,) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: dict, + ) -> None: + """Initialize the entity.""" + super().__init__(hass, coordinator, config) + domain = __name__.split(".")[-2] + self._command_select_option = Script( + hass, + config[CONF_SELECT_OPTION], + self._rendered.get(CONF_NAME, DEFAULT_NAME), + domain, + ) + + @property + def current_option(self) -> str | None: + """Return the currently selected option.""" + return self._rendered.get(CONF_STATE) + + @property + def options(self) -> list[str]: + """Return the list of available options.""" + return self._rendered.get(ATTR_OPTIONS, []) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if self._config[CONF_OPTIMISTIC]: + self._attr_current_option = option + self.async_write_ha_state() + await self._command_select_option.async_run( + {ATTR_OPTION: option}, context=self._context + ) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index ee9c60293df..84ad4072b66 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -23,6 +23,7 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): domain = "" extra_template_keys: tuple | None = None + extra_template_keys_complex: tuple | None = None def __init__( self, @@ -43,7 +44,8 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): self._config = config self._static_rendered = {} - self._to_render = [] + self._to_render_simple = [] + self._to_render_complex = [] for itm in ( CONF_NAME, @@ -57,10 +59,13 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): if config[itm].is_static: self._static_rendered[itm] = config[itm].template else: - self._to_render.append(itm) + self._to_render_simple.append(itm) if self.extra_template_keys is not None: - self._to_render.extend(self.extra_template_keys) + self._to_render_simple.extend(self.extra_template_keys) + + if self.extra_template_keys_complex is not None: + self._to_render_complex.extend(self.extra_template_keys_complex) # We make a copy so our initial render is 'unknown' and not 'unavailable' self._rendered = dict(self._static_rendered) @@ -124,12 +129,18 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): try: rendered = dict(self._static_rendered) - for key in self._to_render: + for key in self._to_render_simple: rendered[key] = self._config[key].async_render( self.coordinator.data["run_variables"], parse_result=key in self._parse_result, ) + for key in self._to_render_complex: + rendered[key] = template.render_complex( + self._config[key], + self.coordinator.data["run_variables"], + ) + if CONF_ATTRIBUTES in self._config: rendered[CONF_ATTRIBUTES] = template.render_complex( self._config[CONF_ATTRIBUTES], diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py new file mode 100644 index 00000000000..eb94a9284f4 --- /dev/null +++ b/tests/components/template/test_select.py @@ -0,0 +1,258 @@ +"""The tests for the Template select platform.""" +import pytest + +from homeassistant import setup +from homeassistant.components.input_select import ( + ATTR_OPTION as INPUT_SELECT_ATTR_OPTION, + ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS, + DOMAIN as INPUT_SELECT_DOMAIN, + SERVICE_SELECT_OPTION as INPUT_SELECT_SERVICE_SELECT_OPTION, + SERVICE_SET_OPTIONS, +) +from homeassistant.components.select.const import ( + ATTR_OPTION as SELECT_ATTR_OPTION, + ATTR_OPTIONS as SELECT_ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, +) +from homeassistant.const import CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import Context +from homeassistant.helpers.entity_registry import async_get + +from tests.common import ( + assert_setup_component, + async_capture_events, + async_mock_service, +) + +_TEST_SELECT = "select.template_select" +# Represent for select's current_option +_OPTION_INPUT_SELECT = "input_select.option" + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_missing_optional_config(hass, calls): + """Test: missing optional template is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "select": { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, "a", ["a", "b"]) + + +async def test_missing_required_keys(hass, calls): + """Test: missing required fields will fail.""" + with assert_setup_component(0, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "select": { + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + } + } + }, + ) + + with assert_setup_component(0, "select"): + assert await setup.async_setup_component( + hass, + "select", + { + "template": { + "select": { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + } + } + }, + ) + + with assert_setup_component(0, "select"): + assert await setup.async_setup_component( + hass, + "select", + { + "template": { + "select": { + "state": "{{ 'a' }}", + "options": "{{ ['a', 'b'] }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_templates_with_entities(hass, calls): + """Test tempalates with values from other entities.""" + with assert_setup_component(1, "input_select"): + assert await setup.async_setup_component( + hass, + "input_select", + { + "input_select": { + "option": { + "options": ["a", "b"], + "initial": "a", + "name": "Option", + }, + } + }, + ) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "unique_id": "b", + "select": { + "state": f"{{{{ states('{_OPTION_INPUT_SELECT}') }}}}", + "options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}", + "select_option": { + "service": "input_select.select_option", + "data_template": { + "entity_id": _OPTION_INPUT_SELECT, + "option": "{{ option }}", + }, + }, + "optimistic": True, + "unique_id": "a", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + ent_reg = async_get(hass) + entry = ent_reg.async_get(_TEST_SELECT) + assert entry + assert entry.unique_id == "b-a" + + _verify(hass, "a", ["a", "b"]) + + await hass.services.async_call( + INPUT_SELECT_DOMAIN, + INPUT_SELECT_SERVICE_SELECT_OPTION, + {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, "b", ["a", "b"]) + + await hass.services.async_call( + INPUT_SELECT_DOMAIN, + SERVICE_SET_OPTIONS, + { + CONF_ENTITY_ID: _OPTION_INPUT_SELECT, + INPUT_SELECT_ATTR_OPTIONS: ["a", "b", "c"], + }, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, "a", ["a", "b", "c"]) + + await hass.services.async_call( + SELECT_DOMAIN, + SELECT_SERVICE_SELECT_OPTION, + {CONF_ENTITY_ID: _TEST_SELECT, SELECT_ATTR_OPTION: "c"}, + blocking=True, + ) + _verify(hass, "c", ["a", "b", "c"]) + + +async def test_trigger_select(hass): + """Test trigger based template select.""" + events = async_capture_events(hass, "test_number_event") + assert await setup.async_setup_component( + hass, + "template", + { + "template": [ + {"invalid": "config"}, + # Config after invalid should still be set up + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "select": [ + { + "name": "Hello Name", + "unique_id": "hello_name-id", + "state": "{{ trigger.event.data.beer }}", + "options": "{{ trigger.event.data.beers }}", + "select_option": {"event": "test_number_event"}, + "optimistic": True, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("select.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire( + "test_event", {"beer": "duff", "beers": ["duff", "alamo"]}, context=context + ) + await hass.async_block_till_done() + + state = hass.states.get("select.hello_name") + assert state is not None + assert state.state == "duff" + assert state.attributes["options"] == ["duff", "alamo"] + + await hass.services.async_call( + SELECT_DOMAIN, + SELECT_SERVICE_SELECT_OPTION, + {CONF_ENTITY_ID: "select.hello_name", SELECT_ATTR_OPTION: "alamo"}, + blocking=True, + ) + assert len(events) == 1 + assert events[0].event_type == "test_number_event" + + +def _verify(hass, expected_current_option, expected_options): + """Verify select's state.""" + state = hass.states.get(_TEST_SELECT) + attributes = state.attributes + assert state.state == str(expected_current_option) + assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options From e9625e4b7a6f3a23fb06ca94d4db15dc5fd331e6 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 25 Aug 2021 14:34:20 -0400 Subject: [PATCH 801/903] Add number platform to template integration (#54789) --- homeassistant/components/template/config.py | 5 + homeassistant/components/template/const.py | 1 + homeassistant/components/template/number.py | 245 ++++++++++++++ tests/components/template/test_number.py | 336 ++++++++++++++++++++ 4 files changed, 587 insertions(+) create mode 100644 homeassistant/components/template/number.py create mode 100644 tests/components/template/test_number.py diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 22b0dc4a5b1..4bcda6b6752 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -4,6 +4,7 @@ import logging import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config import async_log_exception, config_without_domain @@ -13,6 +14,7 @@ from homeassistant.helpers.trigger import async_validate_trigger_config from . import ( binary_sensor as binary_sensor_platform, + number as number_platform, select as select_platform, sensor as sensor_platform, ) @@ -24,6 +26,9 @@ CONFIG_SECTION_SCHEMA = vol.Schema( { vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Optional(NUMBER_DOMAIN): vol.All( + cv.ensure_list, [number_platform.NUMBER_SCHEMA] + ), vol.Optional(SENSOR_DOMAIN): vol.All( cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] ), diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 44b55581e9d..0309321afbc 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -15,6 +15,7 @@ PLATFORMS = [ "fan", "light", "lock", + "number", "select", "sensor", "switch", diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py new file mode 100644 index 00000000000..a7737c31246 --- /dev/null +++ b/homeassistant/components/template/number.py @@ -0,0 +1,245 @@ +"""Support for numbers which integrates with other components.""" +from __future__ import annotations + +import contextlib +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.number import NumberEntity +from homeassistant.components.number.const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE, + DEFAULT_MAX_VALUE, + DEFAULT_MIN_VALUE, + DOMAIN as NUMBER_DOMAIN, +) +from homeassistant.components.template import TriggerUpdateCoordinator +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID +from homeassistant.core import Config, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.script import Script +from homeassistant.helpers.template import Template, TemplateError + +from .const import CONF_AVAILABILITY +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity + +_LOGGER = logging.getLogger(__name__) + +CONF_SET_VALUE = "set_value" + +DEFAULT_NAME = "Template Number" +DEFAULT_OPTIMISTIC = False + +NUMBER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, + vol.Required(ATTR_STEP): cv.template, + vol.Optional(ATTR_MIN, default=DEFAULT_MIN_VALUE): cv.template, + vol.Optional(ATTR_MAX, default=DEFAULT_MAX_VALUE): cv.template, + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +async def _async_create_entities( + hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None +) -> list[TemplateNumber]: + """Create the Template number.""" + entities = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + entities.append( + TemplateNumber( + hass, + definition[CONF_NAME], + definition[CONF_STATE], + definition.get(CONF_AVAILABILITY), + definition[CONF_SET_VALUE], + definition[ATTR_STEP], + definition[ATTR_MIN], + definition[ATTR_MAX], + definition[CONF_OPTIMISTIC], + unique_id, + ) + ) + return entities + + +async def async_setup_platform( + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up the template number.""" + if discovery_info is None: + _LOGGER.warning( + "Template number entities can only be configured under template:" + ) + return + + if "coordinator" in discovery_info: + async_add_entities( + TriggerNumberEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + + async_add_entities( + await _async_create_entities( + hass, discovery_info["entities"], discovery_info["unique_id"] + ) + ) + + +class TemplateNumber(TemplateEntity, NumberEntity): + """Representation of a template number.""" + + def __init__( + self, + hass: HomeAssistant, + name_template: Template, + value_template: Template, + availability_template: Template | None, + command_set_value: dict[str, Any], + step_template: Template, + minimum_template: Template | None, + maximum_template: Template | None, + optimistic: bool, + unique_id: str | None, + ) -> None: + """Initialize the number.""" + super().__init__(availability_template=availability_template) + self._attr_name = DEFAULT_NAME + self._name_template = name_template + name_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_name = name_template.async_render(parse_result=False) + self._value_template = value_template + domain = __name__.split(".")[-2] + self._command_set_value = Script( + hass, command_set_value, self._attr_name, domain + ) + self._step_template = step_template + self._min_value_template = minimum_template + self._max_value_template = maximum_template + self._attr_assumed_state = self._optimistic = optimistic + self._attr_unique_id = unique_id + self._attr_value = None + self._attr_step = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + if self._name_template and not self._name_template.is_static: + self.add_template_attribute("_attr_name", self._name_template, cv.string) + self.add_template_attribute( + "_attr_value", + self._value_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + self.add_template_attribute( + "_attr_step", + self._step_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + if self._min_value_template is not None: + self.add_template_attribute( + "_attr_min_value", + self._min_value_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + if self._max_value_template is not None: + self.add_template_attribute( + "_attr_max_value", + self._max_value_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + await super().async_added_to_hass() + + async def async_set_value(self, value: float) -> None: + """Set value of the number.""" + if self._optimistic: + self._attr_value = value + self.async_write_ha_state() + await self._command_set_value.async_run( + {ATTR_VALUE: value}, context=self._context + ) + + +class TriggerNumberEntity(TriggerEntity, NumberEntity): + """Number entity based on trigger data.""" + + domain = NUMBER_DOMAIN + extra_template_keys = ( + CONF_STATE, + ATTR_STEP, + ATTR_MIN, + ATTR_MAX, + ) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: dict, + ) -> None: + """Initialize the entity.""" + super().__init__(hass, coordinator, config) + domain = __name__.split(".")[-2] + self._command_set_value = Script( + hass, + config[CONF_SET_VALUE], + self._rendered.get(CONF_NAME, DEFAULT_NAME), + domain, + ) + + @property + def value(self) -> float | None: + """Return the currently selected option.""" + return vol.Any(vol.Coerce(float), None)(self._rendered.get(CONF_STATE)) + + @property + def min_value(self) -> int: + """Return the minimum value.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(ATTR_MIN, super().min_value) + ) + + @property + def max_value(self) -> int: + """Return the maximum value.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(ATTR_MAX, super().max_value) + ) + + @property + def step(self) -> int: + """Return the increment/decrement step.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(ATTR_STEP, super().step) + ) + + async def async_set_value(self, value: float) -> None: + """Set value of the number.""" + if self._config[CONF_OPTIMISTIC]: + self._attr_value = value + self.async_write_ha_state() + await self._command_set_value.async_run( + {ATTR_VALUE: value}, context=self._context + ) diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py new file mode 100644 index 00000000000..f297307fd0e --- /dev/null +++ b/tests/components/template/test_number.py @@ -0,0 +1,336 @@ +"""The tests for the Template number platform.""" +import pytest + +from homeassistant import setup +from homeassistant.components.input_number import ( + ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, + DOMAIN as INPUT_NUMBER_DOMAIN, + SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE, +) +from homeassistant.components.number.const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE as NUMBER_ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE as NUMBER_SERVICE_SET_VALUE, +) +from homeassistant.const import CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import Context +from homeassistant.helpers.entity_registry import async_get + +from tests.common import ( + assert_setup_component, + async_capture_events, + async_mock_service, +) + +_TEST_NUMBER = "number.template_number" +# Represent for number's value +_VALUE_INPUT_NUMBER = "input_number.value" +# Represent for number's minimum +_MINIMUM_INPUT_NUMBER = "input_number.minimum" +# Represent for number's maximum +_MAXIMUM_INPUT_NUMBER = "input_number.maximum" +# Represent for number's step +_STEP_INPUT_NUMBER = "input_number.step" + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_missing_optional_config(hass, calls): + """Test: missing optional template is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "number": { + "state": "{{ 4 }}", + "set_value": {"service": "script.set_value"}, + "step": "{{ 1 }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, 4, 1, 0.0, 100.0) + + +async def test_missing_required_keys(hass, calls): + """Test: missing required fields will fail.""" + with assert_setup_component(0, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "number": { + "set_value": {"service": "script.set_value"}, + } + } + }, + ) + + with assert_setup_component(0, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "number": { + "state": "{{ 4 }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_all_optional_config(hass, calls): + """Test: including all optional templates is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "number": { + "state": "{{ 4 }}", + "set_value": {"service": "script.set_value"}, + "min": "{{ 3 }}", + "max": "{{ 5 }}", + "step": "{{ 1 }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, 4, 1, 3, 5) + + +async def test_templates_with_entities(hass, calls): + """Test tempalates with values from other entities.""" + with assert_setup_component(4, "input_number"): + assert await setup.async_setup_component( + hass, + "input_number", + { + "input_number": { + "value": { + "min": 0.0, + "max": 100.0, + "name": "Value", + "step": 1.0, + "mode": "slider", + }, + "step": { + "min": 0.0, + "max": 100.0, + "name": "Step", + "step": 1.0, + "mode": "slider", + }, + "minimum": { + "min": 0.0, + "max": 100.0, + "name": "Minimum", + "step": 1.0, + "mode": "slider", + }, + "maximum": { + "min": 0.0, + "max": 100.0, + "name": "Maximum", + "step": 1.0, + "mode": "slider", + }, + } + }, + ) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "unique_id": "b", + "number": { + "state": f"{{{{ states('{_VALUE_INPUT_NUMBER}') }}}}", + "step": f"{{{{ states('{_STEP_INPUT_NUMBER}') }}}}", + "min": f"{{{{ states('{_MINIMUM_INPUT_NUMBER}') }}}}", + "max": f"{{{{ states('{_MAXIMUM_INPUT_NUMBER}') }}}}", + "set_value": { + "service": "input_number.set_value", + "data_template": { + "entity_id": _VALUE_INPUT_NUMBER, + "value": "{{ value }}", + }, + }, + "optimistic": True, + "unique_id": "a", + }, + } + }, + ) + + hass.states.async_set(_VALUE_INPUT_NUMBER, 4) + hass.states.async_set(_STEP_INPUT_NUMBER, 1) + hass.states.async_set(_MINIMUM_INPUT_NUMBER, 3) + hass.states.async_set(_MAXIMUM_INPUT_NUMBER, 5) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + ent_reg = async_get(hass) + entry = ent_reg.async_get(_TEST_NUMBER) + assert entry + assert entry.unique_id == "b-a" + + _verify(hass, 4, 1, 3, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 5}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 1, 3, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _STEP_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 2, 3, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _MINIMUM_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 2, 2, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _MAXIMUM_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 6}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 2, 2, 6) + + await hass.services.async_call( + NUMBER_DOMAIN, + NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _TEST_NUMBER, NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + _verify(hass, 2, 2, 2, 6) + + +async def test_trigger_number(hass): + """Test trigger based template number.""" + events = async_capture_events(hass, "test_number_event") + assert await setup.async_setup_component( + hass, + "template", + { + "template": [ + {"invalid": "config"}, + # Config after invalid should still be set up + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "number": [ + { + "name": "Hello Name", + "unique_id": "hello_name-id", + "state": "{{ trigger.event.data.beers_drank }}", + "min": "{{ trigger.event.data.min_beers }}", + "max": "{{ trigger.event.data.max_beers }}", + "step": "{{ trigger.event.data.step }}", + "set_value": {"event": "test_number_event"}, + "optimistic": True, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("number.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes["min"] == 0.0 + assert state.attributes["max"] == 100.0 + assert state.attributes["step"] == 1.0 + + context = Context() + hass.bus.async_fire( + "test_event", + {"beers_drank": 3, "min_beers": 1.0, "max_beers": 5.0, "step": 0.5}, + context=context, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.hello_name") + assert state is not None + assert state.state == "3.0" + assert state.attributes["min"] == 1.0 + assert state.attributes["max"] == 5.0 + assert state.attributes["step"] == 0.5 + + await hass.services.async_call( + NUMBER_DOMAIN, + NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: "number.hello_name", NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + assert len(events) == 1 + assert events[0].event_type == "test_number_event" + + +def _verify( + hass, + expected_value, + expected_step, + expected_minimum, + expected_maximum, +): + """Verify number's state.""" + state = hass.states.get(_TEST_NUMBER) + attributes = state.attributes + assert state.state == str(float(expected_value)) + assert attributes.get(ATTR_STEP) == float(expected_step) + assert attributes.get(ATTR_MAX) == float(expected_maximum) + assert attributes.get(ATTR_MIN) == float(expected_minimum) From 038121e87b0865b36447470117bc9a07efb481b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 25 Aug 2021 11:36:06 -0700 Subject: [PATCH 802/903] Bump frontend to 20210825.0 (#55221) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a83e3572828..6224916246a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210818.0" + "home-assistant-frontend==20210825.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 218ba5bb890..37649dcf42f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==3.3.2 defusedxml==0.7.1 emoji==1.2.0 hass-nabucasa==0.46.0 -home-assistant-frontend==20210818.0 +home-assistant-frontend==20210825.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 7458081ab44..40bdcc7f7a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -790,7 +790,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210818.0 +home-assistant-frontend==20210825.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cec84d474a3..3742698484b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -459,7 +459,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210818.0 +home-assistant-frontend==20210825.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 7c5a0174bae0b5df931ebfc3e2eb9352b6c96834 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 25 Aug 2021 11:37:03 -0700 Subject: [PATCH 803/903] Add an energy solar platform for solar forecasts (#54576) Co-authored-by: Martin Hjelmare --- homeassistant/components/energy/types.py | 27 +++++ .../components/energy/websocket_api.py | 100 ++++++++++++++++-- .../components/forecast_solar/__init__.py | 28 +---- .../components/forecast_solar/energy.py | 23 ++++ homeassistant/helpers/integration_platform.py | 2 +- tests/components/energy/test_websocket_api.py | 63 ++++++++++- .../components/forecast_solar/test_energy.py | 34 ++++++ tests/components/forecast_solar/test_init.py | 30 +----- 8 files changed, 243 insertions(+), 64 deletions(-) create mode 100644 homeassistant/components/energy/types.py create mode 100644 homeassistant/components/forecast_solar/energy.py create mode 100644 tests/components/forecast_solar/test_energy.py diff --git a/homeassistant/components/energy/types.py b/homeassistant/components/energy/types.py new file mode 100644 index 00000000000..b8df1b19bef --- /dev/null +++ b/homeassistant/components/energy/types.py @@ -0,0 +1,27 @@ +"""Types for the energy platform.""" +from __future__ import annotations + +from typing import Awaitable, Callable, TypedDict + +from homeassistant.core import HomeAssistant + + +class SolarForecastType(TypedDict): + """Return value for solar forecast.""" + + wh_hours: dict[str, float | int] + + +GetSolarForecastType = Callable[ + [HomeAssistant, str], Awaitable["SolarForecastType | None"] +] + + +class EnergyPlatform: + """This class represents the methods we expect on the energy platforms.""" + + @staticmethod + async def async_get_solar_forecast( + hass: HomeAssistant, config_entry_id: str + ) -> SolarForecastType | None: + """Get forecast for solar production for specific config entry ID.""" diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 6d71a75b9b4..7af7b306f79 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -3,12 +3,17 @@ from __future__ import annotations import asyncio import functools -from typing import Any, Awaitable, Callable, Dict, cast +from types import ModuleType +from typing import Any, Awaitable, Callable, cast import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.integration_platform import ( + async_process_integration_platforms, +) +from homeassistant.helpers.singleton import singleton from .const import DOMAIN from .data import ( @@ -18,14 +23,15 @@ from .data import ( EnergyPreferencesUpdate, async_get_manager, ) +from .types import EnergyPlatform, GetSolarForecastType from .validate import async_validate EnergyWebSocketCommandHandler = Callable[ - [HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"], + [HomeAssistant, websocket_api.ActiveConnection, "dict[str, Any]", "EnergyManager"], None, ] AsyncEnergyWebSocketCommandHandler = Callable[ - [HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"], + [HomeAssistant, websocket_api.ActiveConnection, "dict[str, Any]", "EnergyManager"], Awaitable[None], ] @@ -37,6 +43,28 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_save_prefs) websocket_api.async_register_command(hass, ws_info) websocket_api.async_register_command(hass, ws_validate) + websocket_api.async_register_command(hass, ws_solar_forecast) + + +@singleton("energy_platforms") +async def async_get_energy_platforms( + hass: HomeAssistant, +) -> dict[str, GetSolarForecastType]: + """Get energy platforms.""" + platforms: dict[str, GetSolarForecastType] = {} + + async def _process_energy_platform( + hass: HomeAssistant, domain: str, platform: ModuleType + ) -> None: + """Process energy platforms.""" + if not hasattr(platform, "async_get_solar_forecast"): + return + + platforms[domain] = cast(EnergyPlatform, platform).async_get_solar_forecast + + await async_process_integration_platforms(hass, DOMAIN, _process_energy_platform) + + return platforms def _ws_with_manager( @@ -107,14 +135,21 @@ async def ws_save_prefs( vol.Required("type"): "energy/info", } ) -@callback -def ws_info( +@websocket_api.async_response +async def ws_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Handle get info command.""" - connection.send_result(msg["id"], hass.data[DOMAIN]) + forecast_platforms = await async_get_energy_platforms(hass) + connection.send_result( + msg["id"], + { + "cost_sensors": hass.data[DOMAIN]["cost_sensors"], + "solar_forecast_domains": list(forecast_platforms), + }, + ) @websocket_api.websocket_command( @@ -130,3 +165,56 @@ async def ws_validate( ) -> None: """Handle validate command.""" connection.send_result(msg["id"], (await async_validate(hass)).as_dict()) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/solar_forecast", + } +) +@_ws_with_manager +async def ws_solar_forecast( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + manager: EnergyManager, +) -> None: + """Handle solar forecast command.""" + if manager.data is None: + connection.send_result(msg["id"], {}) + return + + config_entries: dict[str, str | None] = {} + + for source in manager.data["energy_sources"]: + if ( + source["type"] != "solar" + or source.get("config_entry_solar_forecast") is None + ): + continue + + # typing is not catching the above guard for config_entry_solar_forecast being none + for config_entry in source["config_entry_solar_forecast"]: # type: ignore[union-attr] + config_entries[config_entry] = None + + if not config_entries: + connection.send_result(msg["id"], {}) + return + + forecasts = {} + + forecast_platforms = await async_get_energy_platforms(hass) + + for config_entry_id in config_entries: + config_entry = hass.config_entries.async_get_entry(config_entry_id) + # Filter out non-existing config entries or unsupported domains + + if config_entry is None or config_entry.domain not in forecast_platforms: + continue + + forecast = await forecast_platforms[config_entry.domain](hass, config_entry_id) + + if forecast is not None: + forecasts[config_entry_id] = forecast + + connection.send_result(msg["id"], forecasts) diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index adbe040bfbd..9638ea4e4dd 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -5,12 +5,10 @@ from datetime import timedelta import logging from forecast_solar import ForecastSolar -import voluptuous as vol -from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -60,10 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - websocket_api.async_register_command(hass, ws_list_forecasts) - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -84,22 +79,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) - - -@websocket_api.websocket_command({vol.Required("type"): "forecast_solar/forecasts"}) -@callback -def ws_list_forecasts( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict -) -> None: - """Return a list of available forecasts.""" - forecasts = {} - - for config_entry_id, coordinator in hass.data[DOMAIN].items(): - forecasts[config_entry_id] = { - "wh_hours": { - timestamp.isoformat(): val - for timestamp, val in coordinator.data.wh_hours.items() - } - } - - connection.send_result(msg["id"], forecasts) diff --git a/homeassistant/components/forecast_solar/energy.py b/homeassistant/components/forecast_solar/energy.py new file mode 100644 index 00000000000..6bf63910e5f --- /dev/null +++ b/homeassistant/components/forecast_solar/energy.py @@ -0,0 +1,23 @@ +"""Energy platform.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_get_solar_forecast( + hass: HomeAssistant, config_entry_id: str +) -> dict[str, dict[str, float | int]] | None: + """Get solar forecast for a config entry ID.""" + coordinator = hass.data[DOMAIN].get(config_entry_id) + + if coordinator is None: + return None + + return { + "wh_hours": { + timestamp.isoformat(): val + for timestamp, val in coordinator.data.wh_hours.items() + } + } diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 5becda0545b..57a81083c50 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -23,7 +23,7 @@ async def async_process_integration_platforms( """Process a specific platform for all current and future loaded integrations.""" async def _process(component_name: str) -> None: - """Process the intents of a component.""" + """Process component being loaded.""" if "." in component_name: return diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 732bdaa93cf..09a3b7aed94 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -1,10 +1,12 @@ """Test the Energy websocket API.""" +from unittest.mock import AsyncMock, Mock + import pytest from homeassistant.components.energy import data, is_configured from homeassistant.setup import async_setup_component -from tests.common import flush_store +from tests.common import MockConfigEntry, flush_store, mock_platform @pytest.fixture(autouse=True) @@ -15,6 +17,26 @@ async def setup_integration(hass): ) +@pytest.fixture +def mock_energy_platform(hass): + """Mock an energy platform.""" + hass.config.components.add("some_domain") + mock_platform( + hass, + "some_domain.energy", + Mock( + async_get_solar_forecast=AsyncMock( + return_value={ + "wh_hours": { + "2021-06-27T13:00:00+00:00": 12, + "2021-06-27T14:00:00+00:00": 8, + } + } + ) + ), + ) + + async def test_get_preferences_no_data(hass, hass_ws_client) -> None: """Test we get error if no preferences set.""" client = await hass_ws_client(hass) @@ -46,7 +68,9 @@ async def test_get_preferences_default(hass, hass_ws_client, hass_storage) -> No assert msg["result"] == data.EnergyManager.default_preferences() -async def test_save_preferences(hass, hass_ws_client, hass_storage) -> None: +async def test_save_preferences( + hass, hass_ws_client, hass_storage, mock_energy_platform +) -> None: """Test we can save preferences.""" client = await hass_ws_client(hass) @@ -140,7 +164,8 @@ async def test_save_preferences(hass, hass_ws_client, hass_storage) -> None: "cost_sensors": { "sensor.heat_pump_meter_2": "sensor.heat_pump_meter_2_cost", "sensor.return_to_grid_offpeak": "sensor.return_to_grid_offpeak_compensation", - } + }, + "solar_forecast_domains": ["some_domain"], } # Prefs with limited options @@ -232,3 +257,35 @@ async def test_validate(hass, hass_ws_client) -> None: "energy_sources": [], "device_consumption": [], } + + +async def test_get_solar_forecast(hass, hass_ws_client, mock_energy_platform) -> None: + """Test we get preferences.""" + entry = MockConfigEntry(domain="some_domain") + entry.add_to_hass(hass) + + manager = await data.async_get_manager(hass) + manager.data = data.EnergyManager.default_preferences() + manager.data["energy_sources"].append( + { + "type": "solar", + "stat_energy_from": "my_solar_production", + "config_entry_solar_forecast": [entry.entry_id], + } + ) + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "energy/solar_forecast"}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == { + entry.entry_id: { + "wh_hours": { + "2021-06-27T13:00:00+00:00": 12, + "2021-06-27T14:00:00+00:00": 8, + } + } + } diff --git a/tests/components/forecast_solar/test_energy.py b/tests/components/forecast_solar/test_energy.py new file mode 100644 index 00000000000..9ab6038818b --- /dev/null +++ b/tests/components/forecast_solar/test_energy.py @@ -0,0 +1,34 @@ +"""Test forecast solar energy platform.""" +from datetime import datetime, timezone +from unittest.mock import MagicMock + +from homeassistant.components.forecast_solar import energy +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_energy_solar_forecast( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_forecast_solar: MagicMock, +) -> None: + """Test the Forecast.Solar energy platform solar forecast.""" + mock_forecast_solar.estimate.return_value.wh_hours = { + datetime(2021, 6, 27, 13, 0, tzinfo=timezone.utc): 12, + datetime(2021, 6, 27, 14, 0, tzinfo=timezone.utc): 8, + } + + 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 == ConfigEntryState.LOADED + + assert await energy.async_get_solar_forecast(hass, mock_config_entry.entry_id) == { + "wh_hours": { + "2021-06-27T13:00:00+00:00": 12, + "2021-06-27T14:00:00+00:00": 8, + } + } diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index 453196e3300..a0a8f802e5a 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -1,5 +1,4 @@ """Tests for the Forecast.Solar integration.""" -from datetime import datetime, timezone from unittest.mock import MagicMock, patch from forecast_solar import ForecastSolarConnectionError @@ -7,6 +6,7 @@ from forecast_solar import ForecastSolarConnectionError from homeassistant.components.forecast_solar.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -15,39 +15,13 @@ async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_forecast_solar: MagicMock, - hass_ws_client, ) -> None: """Test the Forecast.Solar configuration entry loading/unloading.""" - mock_forecast_solar.estimate.return_value.wh_hours = { - datetime(2021, 6, 27, 13, 0, tzinfo=timezone.utc): 12, - datetime(2021, 6, 27, 14, 0, tzinfo=timezone.utc): 8, - } - 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 async_setup_component(hass, "forecast_solar", {}) assert mock_config_entry.state == ConfigEntryState.LOADED - # Test WS API set up - client = await hass_ws_client(hass) - await client.send_json( - { - "id": 5, - "type": "forecast_solar/forecasts", - } - ) - result = await client.receive_json() - assert result["success"] - assert result["result"] == { - mock_config_entry.entry_id: { - "wh_hours": { - "2021-06-27T13:00:00+00:00": 12, - "2021-06-27T14:00:00+00:00": 8, - } - } - } - await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() From d60f5e1721c37d291a9e73d0643eb256c2ee140a Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 25 Aug 2021 21:02:06 +0200 Subject: [PATCH 804/903] Add missing convert to fan/light/switch modbus platform (#55203) --- homeassistant/components/modbus/base_platform.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index d9ee58f3c38..efcb70b5b16 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -27,7 +27,9 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( CALL_TYPE_COIL, + CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, CALL_TYPE_WRITE_COIL, CALL_TYPE_WRITE_COILS, CALL_TYPE_WRITE_REGISTER, @@ -173,6 +175,14 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_WRITE_REGISTER, ), + CALL_TYPE_DISCRETE: ( + CALL_TYPE_DISCRETE, + None, + ), + CALL_TYPE_REGISTER_INPUT: ( + CALL_TYPE_REGISTER_INPUT, + None, + ), CALL_TYPE_COIL: (CALL_TYPE_COIL, CALL_TYPE_WRITE_COIL), CALL_TYPE_X_COILS: (CALL_TYPE_COIL, CALL_TYPE_WRITE_COILS), CALL_TYPE_X_REGISTER_HOLDINGS: ( From 2f7a7b0309679b1d57fd91a460205ca10d5c2318 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 25 Aug 2021 15:16:51 -0400 Subject: [PATCH 805/903] Add template functions to get area_id and area_name (#54248) * Add template function to get area_id * fix int bug * Prefer area name lookup * remove unnecessary checks * fix import * Add area_name function * change behavior to fail in ambiguous scenarios * Revert lotto winning exception checking * review comments * try except else --- homeassistant/helpers/template.py | 76 ++++++++++++- tests/helpers/test_template.py | 172 +++++++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index cbeaa07aadc..a831e8d156d 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -44,6 +44,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import TemplateError from homeassistant.helpers import ( + area_registry, device_registry, entity_registry, location as loc_helper, @@ -949,6 +950,71 @@ def is_device_attr( return bool(device_attr(hass, device_or_entity_id, attr_name) == attr_value) +def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the area ID from an area name, device id, or entity id.""" + area_reg = area_registry.async_get(hass) + if area := area_reg.async_get_area_by_name(str(lookup_value)): + return area.id + + ent_reg = entity_registry.async_get(hass) + # Import here, not at top-level to avoid circular import + from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel + config_validation as cv, + ) + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + return entity.area_id + + # Check if this could be a device ID (hex string) + dev_reg = device_registry.async_get(hass) + if device := dev_reg.async_get(lookup_value): + return device.area_id + + return None + + +def _get_area_name(area_reg: area_registry.AreaRegistry, valid_area_id: str) -> str: + """Get area name from valid area ID.""" + area = area_reg.async_get_area(valid_area_id) + assert area + return area.name + + +def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the area name from an area id, device id, or entity id.""" + area_reg = area_registry.async_get(hass) + area = area_reg.async_get_area(lookup_value) + if area: + return area.name + + ent_reg = entity_registry.async_get(hass) + # Import here, not at top-level to avoid circular import + from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel + config_validation as cv, + ) + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + if entity.area_id: + return _get_area_name(area_reg, entity.area_id) + return None + + dev_reg = device_registry.async_get(hass) + if (device := dev_reg.async_get(lookup_value)) and device.area_id: + return _get_area_name(area_reg, device.area_id) + + return None + + def closest(hass, *args): """Find closest entity. @@ -1532,6 +1598,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["device_id"] = hassfunction(device_id) self.filters["device_id"] = pass_context(self.globals["device_id"]) + self.globals["area_id"] = hassfunction(area_id) + self.filters["area_id"] = pass_context(self.globals["area_id"]) + + self.globals["area_name"] = hassfunction(area_name) + self.filters["area_name"] = pass_context(self.globals["area_name"]) + if limited: # Only device_entities is available to limited templates, mark other # functions and filters as unsupported. @@ -1556,8 +1628,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "device_attr", "is_device_attr", "device_id", + "area_id", + "area_name", ] - hass_filters = ["closest", "expand", "device_id"] + hass_filters = ["closest", "expand", "device_id", "area_id", "area_name"] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index d6fe2b6dbaf..7a2776fd5b2 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -23,7 +23,12 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import UnitSystem -from tests.common import MockConfigEntry, mock_device_registry, mock_registry +from tests.common import ( + MockConfigEntry, + mock_area_registry, + mock_device_registry, + mock_registry, +) def _set_up_units(hass): @@ -1513,7 +1518,7 @@ async def test_expand(hass): async def test_device_entities(hass): - """Test expand function.""" + """Test device_entities function.""" config_entry = MockConfigEntry(domain="light") device_registry = mock_device_registry(hass) entity_registry = mock_registry(hass) @@ -1730,6 +1735,169 @@ async def test_device_attr(hass): assert info.rate_limit is None +async def test_area_id(hass): + """Test area_id function.""" + config_entry = MockConfigEntry(domain="light") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + area_registry = mock_area_registry(hass) + + # Test non existing entity id + info = render_to_info(hass, "{{ area_id('sensor.fake') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing device id (hex value) + info = render_to_info(hass, "{{ area_id('123abc') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing area name + info = render_to_info(hass, "{{ area_id('fake area name') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ area_id(56) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + area_entry_entity_id = area_registry.async_get_or_create("sensor.fake") + + # Test device with single entity, which has no area + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + info = render_to_info(hass, f"{{{{ area_id('{device_entry.id}') }}}}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test device ID, entity ID and area name as input with area name that looks like + # a device ID. Try a filter too + area_entry_hex = area_registry.async_get_or_create("123abc") + device_entry = device_registry.async_update_device( + device_entry.id, area_id=area_entry_hex.id + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_hex.id + ) + + info = render_to_info(hass, f"{{{{ '{device_entry.id}' | area_id }}}}") + assert_result_info(info, area_entry_hex.id) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry_hex.id) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_id('{area_entry_hex.name}') }}}}") + assert_result_info(info, area_entry_hex.id) + assert info.rate_limit is None + + # Test device ID, entity ID and area name as input with area name that looks like an + # entity ID + area_entry_entity_id = area_registry.async_get_or_create("sensor.fake") + device_entry = device_registry.async_update_device( + device_entry.id, area_id=area_entry_entity_id.id + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_entity_id.id + ) + + info = render_to_info(hass, f"{{{{ area_id('{device_entry.id}') }}}}") + assert_result_info(info, area_entry_entity_id.id) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry_entity_id.id) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_id('{area_entry_entity_id.name}') }}}}") + assert_result_info(info, area_entry_entity_id.id) + assert info.rate_limit is None + + +async def test_area_name(hass): + """Test area_name function.""" + config_entry = MockConfigEntry(domain="light") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + area_registry = mock_area_registry(hass) + + # Test non existing entity id + info = render_to_info(hass, "{{ area_name('sensor.fake') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing device id (hex value) + info = render_to_info(hass, "{{ area_name('123abc') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing area id + info = render_to_info(hass, "{{ area_name('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ area_name(56) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test device with single entity, which has no area + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + info = render_to_info(hass, f"{{{{ area_name('{device_entry.id}') }}}}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test device ID, entity ID and area id as input. Try a filter too + area_entry = area_registry.async_get_or_create("123abc") + device_entry = device_registry.async_update_device( + device_entry.id, area_id=area_entry.id + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry.id + ) + + info = render_to_info(hass, f"{{{{ '{device_entry.id}' | area_name }}}}") + assert_result_info(info, area_entry.name) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry.name) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_name('{area_entry.id}') }}}}") + assert_result_info(info, area_entry.name) + assert info.rate_limit is None + + def test_closest_function_to_coord(hass): """Test closest function to coord.""" hass.states.async_set( From fb28665cfa5e4e49212ed1b1263fab9405bded66 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 25 Aug 2021 20:52:39 +0100 Subject: [PATCH 806/903] Add "cron patterns" to define utility_meter cycles (#46795) Co-authored-by: J. Nick Koston --- .../components/utility_meter/__init__.py | 50 ++++++-- .../components/utility_meter/const.py | 2 + .../components/utility_meter/manifest.json | 1 + .../components/utility_meter/sensor.py | 38 +++++- requirements_all.txt | 3 + requirements_test.txt | 1 + requirements_test_all.txt | 3 + tests/components/utility_meter/test_init.py | 120 ++++++++++++++++++ tests/components/utility_meter/test_sensor.py | 62 +++++++++ 9 files changed, 263 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 25aa6018d44..32ed90a9111 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -2,6 +2,7 @@ from datetime import timedelta import logging +from croniter import croniter import voluptuous as vol from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -14,6 +15,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( ATTR_TARIFF, + CONF_CRON_PATTERN, CONF_METER, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, @@ -40,17 +42,45 @@ ATTR_TARIFFS = "tariffs" DEFAULT_OFFSET = timedelta(hours=0) +def validate_cron_pattern(pattern): + """Check that the pattern is well-formed.""" + if croniter.is_valid(pattern): + return pattern + raise vol.Invalid("Invalid pattern") + + +def period_or_cron(config): + """Check that if cron pattern is used, then meter type and offsite must be removed.""" + if CONF_CRON_PATTERN in config and CONF_METER_TYPE in config: + raise vol.Invalid(f"Use <{CONF_CRON_PATTERN}> or <{CONF_METER_TYPE}>") + if ( + CONF_CRON_PATTERN in config + and CONF_METER_OFFSET in config + and config[CONF_METER_OFFSET] != DEFAULT_OFFSET + ): + raise vol.Invalid( + f"When <{CONF_CRON_PATTERN}> is used <{CONF_METER_OFFSET}> has no meaning" + ) + return config + + METER_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES), - vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET): vol.All( - cv.time_period, cv.positive_timedelta - ), - vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, - vol.Optional(CONF_TARIFFS, default=[]): vol.All(cv.ensure_list, [cv.string]), - } + vol.All( + { + vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES), + vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET): vol.All( + cv.time_period, cv.positive_timedelta + ), + vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, + vol.Optional(CONF_TARIFFS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_CRON_PATTERN): validate_cron_pattern, + }, + period_or_cron, + ) ) CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 39fd952327b..3be6fa9a061 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -32,9 +32,11 @@ CONF_PAUSED = "paused" CONF_TARIFFS = "tariffs" CONF_TARIFF = "tariff" CONF_TARIFF_ENTITY = "tariff_entity" +CONF_CRON_PATTERN = "cron" ATTR_TARIFF = "tariff" ATTR_VALUE = "value" +ATTR_CRON_PATTERN = "cron pattern" SIGNAL_START_PAUSE_METER = "utility_meter_start_pause" SIGNAL_RESET_METER = "utility_meter_reset" diff --git a/homeassistant/components/utility_meter/manifest.json b/homeassistant/components/utility_meter/manifest.json index 06f2b60297b..a1ba3b6d370 100644 --- a/homeassistant/components/utility_meter/manifest.json +++ b/homeassistant/components/utility_meter/manifest.json @@ -2,6 +2,7 @@ "domain": "utility_meter", "name": "Utility Meter", "documentation": "https://www.home-assistant.io/integrations/utility_meter", + "requirements": ["croniter==1.0.6"], "codeowners": ["@dgomes"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index e0bd33006d3..ee3fed02a6b 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -1,8 +1,9 @@ """Utility meter from sensors providing raw data.""" -from datetime import date, timedelta +from datetime import date, datetime, timedelta from decimal import Decimal, DecimalException import logging +from croniter import croniter import voluptuous as vol from homeassistant.components.sensor import ( @@ -25,6 +26,7 @@ from homeassistant.core import callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import ( + async_track_point_in_time, async_track_state_change_event, async_track_time_change, ) @@ -32,8 +34,10 @@ from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.dt as dt_util from .const import ( + ATTR_CRON_PATTERN, ATTR_VALUE, BIMONTHLY, + CONF_CRON_PATTERN, CONF_METER, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, @@ -91,6 +95,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get( CONF_TARIFF_ENTITY ) + conf_cron_pattern = hass.data[DATA_UTILITY][meter].get(CONF_CRON_PATTERN) meters.append( UtilityMeterSensor( @@ -101,6 +106,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= conf_meter_net_consumption, conf.get(CONF_TARIFF), conf_meter_tariff_entity, + conf_cron_pattern, ) ) @@ -127,6 +133,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): net_consumption, tariff=None, tariff_entity=None, + cron_pattern=None, ): """Initialize the Utility Meter sensor.""" self._sensor_source_id = source_entity @@ -141,6 +148,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): self._unit_of_measurement = None self._period = meter_type self._period_offset = meter_offset + self._cron_pattern = cron_pattern self._sensor_net_consumption = net_consumption self._tariff = tariff self._tariff_entity = tariff_entity @@ -207,29 +215,37 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): async def _async_reset_meter(self, event): """Determine cycle - Helper function for larger than daily cycles.""" now = dt_util.now().date() - if ( + if self._cron_pattern is not None: + async_track_point_in_time( + self.hass, + self._async_reset_meter, + croniter(self._cron_pattern, dt_util.now()).get_next(datetime), + ) + elif ( self._period == WEEKLY and now != now - timedelta(days=now.weekday()) + self._period_offset ): return - if ( + elif ( self._period == MONTHLY and now != date(now.year, now.month, 1) + self._period_offset ): return - if ( + elif ( self._period == BIMONTHLY and now != date(now.year, (((now.month - 1) // 2) * 2 + 1), 1) + self._period_offset ): return - if ( + elif ( self._period == QUARTERLY and now != date(now.year, (((now.month - 1) // 3) * 3 + 1), 1) + self._period_offset ): return - if self._period == YEARLY and now != date(now.year, 1, 1) + self._period_offset: + elif ( + self._period == YEARLY and now != date(now.year, 1, 1) + self._period_offset + ): return await self.async_reset_meter(self._tariff_entity) @@ -253,7 +269,13 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): """Handle entity which will be added.""" await super().async_added_to_hass() - if self._period == QUARTER_HOURLY: + if self._cron_pattern is not None: + async_track_point_in_time( + self.hass, + self._async_reset_meter, + croniter(self._cron_pattern, dt_util.now()).get_next(datetime), + ) + elif self._period == QUARTER_HOURLY: for quarter in range(4): async_track_time_change( self.hass, @@ -360,6 +382,8 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): } if self._period is not None: state_attr[ATTR_PERIOD] = self._period + if self._cron_pattern is not None: + state_attr[ATTR_CRON_PATTERN] = self._cron_pattern if self._tariff is not None: state_attr[ATTR_TARIFF] = self._tariff return state_attr diff --git a/requirements_all.txt b/requirements_all.txt index 40bdcc7f7a2..b9611f50d76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -486,6 +486,9 @@ construct==2.10.56 # homeassistant.components.coronavirus coronavirus==1.1.1 +# homeassistant.components.utility_meter +croniter==1.0.6 + # homeassistant.components.datadog datadog==0.15.0 diff --git a/requirements_test.txt b/requirements_test.txt index 73b34913f89..86114cc02b1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -28,6 +28,7 @@ responses==0.12.0 respx==0.17.0 stdlib-list==0.7.0 tqdm==4.49.0 +types-croniter==1.0.0 types-backports==0.1.3 types-certifi==0.1.4 types-chardet==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3742698484b..23ea1648560 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -282,6 +282,9 @@ construct==2.10.56 # homeassistant.components.coronavirus coronavirus==1.1.1 +# homeassistant.components.utility_meter +croniter==1.0.6 + # homeassistant.components.datadog datadog==0.15.0 diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index c422d3b5c1f..aa6de34f611 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -10,15 +10,49 @@ from homeassistant.components.utility_meter.const import ( SERVICE_SELECT_NEXT_TARIFF, SERVICE_SELECT_TARIFF, ) +import homeassistant.components.utility_meter.sensor as um_sensor from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONF_PLATFORM, ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_START, ) +from homeassistant.core import State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from tests.common import mock_restore_cache + + +async def test_restore_state(hass): + """Test utility sensor restore state.""" + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "tariffs": ["onpeak", "midpeak", "offpeak"], + } + } + } + mock_restore_cache( + hass, + [ + State( + "utility_meter.energy_bill", + "midpeak", + ), + ], + ) + + assert await async_setup_component(hass, DOMAIN, config) + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + # restore from cache + state = hass.states.get("utility_meter.energy_bill") + assert state.state == "midpeak" + async def test_services(hass): """Test energy sensor reset service.""" @@ -81,6 +115,13 @@ async def test_services(hass): assert state.state == "1" # Change tariff + data = {ATTR_ENTITY_ID: "utility_meter.energy_bill", ATTR_TARIFF: "wrong_tariff"} + await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data) + await hass.async_block_till_done() + + # Inexisting tariff, ignoring + assert hass.states.get("utility_meter.energy_bill").state != "wrong_tariff" + data = {ATTR_ENTITY_ID: "utility_meter.energy_bill", ATTR_TARIFF: "peak"} await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data) await hass.async_block_till_done() @@ -111,3 +152,82 @@ async def test_services(hass): state = hass.states.get("sensor.energy_bill_offpeak") assert state.state == "0" + + +async def test_cron(hass, legacy_patchable_time): + """Test cron pattern and offset fails.""" + + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "cron": "*/5 * * * *", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + +async def test_cron_and_meter(hass, legacy_patchable_time): + """Test cron pattern and meter type fails.""" + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "cycle": "hourly", + "cron": "0 0 1 * *", + } + } + } + + assert not await async_setup_component(hass, DOMAIN, config) + + +async def test_both_cron_and_meter(hass, legacy_patchable_time): + """Test cron pattern and meter type passes in different meter.""" + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "cron": "0 0 1 * *", + }, + "water_bill": { + "source": "sensor.water", + "cycle": "hourly", + }, + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + +async def test_cron_and_offset(hass, legacy_patchable_time): + """Test cron pattern and offset fails.""" + + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "offset": {"days": 1}, + "cron": "0 0 1 * *", + } + } + } + + assert not await async_setup_component(hass, DOMAIN, config) + + +async def test_bad_cron(hass, legacy_patchable_time): + """Test bad cron pattern.""" + + config = { + "utility_meter": {"energy_bill": {"source": "sensor.energy", "cron": "*"}} + } + + assert not await async_setup_component(hass, DOMAIN, config) + + +async def test_setup_missing_discovery(hass): + """Test setup with configuration missing discovery_info.""" + assert not await um_sensor.async_setup_platform(hass, {CONF_PLATFORM: DOMAIN}, None) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index a2d15c595b0..5627daec7f8 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -11,7 +11,10 @@ from homeassistant.components.sensor import ( from homeassistant.components.utility_meter.const import ( ATTR_TARIFF, ATTR_VALUE, + DAILY, DOMAIN, + HOURLY, + QUARTER_HOURLY, SERVICE_CALIBRATE_METER, SERVICE_SELECT_TARIFF, ) @@ -27,6 +30,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_START, + STATE_UNAVAILABLE, ) from homeassistant.core import State from homeassistant.setup import async_setup_component @@ -162,6 +166,26 @@ async def test_state(hass): assert state is not None assert state.state == "0.123" + # test invalid state + entity_id = config[DOMAIN]["energy_bill"]["source"] + hass.states.async_set( + entity_id, "*", {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill_midpeak") + assert state is not None + assert state.state == "0.123" + + # test unavailable source + entity_id = config[DOMAIN]["energy_bill"]["source"] + hass.states.async_set( + entity_id, STATE_UNAVAILABLE, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill_midpeak") + assert state is not None + assert state.state == "0.123" + async def test_device_class(hass): """Test utility device_class.""" @@ -421,6 +445,44 @@ async def _test_self_reset(hass, config, start_time, expect_reset=True): start_time_str = dt_util.parse_datetime(start_time).isoformat() assert state.attributes.get("last_reset") == start_time_str + # Check next day when nothing should happen for weekly, monthly, bimonthly and yearly + if config["utility_meter"]["energy_bill"].get("cycle") in [ + QUARTER_HOURLY, + HOURLY, + DAILY, + ]: + now += timedelta(minutes=5) + else: + now += timedelta(days=5) + with alter_time(now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + hass.states.async_set( + entity_id, + 10, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill") + if expect_reset: + assert state.attributes.get("last_period") == "2" + assert state.state == "7" + else: + assert state.attributes.get("last_period") == 0 + assert state.state == "9" + + +async def test_self_reset_cron_pattern(hass, legacy_patchable_time): + """Test cron pattern reset of meter.""" + config = { + "utility_meter": { + "energy_bill": {"source": "sensor.energy", "cron": "0 0 1 * *"} + } + } + + await _test_self_reset(hass, config, "2017-01-31T23:59:00.000000+00:00") + async def test_self_reset_quarter_hourly(hass, legacy_patchable_time): """Test quarter-hourly reset of meter.""" From 59d401e7b7bb70978bdd477de74d1c74621f8263 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Wed, 25 Aug 2021 21:56:10 +0200 Subject: [PATCH 807/903] Add Nanoleaf reauth flow (#55217) Co-authored-by: J. Nick Koston Co-authored-by: Paulus Schoutsen --- homeassistant/components/nanoleaf/__init__.py | 6 ++- .../components/nanoleaf/config_flow.py | 24 ++++++++++ .../components/nanoleaf/strings.json | 1 + .../components/nanoleaf/translations/en.json | 1 + tests/components/nanoleaf/test_config_flow.py | 47 ++++++++++++++++++- 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index 84a33a14b3e..be61bbc65a3 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -1,10 +1,10 @@ """The Nanoleaf integration.""" -from pynanoleaf.pynanoleaf import Nanoleaf, Unavailable +from pynanoleaf.pynanoleaf import InvalidToken, Nanoleaf, Unavailable from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DEVICE, DOMAIN, NAME, SERIAL_NO from .util import pynanoleaf_get_info @@ -18,6 +18,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: info = await hass.async_add_executor_job(pynanoleaf_get_info, nanoleaf) except Unavailable as err: raise ConfigEntryNotReady from err + except InvalidToken as err: + raise ConfigEntryAuthFailed from err hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { DEVICE: nanoleaf, diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 0bd7975bbed..9edfd23e6a9 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -32,6 +32,8 @@ USER_SCHEMA: Final = vol.Schema( class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Nanoleaf config flow.""" + reauth_entry: config_entries.ConfigEntry | None = None + VERSION = 1 def __init__(self) -> None: @@ -73,6 +75,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_link() + async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + """Handle Nanoleaf reauth flow if token is invalid.""" + self.reauth_entry = cast( + config_entries.ConfigEntry, + self.hass.config_entries.async_get_entry(self.context["entry_id"]), + ) + self.nanoleaf = Nanoleaf(data[CONF_HOST]) + self.context["title_placeholders"] = {"name": self.reauth_entry.title} + return await self.async_step_link() + async def async_step_zeroconf( self, discovery_info: DiscoveryInfoType ) -> FlowResult: @@ -135,6 +147,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except Exception: # pylint: disable=broad-except _LOGGER.exception("Unknown error authorizing Nanoleaf") return self.async_show_form(step_id="link", errors={"base": "unknown"}) + + if self.reauth_entry is not None: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={ + **self.reauth_entry.data, + CONF_TOKEN: self.nanoleaf.token, + }, + ) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return await self.async_setup_finish() async def async_step_import(self, config: dict[str, Any]) -> FlowResult: diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json index b08748757b7..96fcfd2622a 100644 --- a/homeassistant/components/nanoleaf/strings.json +++ b/homeassistant/components/nanoleaf/strings.json @@ -21,6 +21,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_token": "[%key:common::config_flow::error::invalid_access_token%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } } diff --git a/homeassistant/components/nanoleaf/translations/en.json b/homeassistant/components/nanoleaf/translations/en.json index e76387d0246..7696f056aa3 100644 --- a/homeassistant/components/nanoleaf/translations/en.json +++ b/homeassistant/components/nanoleaf/translations/en.json @@ -4,6 +4,7 @@ "already_configured": "Device is already configured", "cannot_connect": "Failed to connect", "invalid_token": "Invalid access token", + "reauth_successful": "Re-authentication was successful", "unknown": "Unexpected error" }, "error": { diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index a84d97fda2a..93db43e40c9 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Nanoleaf config flow.""" from __future__ import annotations -from unittest.mock import patch +from unittest.mock import MagicMock, patch from pynanoleaf import InvalidToken, NotAuthorizingNewTokens, Unavailable from pynanoleaf.pynanoleaf import NanoleafError @@ -12,6 +12,8 @@ from homeassistant.components.nanoleaf.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry + TEST_NAME = "Canvas ADF9" TEST_HOST = "192.168.0.100" TEST_OTHER_HOST = "192.168.0.200" @@ -283,6 +285,49 @@ async def test_discovery_link_unavailable( assert result["reason"] == "cannot_connect" +async def test_reauth(hass: HomeAssistant) -> None: + """Test Nanoleaf reauth flow.""" + nanoleaf = MagicMock() + nanoleaf.host = TEST_HOST + nanoleaf.token = TEST_TOKEN + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_NAME, + data={CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_OTHER_TOKEN}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=nanoleaf, + ), patch( + "homeassistant.components.nanoleaf.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + assert result["type"] == "form" + assert result["step_id"] == "link" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + assert entry.data[CONF_HOST] == TEST_HOST + assert entry.data[CONF_TOKEN] == TEST_TOKEN + + async def test_import_config(hass: HomeAssistant) -> None: """Test configuration import.""" with patch( From e6e8d7eded455364ffd63ffc4c71bb366c0243d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Aug 2021 14:56:36 -0500 Subject: [PATCH 808/903] Convert color temperature to visible color in lights (#55219) --- .../components/google_assistant/trait.py | 7 +++--- homeassistant/components/light/__init__.py | 9 +++++++- tests/components/elgato/test_light.py | 2 +- .../components/google_assistant/test_trait.py | 2 ++ tests/components/group/test_light.py | 4 ++-- tests/components/hue/test_light.py | 2 +- tests/components/light/test_init.py | 3 +++ tests/components/mqtt/test_light.py | 22 +++++++++---------- tests/components/mqtt/test_light_json.py | 2 +- tests/components/tplink/test_light.py | 4 ++-- tests/components/yeelight/test_light.py | 12 ++++++++++ tests/components/zwave_js/test_light.py | 4 ++-- 12 files changed, 49 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 11ae379e16a..dda8a04c2ed 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -412,10 +412,11 @@ class ColorSettingTrait(_Trait): def query_attributes(self): """Return color temperature query attributes.""" - color_modes = self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) + color_mode = self.state.attributes.get(light.ATTR_COLOR_MODE) + color = {} - if light.color_supported(color_modes): + if light.color_supported([color_mode]): color_hs = self.state.attributes.get(light.ATTR_HS_COLOR) brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS, 1) if color_hs is not None: @@ -425,7 +426,7 @@ class ColorSettingTrait(_Trait): "value": brightness / 255, } - if light.color_temp_supported(color_modes): + if light.color_temp_supported([color_mode]): temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) # Some faulty integrations might put 0 in here, raising exception. if temp == 0: diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 0ef877e6247..6865ae165bc 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -829,6 +829,13 @@ class LightEntity(ToggleEntity): data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) data[ATTR_RGBWW_COLOR] = tuple(int(x) for x in rgbww_color[0:5]) data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif color_mode == COLOR_MODE_COLOR_TEMP and self.color_temp: + hs_color = color_util.color_temperature_to_hs( + color_util.color_temperature_mired_to_kelvin(self.color_temp) + ) + data[ATTR_HS_COLOR] = (round(hs_color[0], 3), round(hs_color[1], 3)) + data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) + data[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) return data @final @@ -863,7 +870,7 @@ class LightEntity(ToggleEntity): if color_mode == COLOR_MODE_COLOR_TEMP: data[ATTR_COLOR_TEMP] = self.color_temp - if color_mode in COLOR_MODES_COLOR: + if color_mode in COLOR_MODES_COLOR or color_mode == COLOR_MODE_COLOR_TEMP: data.update(self._light_internal_convert_color(color_mode)) if supported_features & SUPPORT_COLOR_TEMP and not self.supported_color_modes: diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index fbc926d318f..c85c71aa723 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -45,7 +45,7 @@ async def test_light_state_temperature( assert state assert state.attributes.get(ATTR_BRIGHTNESS) == 54 assert state.attributes.get(ATTR_COLOR_TEMP) == 297 - assert state.attributes.get(ATTR_HS_COLOR) is None + assert state.attributes.get(ATTR_HS_COLOR) == (27.316, 47.743) assert state.attributes.get(ATTR_COLOR_MODE) == COLOR_MODE_COLOR_TEMP assert state.attributes.get(ATTR_MIN_MIREDS) == 143 assert state.attributes.get(ATTR_MAX_MIREDS) == 344 diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index f9261fcba3f..50006060f51 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -576,6 +576,7 @@ async def test_color_setting_color_light(hass, supported_color_modes): { light.ATTR_HS_COLOR: (20, 94), light.ATTR_BRIGHTNESS: 200, + light.ATTR_COLOR_MODE: "hs", "supported_color_modes": supported_color_modes, }, ), @@ -634,6 +635,7 @@ async def test_color_setting_temperature_light(hass): STATE_ON, { light.ATTR_MIN_MIREDS: 200, + light.ATTR_COLOR_MODE: "color_temp", light.ATTR_COLOR_TEMP: 300, light.ATTR_MAX_MIREDS: 500, "supported_color_modes": ["color_temp"], diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index e7a862ee0ad..e769bf33f8a 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -618,12 +618,12 @@ async def test_emulated_color_temp_group(hass, enable_custom_integrations): state = hass.states.get("light.test1") assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_TEMP] == 200 - assert ATTR_HS_COLOR not in state.attributes.keys() + assert ATTR_HS_COLOR in state.attributes.keys() state = hass.states.get("light.test2") assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_TEMP] == 200 - assert ATTR_HS_COLOR not in state.attributes.keys() + assert ATTR_HS_COLOR in state.attributes.keys() state = hass.states.get("light.test3") assert state.state == STATE_ON diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light.py index f4f663c23ae..6025b725c60 100644 --- a/tests/components/hue/test_light.py +++ b/tests/components/hue/test_light.py @@ -260,7 +260,7 @@ async def test_lights_color_mode(hass, mock_bridge): assert lamp_1.state == "on" assert lamp_1.attributes["brightness"] == 145 assert lamp_1.attributes["color_temp"] == 467 - assert "hs_color" not in lamp_1.attributes + assert "hs_color" in lamp_1.attributes async def test_groups(hass, mock_bridge): diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index d9394ae946e..f3bd4583676 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1161,6 +1161,9 @@ async def test_light_backwards_compatibility_color_mode( state = hass.states.get(entity2.entity_id) assert state.attributes["supported_color_modes"] == [light.COLOR_MODE_COLOR_TEMP] assert state.attributes["color_mode"] == light.COLOR_MODE_COLOR_TEMP + assert state.attributes["rgb_color"] == (201, 218, 255) + assert state.attributes["hs_color"] == (221.575, 20.9) + assert state.attributes["xy_color"] == (0.277, 0.287) state = hass.states.get(entity3.entity_id) assert state.attributes["supported_color_modes"] == [light.COLOR_MODE_HS] diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 7341eeb67fc..ae6a58c7d22 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -393,7 +393,7 @@ async def test_legacy_controlling_state_via_topic(hass, mqtt_mock): async_fire_mqtt_message(hass, "test_light_rgb/rgb/status", "125,125,125") light_state = hass.states.get("light.test") - assert light_state.attributes.get("rgb_color") is None + assert light_state.attributes.get("rgb_color") == (255, 187, 131) assert light_state.attributes.get(light.ATTR_COLOR_MODE) == "color_temp" assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes @@ -636,13 +636,13 @@ async def test_legacy_invalid_state_via_topic(hass, mqtt_mock, caplog): state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgb_color") == (255, 254, 250) assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_temp") == 153 assert state.attributes.get("effect") == "none" - assert state.attributes.get("hs_color") is None + assert state.attributes.get("hs_color") == (54.768, 1.6) assert state.attributes.get("white_value") == 255 - assert state.attributes.get("xy_color") is None + assert state.attributes.get("xy_color") == (0.326, 0.333) async_fire_mqtt_message(hass, "test_light_rgb/color_temp/status", "") assert "Ignoring empty color temp message" in caplog.text @@ -776,12 +776,12 @@ async def test_invalid_state_via_topic(hass, mqtt_mock, caplog): state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgb_color") == (255, 254, 250) assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_temp") == 153 assert state.attributes.get("effect") == "none" - assert state.attributes.get("hs_color") is None - assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") == (54.768, 1.6) + assert state.attributes.get("xy_color") == (0.326, 0.333) async_fire_mqtt_message(hass, "test_light_rgb/color_temp/status", "") assert "Ignoring empty color temp message" in caplog.text @@ -988,7 +988,7 @@ async def test_legacy_controlling_state_via_topic_with_templates(hass, mqtt_mock state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 50 - assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgb_color") == (255, 187, 131) assert state.attributes.get("color_temp") == 300 assert state.attributes.get("effect") == "rainbow" assert state.attributes.get("white_value") == 75 @@ -1260,11 +1260,11 @@ async def test_legacy_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgb_color") == (221, 229, 255) assert state.attributes["brightness"] == 50 - assert state.attributes.get("hs_color") is None + assert state.attributes.get("hs_color") == (224.772, 13.249) assert state.attributes["white_value"] == 80 - assert state.attributes.get("xy_color") is None + assert state.attributes.get("xy_color") == (0.296, 0.301) assert state.attributes["color_temp"] == 125 diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 8aba08f60d7..bf0dd1880a4 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -392,7 +392,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color":null}') light_state = hass.states.get("light.test") - assert "hs_color" not in light_state.attributes + assert "hs_color" in light_state.attributes async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color_temp":155}') diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index c9b07529ea4..1854e714902 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -525,7 +525,7 @@ async def test_light(hass: HomeAssistant, light_mock_data: LightMockData) -> Non assert state.state == "on" assert state.attributes["brightness"] == 51 assert state.attributes["color_temp"] == 222 - assert "hs_color" not in state.attributes + assert "hs_color" in state.attributes assert light_state["on_off"] == 1 await hass.services.async_call( @@ -582,7 +582,7 @@ async def test_light(hass: HomeAssistant, light_mock_data: LightMockData) -> Non assert state.state == "on" assert state.attributes["brightness"] == 168 assert state.attributes["color_temp"] == 156 - assert "hs_color" not in state.attributes + assert "hs_color" in state.attributes assert light_state["brightness"] == 66 assert light_state["hue"] == 77 assert light_state["saturation"] == 78 diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 7497fa8773e..4b8717f4ba4 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -673,6 +673,9 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], + "hs_color": (26.812, 34.87), + "rgb_color": (255, 205, 166), + "xy_color": (0.421, 0.364), }, { "supported_features": 0, @@ -837,6 +840,9 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], + "hs_color": (26.812, 34.87), + "rgb_color": (255, 205, 166), + "xy_color": (0.421, 0.364), }, { "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, @@ -870,6 +876,9 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], + "hs_color": (26.812, 34.87), + "rgb_color": (255, 205, 166), + "xy_color": (0.421, 0.364), }, { "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, @@ -893,6 +902,9 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_temp": bg_ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], + "hs_color": (27.001, 19.243), + "rgb_color": (255, 228, 205), + "xy_color": (0.372, 0.35), }, name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 5ce66d6d8e2..373ca2525ac 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -129,7 +129,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): assert state.attributes[ATTR_COLOR_MODE] == "color_temp" assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_COLOR_TEMP] == 370 - assert ATTR_RGB_COLOR not in state.attributes + assert ATTR_RGB_COLOR in state.attributes # Test turning on with same brightness await hass.services.async_call( @@ -387,7 +387,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): assert state.attributes[ATTR_COLOR_MODE] == "color_temp" assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_COLOR_TEMP] == 170 - assert ATTR_RGB_COLOR not in state.attributes + assert ATTR_RGB_COLOR in state.attributes # Test turning on with same color temp await hass.services.async_call( From 35d943ba5674632279eaf744423265c77c9e427e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Aug 2021 15:07:31 -0500 Subject: [PATCH 809/903] Add services to bond to start and stop increase/decrease brightness (#55006) --- homeassistant/components/bond/light.py | 47 +++++- homeassistant/components/bond/services.yaml | 23 +++ homeassistant/components/bond/utils.py | 13 +- tests/components/bond/test_light.py | 150 ++++++++++++++++++++ 4 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/bond/services.yaml diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 31eceda6c41..9fe33e8e99e 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -12,7 +12,9 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,6 +25,16 @@ from .utils import BondDevice _LOGGER = logging.getLogger(__name__) +SERVICE_START_INCREASING_BRIGHTNESS = "start_increasing_brightness" +SERVICE_START_DECREASING_BRIGHTNESS = "start_decreasing_brightness" +SERVICE_STOP = "stop" + +ENTITY_SERVICES = [ + SERVICE_START_INCREASING_BRIGHTNESS, + SERVICE_START_DECREASING_BRIGHTNESS, + SERVICE_STOP, +] + async def async_setup_entry( hass: HomeAssistant, @@ -34,6 +46,14 @@ async def async_setup_entry( hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() + for service in ENTITY_SERVICES: + platform.async_register_entity_service( + service, + {}, + f"async_{service}", + ) + fan_lights: list[Entity] = [ BondLight(hub, device, bpup_subs) for device in hub.devices @@ -119,6 +139,31 @@ class BondLight(BondBaseLight, BondEntity, LightEntity): """Turn off the light.""" await self._hub.bond.action(self._device.device_id, Action.turn_light_off()) + @callback + def _async_has_action_or_raise(self, action: str) -> None: + """Raise HomeAssistantError if the device does not support an action.""" + if not self._device.has_action(action): + raise HomeAssistantError(f"{self.entity_id} does not support {action}") + + async def async_start_increasing_brightness(self) -> None: + """Start increasing the light brightness.""" + self._async_has_action_or_raise(Action.START_INCREASING_BRIGHTNESS) + await self._hub.bond.action( + self._device.device_id, Action(Action.START_INCREASING_BRIGHTNESS) + ) + + async def async_start_decreasing_brightness(self) -> None: + """Start decreasing the light brightness.""" + self._async_has_action_or_raise(Action.START_DECREASING_BRIGHTNESS) + await self._hub.bond.action( + self._device.device_id, Action(Action.START_DECREASING_BRIGHTNESS) + ) + + async def async_stop(self) -> None: + """Stop all actions and clear the queue.""" + self._async_has_action_or_raise(Action.STOP) + await self._hub.bond.action(self._device.device_id, Action(Action.STOP)) + class BondDownLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" diff --git a/homeassistant/components/bond/services.yaml b/homeassistant/components/bond/services.yaml new file mode 100644 index 00000000000..1cb24c5ed71 --- /dev/null +++ b/homeassistant/components/bond/services.yaml @@ -0,0 +1,23 @@ +start_increasing_brightness: + name: Start increasing brightness + description: "Start increasing the brightness of the light." + target: + entity: + integration: bond + domain: light + +start_decreasing_brightness: + name: Start decreasing brightness + description: "Start decreasing the brightness of the light." + target: + entity: + integration: bond + domain: light + +stop: + name: Stop + description: "Stop any in-progress action and empty the queue." + target: + entity: + integration: bond + domain: light diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 6ace83831fe..4f3de1bf1f0 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -25,7 +25,8 @@ class BondDevice: """Create a helper device from ID and attributes returned by API.""" self.device_id = device_id self.props = props - self._attrs = attrs + self._attrs = attrs or {} + self._supported_actions: set[str] = set(self._attrs.get("actions", [])) def __repr__(self) -> str: """Return readable representation of a bond device.""" @@ -65,13 +66,13 @@ class BondDevice: """Check if Trust State is turned on.""" return self.props.get("trust_state", False) + def has_action(self, action: str) -> bool: + """Check to see if the device supports an actions.""" + return action in self._supported_actions + def _has_any_action(self, actions: set[str]) -> bool: """Check to see if the device supports any of the actions.""" - supported_actions: list[str] = self._attrs["actions"] - for action in supported_actions: - if action in actions: - return True - return False + return bool(self._supported_actions.intersection(actions)) def supports_speed(self) -> bool: """Return True if this device supports any of the speed related commands.""" diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index e59efcd7bcf..545feee21a5 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -2,8 +2,15 @@ from datetime import timedelta from bond_api import Action, DeviceType +import pytest from homeassistant import core +from homeassistant.components.bond.const import DOMAIN +from homeassistant.components.bond.light import ( + SERVICE_START_DECREASING_BRIGHTNESS, + SERVICE_START_INCREASING_BRIGHTNESS, + SERVICE_STOP, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -16,6 +23,7 @@ from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow @@ -98,6 +106,21 @@ def fireplace_with_light(name: str): } +def light_brightness_increase_decrease_only(name: str): + """Create a light that can only increase or decrease brightness.""" + return { + "name": name, + "type": DeviceType.LIGHT, + "actions": [ + Action.TURN_LIGHT_ON, + Action.TURN_LIGHT_OFF, + Action.START_INCREASING_BRIGHTNESS, + Action.START_DECREASING_BRIGHTNESS, + Action.STOP, + ], + } + + async def test_fan_entity_registry(hass: core.HomeAssistant): """Tests that fan with light devices are registered in the entity registry.""" await setup_platform( @@ -231,6 +254,133 @@ async def test_no_trust_state(hass: core.HomeAssistant): assert device.attributes.get(ATTR_ASSUMED_STATE) is not True +async def test_light_start_increasing_brightness(hass: core.HomeAssistant): + """Tests a light that can only increase or decrease brightness delegates to API can start increasing brightness.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light_brightness_increase_decrease_only("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_START_INCREASING_BRIGHTNESS, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action(Action.START_INCREASING_BRIGHTNESS) + ) + + +async def test_light_start_increasing_brightness_missing_service( + hass: core.HomeAssistant, +): + """Tests a light does not have start increasing brightness throws.""" + await setup_platform( + hass, LIGHT_DOMAIN, light("name-1"), bond_device_id="test-device-id" + ) + + with pytest.raises(HomeAssistantError), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_START_INCREASING_BRIGHTNESS, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_light_start_decreasing_brightness(hass: core.HomeAssistant): + """Tests a light that can only increase or decrease brightness delegates to API can start decreasing brightness.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light_brightness_increase_decrease_only("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_START_DECREASING_BRIGHTNESS, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action(Action.START_DECREASING_BRIGHTNESS) + ) + + +async def test_light_start_decreasing_brightness_missing_service( + hass: core.HomeAssistant, +): + """Tests a light does not have start decreasing brightness throws.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises(HomeAssistantError), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_START_DECREASING_BRIGHTNESS, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_light_stop(hass: core.HomeAssistant): + """Tests a light that can only increase or decrease brightness delegates to API can stop.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light_brightness_increase_decrease_only("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_STOP, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with("test-device-id", Action(Action.STOP)) + + +async def test_light_stop_missing_service( + hass: core.HomeAssistant, +): + """Tests a light does not have stop throws.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises(HomeAssistantError), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_STOP, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + async def test_turn_on_light(hass: core.HomeAssistant): """Tests that turn on command delegates to API.""" await setup_platform( From f78d57515a2ebb0103bfbf922199fd254c88308c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Aug 2021 22:11:21 +0200 Subject: [PATCH 810/903] Bumped version to 2021.9.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7f5dba5b17d..b4751f86b41 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 1865a280839bcfaa1a0ed3d71fd84596eaed226e Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 26 Aug 2021 11:35:35 -0500 Subject: [PATCH 811/903] Set up polling task with subscriptions in Sonos (#54355) --- homeassistant/components/sonos/speaker.py | 32 +++++++++-------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 6f37739a17a..30d107bdd8d 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -323,6 +323,18 @@ class SonosSpeaker: async def async_subscribe(self) -> bool: """Initiate event subscriptions.""" _LOGGER.debug("Creating subscriptions for %s", self.zone_name) + + # Create a polling task in case subscriptions fail or callback events do not arrive + if not self._poll_timer: + self._poll_timer = self.hass.helpers.event.async_track_time_interval( + partial( + async_dispatcher_send, + self.hass, + f"{SONOS_POLL_UPDATE}-{self.soco.uid}", + ), + SCAN_INTERVAL, + ) + try: await self.hass.async_add_executor_job(self.set_basic_info) @@ -337,10 +349,10 @@ class SonosSpeaker: for service in SUBSCRIPTION_SERVICES ] await asyncio.gather(*subscriptions) - return True except SoCoException as ex: _LOGGER.warning("Could not connect %s: %s", self.zone_name, ex) return False + return True async def _subscribe( self, target: SubscriptionBase, sub_callback: Callable @@ -497,15 +509,6 @@ class SonosSpeaker: self.soco.ip_address, ) - self._poll_timer = self.hass.helpers.event.async_track_time_interval( - partial( - async_dispatcher_send, - self.hass, - f"{SONOS_POLL_UPDATE}-{self.soco.uid}", - ), - SCAN_INTERVAL, - ) - if self._is_ready and not self.subscriptions_failed: done = await self.async_subscribe() if not done: @@ -567,15 +570,6 @@ class SonosSpeaker: self._seen_timer = self.hass.helpers.event.async_call_later( SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen ) - if not self._poll_timer: - self._poll_timer = self.hass.helpers.event.async_track_time_interval( - partial( - async_dispatcher_send, - self.hass, - f"{SONOS_POLL_UPDATE}-{self.soco.uid}", - ), - SCAN_INTERVAL, - ) self.async_write_entity_states() # From 05df9b4b8b5aa5795ba4b96ea9603096efaee1a4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 26 Aug 2021 00:37:18 +0200 Subject: [PATCH 812/903] Remove temperature conversion - tado (#55231) --- homeassistant/components/tado/sensor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 537e094bfd2..044241f2be0 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -168,10 +168,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): return if self.home_variable == "outdoor temperature": - self._state = self.hass.config.units.temperature( - self._tado_weather_data["outsideTemperature"]["celsius"], - TEMP_CELSIUS, - ) + self._state = self._tado_weather_data["outsideTemperature"]["celsius"] self._state_attributes = { "time": self._tado_weather_data["outsideTemperature"]["timestamp"], } @@ -245,7 +242,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.zone_variable == "temperature": - return self.hass.config.units.temperature_unit + return TEMP_CELSIUS if self.zone_variable == "humidity": return PERCENTAGE if self.zone_variable == "heating": @@ -277,9 +274,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): return if self.zone_variable == "temperature": - self._state = self.hass.config.units.temperature( - self._tado_zone_data.current_temp, TEMP_CELSIUS - ) + self._state = self._tado_zone_data.current_temp self._state_attributes = { "time": self._tado_zone_data.current_temp_timestamp, "setting": 0, # setting is used in climate device From 3d09478aea849c1db496f600e23baaa02abbf597 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 08:59:02 -0500 Subject: [PATCH 813/903] Limit USB discovery to specific manufacturer/description/serial_number matches (#55236) * Limit USB discovery to specific manufacturer/description/serial_number matches * test for None case --- homeassistant/components/usb/__init__.py | 20 ++ homeassistant/components/zha/manifest.json | 7 +- homeassistant/generated/usb.py | 14 +- script/hassfest/manifest.py | 3 + tests/components/usb/test_init.py | 293 ++++++++++++++++++++- 5 files changed, 324 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 3aaccc15a64..d02c01ad03d 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import dataclasses +import fnmatch import logging import os import sys @@ -72,6 +73,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _fnmatch_lower(name: str | None, pattern: str) -> bool: + """Match a lowercase version of the name.""" + if name is None: + return False + return fnmatch.fnmatch(name.lower(), pattern) + + class USBDiscovery: """Manage USB Discovery.""" @@ -152,6 +160,18 @@ class USBDiscovery: continue if "pid" in matcher and device.pid != matcher["pid"]: continue + if "serial_number" in matcher and not _fnmatch_lower( + device.serial_number, matcher["serial_number"] + ): + continue + if "manufacturer" in matcher and not _fnmatch_lower( + device.manufacturer, matcher["manufacturer"] + ): + continue + if "description" in matcher and not _fnmatch_lower( + device.description, matcher["description"] + ): + continue flow: USBFlow = { "domain": matcher["domain"], "context": {"source": config_entries.SOURCE_USB}, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 93d9816d339..2c1d625b7fe 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -16,10 +16,9 @@ "zigpy-znp==0.5.3" ], "usb": [ - {"vid":"10C4","pid":"EA60","known_devices":["slae.sh cc2652rb stick"]}, - {"vid":"1CF1","pid":"0030","known_devices":["Conbee II"]}, - {"vid":"1A86","pid":"7523","known_devices":["Electrolama zig-a-zig-ah"]}, - {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]} + {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, + {"vid":"1CF1","pid":"0030","description":"*conbee*","known_devices":["Conbee II"]}, + {"vid":"10C4","pid":"8A2A","description":"*zigbee*","known_devices":["Nortek HUSBZB-1"]} ], "codeowners": ["@dmulcahey", "@adminiuga"], "zeroconf": [ diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index cb672c736b2..477a762ae62 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -9,22 +9,20 @@ USB = [ { "domain": "zha", "vid": "10C4", - "pid": "EA60" + "pid": "EA60", + "description": "*2652*" }, { "domain": "zha", "vid": "1CF1", - "pid": "0030" - }, - { - "domain": "zha", - "vid": "1A86", - "pid": "7523" + "pid": "0030", + "description": "*conbee*" }, { "domain": "zha", "vid": "10C4", - "pid": "8A2A" + "pid": "8A2A", + "description": "*zigbee*" }, { "domain": "zwave_js", diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 8c9776ed7c9..abade24dbf9 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -210,6 +210,9 @@ MANIFEST_SCHEMA = vol.Schema( { vol.Optional("vid"): vol.All(str, verify_uppercase), vol.Optional("pid"): vol.All(str, verify_uppercase), + vol.Optional("serial_number"): vol.All(str, verify_lowercase), + vol.Optional("manufacturer"): vol.All(str, verify_lowercase), + vol.Optional("description"): vol.All(str, verify_lowercase), vol.Optional("known_devices"): [str], } ) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 9c480f11fc6..e22e514f230 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components import usb from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.setup import async_setup_component -from . import slae_sh_device +from . import conbee_device, slae_sh_device @pytest.fixture(name="operating_system") @@ -171,6 +171,297 @@ async def test_discovered_by_websocket_scan(hass, hass_ws_client): assert mock_config_flow.mock_calls[0][1][0] == "test1" +async def test_discovered_by_websocket_scan_limited_by_description_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the description matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*2652*"} + ] + + mock_comports = [ + 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("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: + 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() + ws_client = await hass_ws_client(hass) + 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() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_description_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan rejected by the description matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*not_it*"} + ] + + mock_comports = [ + 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("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: + 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() + ws_client = await hass_ws_client(hass) + 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() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the serial_number matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "serial_number": "00_12_4b_00*", + } + ] + + mock_comports = [ + 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("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: + 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() + ws_client = await hass_ws_client(hass) + 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() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is rejected by the serial_number matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} + ] + + mock_comports = [ + 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("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: + 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() + ws_client = await hass_ws_client(hass) + 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() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the manufacturer matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "manufacturer": "dresden elektronik ingenieurtechnik*", + } + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + ] + + 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: + 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() + ws_client = await hass_ws_client(hass) + 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() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is rejected by the manufacturer matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "manufacturer": "other vendor*", + } + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + ] + + 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: + 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() + ws_client = await hass_ws_client(hass) + 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() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( + hass, hass_ws_client +): + """Test a device is discovered from websocket is rejected with empty serial number.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=None, + manufacturer=None, + description=None, + ) + ] + + 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: + 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() + ws_client = await hass_ws_client(hass) + 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() + + assert len(mock_config_flow.mock_calls) == 0 + + async def test_discovered_by_websocket_scan_match_vid_only(hass, hass_ws_client): """Test a device is discovered from websocket scan only matching vid.""" new_usb = [{"domain": "test1", "vid": "3039"}] From aa907f4d10eeee6e70c15141f2b4528608ddc8b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 11:36:25 -0500 Subject: [PATCH 814/903] Only warn once per entity when the async_camera_image signature needs to be updated (#55238) --- homeassistant/components/camera/__init__.py | 17 +++++++++++----- tests/components/camera/test_init.py | 22 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 14cd64df920..9724e8e1e70 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -165,10 +165,7 @@ async def _async_get_image( width=width, height=height ) else: - _LOGGER.warning( - "The camera entity %s does not support requesting width and height, please open an issue with the integration author", - camera.entity_id, - ) + camera.async_warn_old_async_camera_image_signature() image_bytes = await camera.async_camera_image() if image_bytes: @@ -381,6 +378,7 @@ class Camera(Entity): self.stream_options: dict[str, str] = {} self.content_type: str = DEFAULT_CONTENT_TYPE self.access_tokens: collections.deque = collections.deque([], 2) + self._warned_old_signature = False self.async_update_token() @property @@ -455,11 +453,20 @@ class Camera(Entity): return await self.hass.async_add_executor_job( partial(self.camera_image, width=width, height=height) ) + self.async_warn_old_async_camera_image_signature() + return await self.hass.async_add_executor_job(self.camera_image) + + # Remove in 2022.1 after all custom components have had a chance to change their signature + @callback + def async_warn_old_async_camera_image_signature(self) -> None: + """Warn once when calling async_camera_image with the function old signature.""" + if self._warned_old_signature: + return _LOGGER.warning( "The camera entity %s does not support requesting width and height, please open an issue with the integration author", self.entity_id, ) - return await self.hass.async_add_executor_job(self.camera_image) + self._warned_old_signature = True async def handle_async_still_stream( self, request: web.Request, interval: float diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index bb3f76e0d1b..df4b64e4310 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -77,6 +77,28 @@ async def test_get_image_from_camera(hass, image_mock_url): assert image.content == b"Test" +async def test_legacy_async_get_image_signature_warns_only_once( + hass, image_mock_url, caplog +): + """Test that we only warn once when we encounter a legacy async_get_image function signature.""" + + async def _legacy_async_camera_image(self): + return b"Image" + + with patch( + "homeassistant.components.demo.camera.DemoCamera.async_camera_image", + new=_legacy_async_camera_image, + ): + image = await camera.async_get_image(hass, "camera.demo_camera") + assert image.content == b"Image" + assert "does not support requesting width and height" in caplog.text + caplog.clear() + + image = await camera.async_get_image(hass, "camera.demo_camera") + assert image.content == b"Image" + assert "does not support requesting width and height" not in caplog.text + + async def test_get_image_from_camera_with_width_height(hass, image_mock_url): """Grab an image from camera entity with width and height.""" From 175febe63590fb6e075c951f41dbab6255278328 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 08:59:41 -0500 Subject: [PATCH 815/903] Defer zha auto configure probe until after clicking configure (#55239) --- homeassistant/components/zha/config_flow.py | 18 ++++++------- homeassistant/components/zha/strings.json | 3 ++- tests/components/zha/test_config_flow.py | 28 +++++++-------------- 3 files changed, 18 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 61898328d2e..772362b3850 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -36,7 +36,6 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize flow instance.""" self._device_path = None self._radio_type = None - self._auto_detected_data = None self._title = None async def async_step_user(self, user_input=None): @@ -124,15 +123,6 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if flow["handler"] == "deconz": return self.async_abort(reason="not_zha_device") - # The Nortek sticks are a special case since they - # have a Z-Wave and a Zigbee radio. We need to reject - # the Z-Wave radio. - if vid == "10C4" and pid == "8A2A" and "ZigBee" not in description: - return self.async_abort(reason="not_zha_device") - - self._auto_detected_data = await detect_radios(dev_path) - if self._auto_detected_data is None: - return self.async_abort(reason="not_zha_device") self._device_path = dev_path self._title = usb.human_readable_device_name( dev_path, @@ -149,9 +139,15 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_confirm(self, user_input=None): """Confirm a discovery.""" if user_input is not None: + auto_detected_data = await detect_radios(self._device_path) + if auto_detected_data is None: + # This probably will not happen how they have + # have very specific usb matching, but there could + # be a problem with the device + return self.async_abort(reason="usb_probe_failed") return self.async_create_entry( title=self._title, - data=self._auto_detected_data, + data=auto_detected_data, ) return self.async_show_form( diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 4b5b429522f..5953df52e92 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -30,7 +30,8 @@ }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "not_zha_device": "This device is not a zha device" + "not_zha_device": "This device is not a zha device", + "usb_probe_failed": "Failed to probe the usb device" } }, "config_panel": { diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 9f7e3baeaf1..281a0683eb8 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -164,27 +164,17 @@ async def test_discovery_via_usb_no_radio(detect_mock, hass): "zha", context={"source": SOURCE_USB}, data=discovery_info ) await hass.async_block_till_done() - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "not_zha_device" + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + with patch("homeassistant.components.zha.async_setup_entry"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() -@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) -async def test_discovery_via_usb_rejects_nortek_zwave(detect_mock, hass): - """Test usb flow -- reject the nortek zwave radio.""" - discovery_info = { - "device": "/dev/null", - "vid": "10C4", - "pid": "8A2A", - "serial_number": "612020FD", - "description": "HubZ Smart Home Controller - HubZ Z-Wave Com Port", - "manufacturer": "Silicon Labs", - } - result = await hass.config_entries.flow.async_init( - "zha", context={"source": SOURCE_USB}, data=discovery_info - ) - await hass.async_block_till_done() - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "not_zha_device" + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "usb_probe_failed" @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) From 6f24f4e302333be2e2a07fb0157fd7dee88b3b6e Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 26 Aug 2021 11:38:35 -0400 Subject: [PATCH 816/903] Bump up ZHA dependencies (#55242) * Bump up ZHA dependencies * Bump up zha-device-handlers --- homeassistant/components/zha/manifest.json | 12 ++++++------ requirements_all.txt | 12 ++++++------ requirements_test_all.txt | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 2c1d625b7fe..4b2b27e829c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,16 +4,16 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.26.0", + "bellows==0.27.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.59", + "zha-quirks==0.0.60", "zigpy-cc==0.5.2", - "zigpy-deconz==0.12.1", - "zigpy==0.36.1", - "zigpy-xbee==0.13.0", + "zigpy-deconz==0.13.0", + "zigpy==0.37.1", + "zigpy-xbee==0.14.0", "zigpy-zigate==0.7.3", - "zigpy-znp==0.5.3" + "zigpy-znp==0.5.4" ], "usb": [ {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, diff --git a/requirements_all.txt b/requirements_all.txt index b9611f50d76..83e3c0418cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ beautifulsoup4==4.9.3 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.26.0 +bellows==0.27.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.19 @@ -2459,7 +2459,7 @@ zengge==0.2 zeroconf==0.36.0 # homeassistant.components.zha -zha-quirks==0.0.59 +zha-quirks==0.0.60 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2471,19 +2471,19 @@ ziggo-mediabox-xl==1.1.0 zigpy-cc==0.5.2 # homeassistant.components.zha -zigpy-deconz==0.12.1 +zigpy-deconz==0.13.0 # homeassistant.components.zha -zigpy-xbee==0.13.0 +zigpy-xbee==0.14.0 # homeassistant.components.zha zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.3 +zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.36.1 +zigpy==0.37.1 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 23ea1648560..375948cacc4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -227,7 +227,7 @@ azure-eventhub==5.5.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.26.0 +bellows==0.27.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.19 @@ -1379,25 +1379,25 @@ zeep[async]==4.0.0 zeroconf==0.36.0 # homeassistant.components.zha -zha-quirks==0.0.59 +zha-quirks==0.0.60 # homeassistant.components.zha zigpy-cc==0.5.2 # homeassistant.components.zha -zigpy-deconz==0.12.1 +zigpy-deconz==0.13.0 # homeassistant.components.zha -zigpy-xbee==0.13.0 +zigpy-xbee==0.14.0 # homeassistant.components.zha zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.3 +zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.36.1 +zigpy==0.37.1 # homeassistant.components.zwave_js zwave-js-server-python==0.29.0 From dff6151ff42f857abee33e5e643bbe9bc0cdf51c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 09:00:32 -0500 Subject: [PATCH 817/903] Abort zha usb discovery if deconz is setup (#55245) * Abort zha usb discovery if deconz is setup * Update tests/components/zha/test_config_flow.py * add deconz domain const * Update homeassistant/components/zha/config_flow.py Co-authored-by: Robert Svensson Co-authored-by: Robert Svensson --- homeassistant/components/zha/config_flow.py | 6 ++- tests/components/zha/test_config_flow.py | 48 ++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 772362b3850..4bf255e95a0 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -25,6 +25,7 @@ SUPPORTED_PORT_SETTINGS = ( CONF_BAUDRATE, CONF_FLOWCONTROL, ) +DECONZ_DOMAIN = "deconz" class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -120,7 +121,10 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # we ignore the usb discovery as they probably # want to use it there instead for flow in self.hass.config_entries.flow.async_progress(): - if flow["handler"] == "deconz": + if flow["handler"] == DECONZ_DOMAIN: + return self.async_abort(reason="not_zha_device") + for entry in self.hass.config_entries.async_entries(DECONZ_DOMAIN): + if entry.source != config_entries.SOURCE_IGNORE: return self.async_abort(reason="not_zha_device") self._device_path = dev_path diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 281a0683eb8..732b7cf440d 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -7,7 +7,7 @@ import serial.tools.list_ports import zigpy.config from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH -from homeassistant import setup +from homeassistant import config_entries, setup from homeassistant.components.ssdp import ( ATTR_SSDP_LOCATION, ATTR_UPNP_MANUFACTURER_URL, @@ -271,6 +271,52 @@ async def test_discovery_via_usb_deconz_already_discovered(detect_mock, hass): assert result["reason"] == "not_zha_device" +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_deconz_already_setup(detect_mock, hass): + """Test usb flow -- deconz setup.""" + MockConfigEntry(domain="deconz", data={}).add_to_hass(hass) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "not_zha_device" + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_deconz_ignored(detect_mock, hass): + """Test usb flow -- deconz ignored.""" + MockConfigEntry( + domain="deconz", source=config_entries.SOURCE_IGNORE, data={} + ).add_to_hass(hass) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_already_setup(detect_mock, hass): From 20796303da001eed64e93798a0b308c203c54f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 26 Aug 2021 10:54:42 +0200 Subject: [PATCH 818/903] Only postfix image name for container (#55248) --- homeassistant/components/version/sensor.py | 2 +- tests/components/version/test_sensor.py | 30 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index 1cd42cce9b3..925e9111c1a 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -87,7 +87,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= source = HaVersionSource.CONTAINER if ( - source in (HaVersionSource.SUPERVISOR, HaVersionSource.CONTAINER) + source == HaVersionSource.CONTAINER and image is not None and image != DEFAULT_IMAGE ): diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index c8883e72389..cd56223a1e6 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -98,3 +98,33 @@ async def test_update(hass): state = hass.states.get("sensor.current_version") assert state assert state.state == "1234" + + +async def test_image_name_container(hass): + """Test the Version sensor with image name for container.""" + config = { + "sensor": {"platform": "version", "source": "docker", "image": "qemux86-64"} + } + + with patch("homeassistant.components.version.sensor.HaVersion") as haversion: + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + constructor = haversion.call_args[1] + assert constructor["source"] == "container" + assert constructor["image"] == "qemux86-64-homeassistant" + + +async def test_image_name_supervisor(hass): + """Test the Version sensor with image name for supervisor.""" + config = { + "sensor": {"platform": "version", "source": "hassio", "image": "qemux86-64"} + } + + with patch("homeassistant.components.version.sensor.HaVersion") as haversion: + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + constructor = haversion.call_args[1] + assert constructor["source"] == "supervisor" + assert constructor["image"] == "qemux86-64" From 080cb6b6e9988690a03969208f4f73adf22efaf1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Aug 2021 10:06:53 +0200 Subject: [PATCH 819/903] Fix double precision float for postgresql (#55249) --- homeassistant/components/recorder/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 017c65cd75f..8b5aef88738 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -70,7 +70,7 @@ DOUBLE_TYPE = ( Float() .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") .with_variant(oracle.DOUBLE_PRECISION(), "oracle") - .with_variant(postgresql.DOUBLE_PRECISION, "postgresql") + .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) From 3658eeb8d1aa39e2e8ea73676827401aa3ba6587 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Aug 2021 11:14:42 +0200 Subject: [PATCH 820/903] Fix MQTT add-on discovery to be ignorable (#55250) --- homeassistant/components/mqtt/config_flow.py | 3 +-- homeassistant/components/mqtt/strings.json | 1 + .../components/mqtt/translations/en.json | 1 + tests/components/mqtt/test_config_flow.py | 21 +++++++++++++++++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index c6af0cc08b5..172657ded98 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -95,8 +95,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_hassio(self, discovery_info): """Receive a Hass.io discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + await self._async_handle_discovery_without_unique_id() self._hassio_discovery = discovery_info diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 9de9075f19d..155f9fcb4f2 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -20,6 +20,7 @@ } }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "error": { diff --git a/homeassistant/components/mqtt/translations/en.json b/homeassistant/components/mqtt/translations/en.json index 775b4d21c9b..23012946a71 100644 --- a/homeassistant/components/mqtt/translations/en.json +++ b/homeassistant/components/mqtt/translations/en.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Service is already configured", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 55bacb0ef91..e00e959e606 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.components import mqtt +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -100,7 +101,7 @@ async def test_user_single_instance(hass): assert result["reason"] == "single_instance_allowed" -async def test_hassio_single_instance(hass): +async def test_hassio_already_configured(hass): """Test we only allow a single config flow.""" MockConfigEntry(domain="mqtt").add_to_hass(hass) @@ -108,7 +109,23 @@ async def test_hassio_single_instance(hass): "mqtt", context={"source": config_entries.SOURCE_HASSIO} ) assert result["type"] == "abort" - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" + + +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 + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + mqtt.DOMAIN, + data={"addon": "Mosquitto", "host": "mock-mosquitto", "port": "1883"}, + context={"source": config_entries.SOURCE_HASSIO}, + ) + assert result + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" async def test_hassio_confirm(hass, mock_try_connection, mock_finish_setup): From aefd3df9145a136a0357d6ae6d794343bd647b1f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Aug 2021 14:27:14 +0200 Subject: [PATCH 821/903] Warn if a sensor with state_class_total has a decreasing value twice (#55251) --- homeassistant/components/sensor/recorder.py | 13 ++++++++++++- tests/components/sensor/test_recorder.py | 20 +++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 2115cca2892..bcb21136007 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -108,6 +108,7 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { } # Keep track of entities for which a warning about decreasing value has been logged +SEEN_DIP = "sensor_seen_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip" # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" @@ -233,7 +234,17 @@ def _normalize_states( def warn_dip(hass: HomeAssistant, entity_id: str) -> None: - """Log a warning once if a sensor with state_class_total has a decreasing value.""" + """Log a warning once if a sensor with state_class_total has a decreasing value. + + The log will be suppressed until two dips have been seen to prevent warning due to + rounding issues with databases storing the state as a single precision float, which + was fixed in recorder DB version 20. + """ + if SEEN_DIP not in hass.data: + hass.data[SEEN_DIP] = set() + if entity_id not in hass.data[SEEN_DIP]: + hass.data[SEEN_DIP].add(entity_id) + return if WARN_DIP not in hass.data: hass.data[WARN_DIP] = set() if entity_id not in hass.data[WARN_DIP]: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 0234a8c0613..c7f356e49ee 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -371,7 +371,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "state_class": "total_increasing", "unit_of_measurement": unit, } - seq = [10, 15, 20, 19, 30, 40, 50, 60, 70] + seq = [10, 15, 20, 19, 30, 40, 39, 60, 70] four, eight, states = record_meter_states( hass, zero, "sensor.test1", attributes, seq @@ -385,8 +385,20 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( wait_recording_done(hass) recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) wait_recording_done(hass) + assert ( + "Entity sensor.test1 has state class total_increasing, but its state is not " + "strictly increasing. Please create a bug report at https://github.com/" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" + "+recorder%22" + ) not in caplog.text recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) wait_recording_done(hass) + assert ( + "Entity sensor.test1 has state class total_increasing, but its state is not " + "strictly increasing. Please create a bug report at https://github.com/" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" + "+recorder%22" + ) in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} @@ -427,12 +439,6 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( ] } assert "Error while processing event StatisticsTask" not in caplog.text - assert ( - "Entity sensor.test1 has state class total_increasing, but its state is not " - "strictly increasing. Please create a bug report at https://github.com/" - "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" - "+recorder%22" - ) in caplog.text def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): From f2765ba3201a1b8cec29cc1003bb3f7ddfb4b7e3 Mon Sep 17 00:00:00 2001 From: Florian Gareis Date: Thu, 26 Aug 2021 18:33:41 +0200 Subject: [PATCH 822/903] Don't create DSL sensor for devices that don't support DSL (#55269) --- homeassistant/components/fritz/sensor.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index e3d366e83fd..7b6a6528eab 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -5,7 +5,12 @@ import datetime import logging from typing import Callable, TypedDict -from fritzconnection.core.exceptions import FritzConnectionException +from fritzconnection.core.exceptions import ( + FritzActionError, + FritzActionFailedError, + FritzConnectionException, + FritzServiceError, +) from fritzconnection.lib.fritzstatus import FritzStatus from homeassistant.components.sensor import ( @@ -260,12 +265,16 @@ async def async_setup_entry( return entities = [] - dslinterface = await hass.async_add_executor_job( - fritzbox_tools.connection.call_action, - "WANDSLInterfaceConfig:1", - "GetInfo", - ) - dsl: bool = dslinterface["NewEnable"] + dsl: bool = False + try: + dslinterface = await hass.async_add_executor_job( + fritzbox_tools.connection.call_action, + "WANDSLInterfaceConfig:1", + "GetInfo", + ) + dsl = dslinterface["NewEnable"] + except (FritzActionError, FritzActionFailedError, FritzServiceError): + pass for sensor_type, sensor_data in SENSOR_DATA.items(): if not dsl and sensor_data.get("connection_type") == DSL_CONNECTION: From 67dd861d8c17b127ae90868d44422662b2476083 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 26 Aug 2021 18:29:25 +0200 Subject: [PATCH 823/903] Fix AttributeError for non-MIOT Xiaomi Miio purifiers (#55271) --- homeassistant/components/xiaomi_miio/fan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 19d85ced2dc..42828943d93 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -333,7 +333,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): } ) self._mode = self._state_attrs.get(ATTR_MODE) - self._fan_level = self.coordinator.data.fan_level + self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) self.async_write_ha_state() # @@ -440,7 +440,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): {attribute: None for attribute in self._available_attributes} ) self._mode = self._state_attrs.get(ATTR_MODE) - self._fan_level = self.coordinator.data.fan_level + self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) @property def preset_mode(self): From 219868b30847c72cbcf79f9cbb8024356db60fff Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Aug 2021 09:37:25 -0700 Subject: [PATCH 824/903] Bumped version to 2021.9.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b4751f86b41..3f7a7c89dee 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From e6e72bfa821858f71030169bf92a088c53421480 Mon Sep 17 00:00:00 2001 From: prwood80 <22550665+prwood80@users.noreply.github.com> Date: Fri, 27 Aug 2021 11:22:49 -0500 Subject: [PATCH 825/903] Improve performance of ring camera still images (#53803) Co-authored-by: Pat Wood Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- homeassistant/components/ring/camera.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 509877ae5ff..6a4ef692c1e 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -52,6 +52,7 @@ class RingCam(RingEntityMixin, Camera): self._last_event = None self._last_video_id = None self._video_url = None + self._image = None self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL async def async_added_to_hass(self): @@ -80,6 +81,7 @@ class RingCam(RingEntityMixin, Camera): self._last_event = None self._last_video_id = None self._video_url = None + self._image = None self._expires_at = dt_util.utcnow() self.async_write_ha_state() @@ -106,12 +108,18 @@ class RingCam(RingEntityMixin, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - if self._video_url is None: - return + if self._image is None and self._video_url: + image = await ffmpeg.async_get_image( + self.hass, + self._video_url, + width=width, + height=height, + ) - return await ffmpeg.async_get_image( - self.hass, self._video_url, width=width, height=height - ) + if image: + self._image = image + + return self._image async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" @@ -144,6 +152,9 @@ class RingCam(RingEntityMixin, Camera): if self._last_video_id == self._last_event["id"] and utcnow <= self._expires_at: return + if self._last_video_id != self._last_event["id"]: + self._image = None + try: video_url = await self.hass.async_add_executor_job( self._device.recording_url, self._last_event["id"] From 2a1e943b1846b2801c60110a269ca47b7b31153b Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 26 Aug 2021 13:43:26 -0700 Subject: [PATCH 826/903] Fix unique_id conflict in smarttthings (#55235) --- homeassistant/components/smartthings/sensor.py | 2 +- tests/components/smartthings/test_sensor.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 7c682486f04..f5ab5562229 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -561,7 +561,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): @property def unique_id(self) -> str: """Return a unique ID.""" - return f"{self._device.device_id}.{self.report_name}" + return f"{self._device.device_id}.{self.report_name}_meter" @property def native_value(self): diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 70103c3a837..f36f05616d6 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -168,7 +168,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "1412.002" entry = entity_registry.async_get("sensor.refrigerator_energy") assert entry - assert entry.unique_id == f"{device.device_id}.energy" + assert entry.unique_id == f"{device.device_id}.energy_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label @@ -180,7 +180,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "109" entry = entity_registry.async_get("sensor.refrigerator_power") assert entry - assert entry.unique_id == f"{device.device_id}.power" + assert entry.unique_id == f"{device.device_id}.power_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label @@ -202,7 +202,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "unknown" entry = entity_registry.async_get("sensor.vacuum_energy") assert entry - assert entry.unique_id == f"{device.device_id}.energy" + assert entry.unique_id == f"{device.device_id}.energy_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label From 7df84dadadfda652cacd4e71024a6941a8a3f669 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 13:25:26 -0500 Subject: [PATCH 827/903] Fix some yeelights showing wrong state after on/off (#55279) --- homeassistant/components/yeelight/__init__.py | 6 +- homeassistant/components/yeelight/light.py | 7 +++ tests/components/yeelight/test_light.py | 56 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 2bdde2113a4..d795abfeb32 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -684,10 +684,10 @@ class YeelightDevice: else: self._name = self._host # Default name is host - async def async_update(self): + async def async_update(self, force=False): """Update device properties and send data updated signal.""" - if self._initialized and self._available: - # No need to poll, already connected + if not force and self._initialized and self._available: + # No need to poll unless force, already connected return await self._async_update_properties() async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 4766d897909..bc4d027ce93 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -762,6 +762,10 @@ class YeelightGenericLight(YeelightEntity, LightEntity): _LOGGER.error("Unable to set the defaults: %s", ex) return + # Some devices (mainly nightlights) will not send back the on state so we need to force a refresh + if not self.is_on: + await self.device.async_update(True) + async def async_turn_off(self, **kwargs) -> None: """Turn off.""" if not self.is_on: @@ -772,6 +776,9 @@ class YeelightGenericLight(YeelightEntity, LightEntity): duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s await self.device.async_turn_off(duration=duration, light_type=self.light_type) + # Some devices will not send back the off state so we need to force a refresh + if self.is_on: + await self.device.async_update(True) async def async_set_mode(self, mode: str): """Set a power mode.""" diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 4b8717f4ba4..17924facfad 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -1122,3 +1122,59 @@ async def test_effects(hass: HomeAssistant): for name, target in effects.items(): await _async_test_effect(name, target) await _async_test_effect("not_existed", called=False) + + +async def test_state_fails_to_update_triggers_update(hass: HomeAssistant): + """Ensure we call async_get_properties if the turn on/off fails to update the state.""" + mocked_bulb = _mocked_bulb() + properties = {**PROPERTIES} + properties.pop("active_mode") + properties["color_mode"] = "3" # HSV + mocked_bulb.last_properties = properties + mocked_bulb.bulb_type = BulbType.Color + config_entry = MockConfigEntry( + domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} + ) + config_entry.add_to_hass(hass) + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # We use asyncio.create_task now to avoid + # blocking starting so we need to block again + await hass.async_block_till_done() + + mocked_bulb.last_properties["power"] = "off" + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + }, + blocking=True, + ) + assert len(mocked_bulb.async_turn_on.mock_calls) == 1 + assert len(mocked_bulb.async_get_properties.mock_calls) == 2 + + mocked_bulb.last_properties["power"] = "on" + await hass.services.async_call( + "light", + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + }, + blocking=True, + ) + assert len(mocked_bulb.async_turn_off.mock_calls) == 1 + assert len(mocked_bulb.async_get_properties.mock_calls) == 3 + + # But if the state is correct no calls + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + }, + blocking=True, + ) + assert len(mocked_bulb.async_turn_on.mock_calls) == 1 + assert len(mocked_bulb.async_get_properties.mock_calls) == 3 From 9f7398e0df2d5af331ed6a7ed3341ce74004ddc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 15:18:36 -0500 Subject: [PATCH 828/903] Fix yeelight brightness when nightlight switch is disabled (#55278) --- homeassistant/components/yeelight/light.py | 14 +++++++-- tests/components/yeelight/test_light.py | 36 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index bc4d027ce93..be876690b06 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -859,7 +859,12 @@ class YeelightColorLightWithoutNightlightSwitch( @property def _brightness_property(self): - return "current_brightness" + # If the nightlight is not active, we do not + # want to "current_brightness" since it will check + # "bg_power" and main light could still be on + if self.device.is_nightlight_enabled: + return "current_brightness" + return super()._brightness_property class YeelightColorLightWithNightlightSwitch( @@ -883,7 +888,12 @@ class YeelightWhiteTempWithoutNightlightSwitch( @property def _brightness_property(self): - return "current_brightness" + # If the nightlight is not active, we do not + # want to "current_brightness" since it will check + # "bg_power" and main light could still be on + if self.device.is_nightlight_enabled: + return "current_brightness" + return super()._brightness_property class YeelightWithNightLight( diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 17924facfad..030f6a54cea 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -96,6 +96,7 @@ from homeassistant.util.color import ( ) from . import ( + CAPABILITIES, ENTITY_LIGHT, ENTITY_NIGHTLIGHT, IP_ADDRESS, @@ -1178,3 +1179,38 @@ async def test_state_fails_to_update_triggers_update(hass: HomeAssistant): ) assert len(mocked_bulb.async_turn_on.mock_calls) == 1 assert len(mocked_bulb.async_get_properties.mock_calls) == 3 + + +async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant): + """Test that main light on ambilights with the nightlight disabled shows the correct brightness.""" + mocked_bulb = _mocked_bulb() + properties = {**PROPERTIES} + capabilities = {**CAPABILITIES} + capabilities["model"] = "ceiling10" + properties["color_mode"] = "3" # HSV + properties["bg_power"] = "off" + properties["current_brightness"] = 0 + properties["bg_lmode"] = "2" # CT + mocked_bulb.last_properties = properties + mocked_bulb.bulb_type = BulbType.WhiteTempMood + main_light_entity_id = "light.yeelight_ceiling10_0x15243f" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}, + options={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}, + ) + config_entry.add_to_hass(hass) + with _patch_discovery(capabilities=capabilities), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # We use asyncio.create_task now to avoid + # blocking starting so we need to block again + await hass.async_block_till_done() + + state = hass.states.get(main_light_entity_id) + assert state.state == "on" + # bg_power off should not set the brightness to 0 + assert state.attributes[ATTR_BRIGHTNESS] == 128 From 8a2c07ce199de26c30238308a1b376dac2721c31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 13:02:59 -0500 Subject: [PATCH 829/903] Ensure yeelight model is set in the config entry (#55281) * Ensure yeelight model is set in the config entry - If the model was not set in the config entry the light could be sent commands it could not handle * update tests * fix test --- homeassistant/components/yeelight/__init__.py | 22 +++++++---- .../components/yeelight/config_flow.py | 13 ++++++- tests/components/yeelight/test_config_flow.py | 37 +++++++++++++++---- tests/components/yeelight/test_init.py | 3 ++ 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index d795abfeb32..3d1a6cd03e1 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -196,7 +196,6 @@ async def _async_initialize( entry_data = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id] = { DATA_PLATFORMS_LOADED: False } - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @callback def _async_load_platforms(): @@ -212,6 +211,15 @@ async def _async_initialize( await device.async_setup() entry_data[DATA_DEVICE] = device + if ( + device.capabilities + and entry.options.get(CONF_MODEL) != device.capabilities["model"] + ): + hass.config_entries.async_update_entry( + entry, options={**entry.options, CONF_MODEL: device.capabilities["model"]} + ) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( async_dispatcher_connect( hass, DEVICE_INITIALIZED.format(host), _async_load_platforms @@ -540,7 +548,7 @@ class YeelightDevice: self._config = config self._host = host self._bulb_device = bulb - self._capabilities = {} + self.capabilities = {} self._device_type = None self._available = False self._initialized = False @@ -574,12 +582,12 @@ class YeelightDevice: @property def model(self): """Return configured/autodetected device model.""" - return self._bulb_device.model or self._capabilities.get("model") + return self._bulb_device.model or self.capabilities.get("model") @property def fw_version(self): """Return the firmware version.""" - return self._capabilities.get("fw_ver") + return self.capabilities.get("fw_ver") @property def is_nightlight_supported(self) -> bool: @@ -674,13 +682,13 @@ class YeelightDevice: async def async_setup(self): """Fetch capabilities and setup name if available.""" scanner = YeelightScanner.async_get(self._hass) - self._capabilities = await scanner.async_get_capabilities(self._host) or {} + self.capabilities = await scanner.async_get_capabilities(self._host) or {} if name := self._config.get(CONF_NAME): # Override default name when name is set in config self._name = name - elif self._capabilities: + elif self.capabilities: # Generate name from model and id when capabilities is available - self._name = _async_unique_name(self._capabilities) + self._name = _async_unique_name(self.capabilities) else: self._name = self._host # Default name is host diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 268a0e9cea2..73bbcdcfe5f 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -96,7 +96,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: return self.async_create_entry( title=async_format_model_id(self._discovered_model, self.unique_id), - data={CONF_ID: self.unique_id, CONF_HOST: self._discovered_ip}, + data={ + CONF_ID: self.unique_id, + CONF_HOST: self._discovered_ip, + CONF_MODEL: self._discovered_model, + }, ) self._set_confirm_only() @@ -129,6 +133,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data={ CONF_HOST: user_input[CONF_HOST], CONF_ID: self.unique_id, + CONF_MODEL: model, }, ) @@ -151,7 +156,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host = urlparse(capabilities["location"]).hostname return self.async_create_entry( title=_async_unique_name(capabilities), - data={CONF_ID: unique_id, CONF_HOST: host}, + data={ + CONF_ID: unique_id, + CONF_HOST: host, + CONF_MODEL: capabilities["model"], + }, ) configured_devices = { diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index bde8a18ae55..6bc3ba68275 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -19,7 +19,7 @@ from homeassistant.components.yeelight import ( DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, ) -from homeassistant.components.yeelight.config_flow import CannotConnect +from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM @@ -28,6 +28,7 @@ from . import ( CAPABILITIES, ID, IP_ADDRESS, + MODEL, MODULE, MODULE_CONFIG_FLOW, NAME, @@ -87,7 +88,7 @@ async def test_discovery(hass: HomeAssistant): ) assert result3["type"] == "create_entry" assert result3["title"] == UNIQUE_FRIENDLY_NAME - assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS} + assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS, CONF_MODEL: MODEL} await hass.async_block_till_done() mock_setup.assert_called_once() mock_setup_entry.assert_called_once() @@ -160,7 +161,11 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant): ) assert result3["type"] == "create_entry" assert result3["title"] == UNIQUE_FRIENDLY_NAME - assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS} + assert result3["data"] == { + CONF_ID: ID, + CONF_HOST: IP_ADDRESS, + CONF_MODEL: MODEL, + } await hass.async_block_till_done() await hass.async_block_till_done() @@ -300,7 +305,11 @@ async def test_manual(hass: HomeAssistant): await hass.async_block_till_done() assert result4["type"] == "create_entry" assert result4["title"] == "Color 0x15243f" - assert result4["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} + assert result4["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } # Duplicate result = await hass.config_entries.flow.async_init( @@ -333,7 +342,7 @@ async def test_options(hass: HomeAssistant): config = { CONF_NAME: NAME, - CONF_MODEL: "", + CONF_MODEL: MODEL, CONF_TRANSITION: DEFAULT_TRANSITION, CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC, CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE, @@ -383,7 +392,11 @@ async def test_manual_no_capabilities(hass: HomeAssistant): result["flow_id"], {CONF_HOST: IP_ADDRESS} ) assert result["type"] == "create_entry" - assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: None} + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: None, + CONF_MODEL: MODEL_UNKNOWN, + } async def test_discovered_by_homekit_and_dhcp(hass): @@ -480,7 +493,11 @@ async def test_discovered_by_dhcp_or_homekit(hass, source, data): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } assert mock_async_setup.called assert mock_async_setup_entry.called @@ -540,7 +557,11 @@ async def test_discovered_ssdp(hass): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } assert mock_async_setup.called assert mock_async_setup_entry.called diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 84c87b7f1dc..4414909d8e0 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from yeelight import BulbException, BulbType from homeassistant.components.yeelight import ( + CONF_MODEL, CONF_NIGHTLIGHT_SWITCH, CONF_NIGHTLIGHT_SWITCH_TYPE, DATA_CONFIG_ENTRIES, @@ -35,6 +36,7 @@ from . import ( FAIL_TO_BIND_IP, ID, IP_ADDRESS, + MODEL, MODULE, SHORT_ID, _mocked_bulb, @@ -360,6 +362,7 @@ async def test_async_listen_error_late_discovery(hass, caplog): assert "Failed to connect to bulb at" not in caplog.text assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options[CONF_MODEL] == MODEL async def test_async_listen_error_has_host_with_id(hass: HomeAssistant): From 97ff5e2085fc7345fe722faeb788f2508cab8905 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 19:04:12 -0500 Subject: [PATCH 830/903] Set yeelight capabilities from external discovery (#55280) --- homeassistant/components/yeelight/__init__.py | 2 ++ homeassistant/components/yeelight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 3d1a6cd03e1..8684e331fad 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -683,6 +683,8 @@ class YeelightDevice: """Fetch capabilities and setup name if available.""" scanner = YeelightScanner.async_get(self._hass) self.capabilities = await scanner.async_get_capabilities(self._host) or {} + if self.capabilities: + self._bulb_device.set_capabilities(self.capabilities) if name := self._config.get(CONF_NAME): # Override default name when name is set in config self._name = name diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 5910341cfb4..0a4b5d4499f 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.3", "async-upnp-client==0.20.0"], + "requirements": ["yeelight==0.7.4", "async-upnp-client==0.20.0"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/requirements_all.txt b/requirements_all.txt index 83e3c0418cb..274a1241c74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2438,7 +2438,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.3 +yeelight==0.7.4 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 375948cacc4..ec37d01c003 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1367,7 +1367,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.3 +yeelight==0.7.4 # homeassistant.components.youless youless-api==0.12 From 06e40036406d3ff74faa00fad7af942411031ca4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Aug 2021 10:37:53 -0700 Subject: [PATCH 831/903] Bump ring to 0.7.1 (#55282) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index ecb64c99fd7..527fb143aff 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -2,7 +2,7 @@ "domain": "ring", "name": "Ring", "documentation": "https://www.home-assistant.io/integrations/ring", - "requirements": ["ring_doorbell==0.6.2"], + "requirements": ["ring_doorbell==0.7.1"], "dependencies": ["ffmpeg"], "codeowners": ["@balloob"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 274a1241c74..e612f922d24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2043,7 +2043,7 @@ rfk101py==0.0.1 rflink==0.0.58 # homeassistant.components.ring -ring_doorbell==0.6.2 +ring_doorbell==0.7.1 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec37d01c003..031cba6ccc3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1148,7 +1148,7 @@ restrictedpython==5.1 rflink==0.0.58 # homeassistant.components.ring -ring_doorbell==0.6.2 +ring_doorbell==0.7.1 # homeassistant.components.roku rokuecp==0.8.1 From 93750d71ce6dc9f1fa3d27bb4978af4a04270b04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 15:47:10 -0500 Subject: [PATCH 832/903] Gracefully handle pyudev failing to filter on WSL (#55286) * Gracefully handle pyudev failing to filter on WSL * add debug message * add mocks so we reach the new check --- homeassistant/components/usb/__init__.py | 8 +++- tests/components/usb/test_init.py | 56 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index d02c01ad03d..679f2e1caa2 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -127,7 +127,13 @@ class USBDiscovery: return monitor = Monitor.from_netlink(context) - monitor.filter_by(subsystem="tty") + 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 observer = MonitorObserver( monitor, callback=self._device_discovered, name="usb-observer" ) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index e22e514f230..6ba21222052 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -38,6 +38,20 @@ def mock_docker(): 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", @@ -606,6 +620,48 @@ 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, hass_ws_client, venv +): + """Test that observer on WSL failure results in fallback to scanning without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + + mock_comports = [ + 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("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: + 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() + ws_client = await hass_ws_client(hass) + 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() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + @pytest.mark.skipif( not sys.platform.startswith("linux"), reason="Only works on linux", From f9a0f44137f8f39d36f9f5b428796b8810f8a74c Mon Sep 17 00:00:00 2001 From: realPy Date: Fri, 27 Aug 2021 18:25:27 +0200 Subject: [PATCH 833/903] Correct flash light livarno when use hue (#55294) Co-authored-by: Paulus Schoutsen --- homeassistant/components/hue/light.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 345156de7d7..ea89d91113b 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -282,12 +282,14 @@ class HueLight(CoordinatorEntity, LightEntity): self.is_osram = False self.is_philips = False self.is_innr = False + self.is_livarno = False self.gamut_typ = GAMUT_TYPE_UNAVAILABLE self.gamut = None else: self.is_osram = light.manufacturername == "OSRAM" self.is_philips = light.manufacturername == "Philips" self.is_innr = light.manufacturername == "innr" + self.is_livarno = light.manufacturername.startswith("_TZ3000_") self.gamut_typ = self.light.colorgamuttype self.gamut = self.light.colorgamut _LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut)) @@ -383,6 +385,8 @@ class HueLight(CoordinatorEntity, LightEntity): """Return the warmest color_temp that this light supports.""" if self.is_group: return super().max_mireds + if self.is_livarno: + return 500 max_mireds = self.light.controlcapabilities.get("ct", {}).get("max") @@ -493,7 +497,7 @@ class HueLight(CoordinatorEntity, LightEntity): elif flash == FLASH_SHORT: command["alert"] = "select" del command["on"] - elif not self.is_innr: + elif not self.is_innr and not self.is_livarno: command["alert"] = "none" if ATTR_EFFECT in kwargs: @@ -532,7 +536,7 @@ class HueLight(CoordinatorEntity, LightEntity): elif flash == FLASH_SHORT: command["alert"] = "select" del command["on"] - elif not self.is_innr: + elif not self.is_innr and not self.is_livarno: command["alert"] = "none" if self.is_group: From bfc98b444f3745d38643cce5cba80e323755a03d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 17:02:49 -0500 Subject: [PATCH 834/903] Fix creation of new nmap tracker entities (#55297) --- homeassistant/components/nmap_tracker/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 78465fbe91d..dfd8987484c 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -361,13 +361,6 @@ class NmapDeviceScanner: continue formatted_mac = format_mac(mac) - new = formatted_mac not in devices.tracked - if ( - new - and formatted_mac not in devices.tracked - and formatted_mac not in self._known_mac_addresses - ): - continue if ( devices.config_entry_owner.setdefault(formatted_mac, entry_id) @@ -382,6 +375,7 @@ class NmapDeviceScanner: formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 ) + new = formatted_mac not in devices.tracked devices.tracked[formatted_mac] = device devices.ipv4_last_mac[ipv4] = formatted_mac self._last_results.append(device) From ddb28db21a5b8e42b11d6e4438e89221ca017ae8 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Fri, 27 Aug 2021 12:03:25 +0200 Subject: [PATCH 835/903] Bump bimmer_connected to 0.7.20 (#55299) --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index a7c4c5c837b..8a1e7e2c826 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.19"], + "requirements": ["bimmer_connected==0.7.20"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index e612f922d24..f3dfd3fd871 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -375,7 +375,7 @@ beautifulsoup4==4.9.3 bellows==0.27.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.19 +bimmer_connected==0.7.20 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 031cba6ccc3..f87f7fc0397 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -230,7 +230,7 @@ base36==0.1.1 bellows==0.27.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.19 +bimmer_connected==0.7.20 # homeassistant.components.blebox blebox_uniapi==1.3.3 From c963cf874352e32184584372517140d1bea8bae6 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 26 Aug 2021 16:59:27 -0600 Subject: [PATCH 836/903] Bump aiorecollect to 1.0.8 (#55300) --- homeassistant/components/recollect_waste/manifest.json | 2 +- homeassistant/components/recollect_waste/sensor.py | 5 ++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recollect_waste/manifest.json b/homeassistant/components/recollect_waste/manifest.json index 258d74915f7..85cb7100a65 100644 --- a/homeassistant/components/recollect_waste/manifest.json +++ b/homeassistant/components/recollect_waste/manifest.json @@ -3,7 +3,7 @@ "name": "ReCollect Waste", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/recollect_waste", - "requirements": ["aiorecollect==1.0.7"], + "requirements": ["aiorecollect==1.0.8"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 304eaafb85f..434d24be22a 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -20,7 +20,6 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from homeassistant.util.dt import as_utc from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN, LOGGER @@ -124,7 +123,7 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): ATTR_NEXT_PICKUP_TYPES: async_get_pickup_type_names( self._entry, next_pickup_event.pickup_types ), - ATTR_NEXT_PICKUP_DATE: as_utc(next_pickup_event.date).isoformat(), + ATTR_NEXT_PICKUP_DATE: next_pickup_event.date.isoformat(), } ) - self._attr_native_value = as_utc(pickup_event.date).isoformat() + self._attr_native_value = pickup_event.date.isoformat() diff --git a/requirements_all.txt b/requirements_all.txt index f3dfd3fd871..2a5651bad6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aiopvpc==2.2.0 aiopylgtv==0.4.0 # homeassistant.components.recollect_waste -aiorecollect==1.0.7 +aiorecollect==1.0.8 # homeassistant.components.shelly aioshelly==0.6.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f87f7fc0397..5c2da6980fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,7 +158,7 @@ aiopvpc==2.2.0 aiopylgtv==0.4.0 # homeassistant.components.recollect_waste -aiorecollect==1.0.7 +aiorecollect==1.0.8 # homeassistant.components.shelly aioshelly==0.6.4 From fb25c6c115f1cc5920b6018b00f741e7fd6b87ba Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 27 Aug 2021 00:48:20 -0600 Subject: [PATCH 837/903] Bump simplisafe-python to 11.0.5 (#55306) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 6bf029ead6e..2d524d4c381 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==11.0.4"], + "requirements": ["simplisafe-python==11.0.5"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 2a5651bad6b..32eb110b1d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2131,7 +2131,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.4 +simplisafe-python==11.0.5 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c2da6980fd..bb6f5c4c2ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1191,7 +1191,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.4 +simplisafe-python==11.0.5 # homeassistant.components.slack slackclient==2.5.0 From 865656d4365d9278def9665ac8882a938467cb49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Aug 2021 01:49:50 -0500 Subject: [PATCH 838/903] Always send powerview move command in case shade is out of sync (#55308) --- homeassistant/components/hunterdouglas_powerview/cover.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 901a048fc7f..22636b7e3c4 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -177,8 +177,6 @@ class PowerViewShade(ShadeEntity, CoverEntity): """Move the shade to a position.""" current_hass_position = hd_position_to_hass(self._current_cover_position) steps_to_move = abs(current_hass_position - target_hass_position) - if not steps_to_move: - return self._async_schedule_update_for_transition(steps_to_move) self._async_update_from_command( await self._shade.move( From 5b993129d6cfcfd4a57f0746fe36ac7e0e63182f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 23:37:28 -0500 Subject: [PATCH 839/903] Fix lifx model to be a string (#55309) Fixes #55307 --- homeassistant/components/lifx/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 30c0ffbe850..a4412d042a8 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -470,7 +470,7 @@ class LIFXLight(LightEntity): model = product_map.get(self.bulb.product) or self.bulb.product if model is not None: - info["model"] = model + info["model"] = str(model) return info From f53a10d39a151bf58c9feba957d900433809d371 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Aug 2021 16:18:49 +0200 Subject: [PATCH 840/903] Handle statistics for sensor with changing state class (#55316) --- homeassistant/components/recorder/models.py | 1 + .../components/recorder/statistics.py | 89 +++++++++++++----- homeassistant/components/sensor/recorder.py | 2 +- tests/components/sensor/test_recorder.py | 90 +++++++++++++++++++ 4 files changed, 158 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 8b5aef88738..28eff4d9d95 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -267,6 +267,7 @@ class Statistics(Base): # type: ignore class StatisticMetaData(TypedDict, total=False): """Statistic meta data class.""" + statistic_id: str unit_of_measurement: str | None has_mean: bool has_sum: bool diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 06f7851b1a6..ddc542d23b7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -53,6 +53,13 @@ QUERY_STATISTIC_META = [ StatisticsMeta.id, StatisticsMeta.statistic_id, StatisticsMeta.unit_of_measurement, + StatisticsMeta.has_mean, + StatisticsMeta.has_sum, +] + +QUERY_STATISTIC_META_ID = [ + StatisticsMeta.id, + StatisticsMeta.statistic_id, ] STATISTICS_BAKERY = "recorder_statistics_bakery" @@ -124,33 +131,61 @@ def _get_metadata_ids( ) -> list[str]: """Resolve metadata_id for a list of statistic_ids.""" baked_query = hass.data[STATISTICS_META_BAKERY]( - lambda session: session.query(*QUERY_STATISTIC_META) + lambda session: session.query(*QUERY_STATISTIC_META_ID) ) baked_query += lambda q: q.filter( StatisticsMeta.statistic_id.in_(bindparam("statistic_ids")) ) result = execute(baked_query(session).params(statistic_ids=statistic_ids)) - return [id for id, _, _ in result] if result else [] + return [id for id, _ in result] if result else [] -def _get_or_add_metadata_id( +def _update_or_add_metadata( hass: HomeAssistant, session: scoped_session, statistic_id: str, - metadata: StatisticMetaData, + new_metadata: StatisticMetaData, ) -> str: """Get metadata_id for a statistic_id, add if it doesn't exist.""" - metadata_id = _get_metadata_ids(hass, session, [statistic_id]) - if not metadata_id: - unit = metadata["unit_of_measurement"] - has_mean = metadata["has_mean"] - has_sum = metadata["has_sum"] + old_metadata_dict = _get_metadata(hass, session, [statistic_id], None) + if not old_metadata_dict: + unit = new_metadata["unit_of_measurement"] + has_mean = new_metadata["has_mean"] + has_sum = new_metadata["has_sum"] session.add( StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum) ) - metadata_id = _get_metadata_ids(hass, session, [statistic_id]) - return metadata_id[0] + metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) + _LOGGER.debug( + "Added new statistics metadata for %s, new_metadata: %s", + statistic_id, + new_metadata, + ) + return metadata_ids[0] + + metadata_id, old_metadata = next(iter(old_metadata_dict.items())) + if ( + old_metadata["has_mean"] != new_metadata["has_mean"] + or old_metadata["has_sum"] != new_metadata["has_sum"] + or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"] + ): + session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( + { + StatisticsMeta.has_mean: new_metadata["has_mean"], + StatisticsMeta.has_sum: new_metadata["has_sum"], + StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], + }, + synchronize_session=False, + ) + _LOGGER.debug( + "Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s", + statistic_id, + old_metadata, + new_metadata, + ) + + return metadata_id @retryable_database_job("statistics") @@ -177,7 +212,7 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: with session_scope(session=instance.get_session()) as session: # type: ignore for stats in platform_stats: for entity_id, stat in stats.items(): - metadata_id = _get_or_add_metadata_id( + metadata_id = _update_or_add_metadata( instance.hass, session, entity_id, stat["meta"] ) session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) @@ -191,14 +226,19 @@ def _get_metadata( session: scoped_session, statistic_ids: list[str] | None, statistic_type: str | None, -) -> dict[str, dict[str, str]]: +) -> dict[str, StatisticMetaData]: """Fetch meta data.""" - def _meta(metas: list, wanted_metadata_id: str) -> dict[str, str] | None: - meta = None - for metadata_id, statistic_id, unit in metas: + def _meta(metas: list, wanted_metadata_id: str) -> StatisticMetaData | None: + meta: StatisticMetaData | None = None + for metadata_id, statistic_id, unit, has_mean, has_sum in metas: if metadata_id == wanted_metadata_id: - meta = {"unit_of_measurement": unit, "statistic_id": statistic_id} + meta = { + "statistic_id": statistic_id, + "unit_of_measurement": unit, + "has_mean": has_mean, + "has_sum": has_sum, + } return meta baked_query = hass.data[STATISTICS_META_BAKERY]( @@ -219,7 +259,7 @@ def _get_metadata( return {} metadata_ids = [metadata[0] for metadata in result] - metadata = {} + metadata: dict[str, StatisticMetaData] = {} for _id in metadata_ids: meta = _meta(result, _id) if meta: @@ -230,7 +270,7 @@ def _get_metadata( def get_metadata( hass: HomeAssistant, statistic_id: str, -) -> dict[str, str] | None: +) -> StatisticMetaData | None: """Return metadata for a statistic_id.""" statistic_ids = [statistic_id] with session_scope(hass=hass) as session: @@ -255,7 +295,7 @@ def _configured_unit(unit: str, units: UnitSystem) -> str: def list_statistic_ids( hass: HomeAssistant, statistic_type: str | None = None -) -> list[dict[str, str] | None]: +) -> list[StatisticMetaData | None]: """Return statistic_ids and meta data.""" units = hass.config.units statistic_ids = {} @@ -263,7 +303,9 @@ def list_statistic_ids( metadata = _get_metadata(hass, session, None, statistic_type) for meta in metadata.values(): - unit = _configured_unit(meta["unit_of_measurement"], units) + unit = meta["unit_of_measurement"] + if unit is not None: + unit = _configured_unit(unit, units) meta["unit_of_measurement"] = unit statistic_ids = { @@ -277,7 +319,8 @@ def list_statistic_ids( platform_statistic_ids = platform.list_statistic_ids(hass, statistic_type) for statistic_id, unit in platform_statistic_ids.items(): - unit = _configured_unit(unit, units) + if unit is not None: + unit = _configured_unit(unit, units) platform_statistic_ids[statistic_id] = unit statistic_ids = {**statistic_ids, **platform_statistic_ids} @@ -367,7 +410,7 @@ def _sorted_statistics_to_dict( hass: HomeAssistant, stats: list, statistic_ids: list[str] | None, - metadata: dict[str, dict[str, str]], + metadata: dict[str, StatisticMetaData], ) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" result: dict = defaultdict(list) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index bcb21136007..2b59592dd17 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -352,7 +352,7 @@ def compile_statistics( # We have compiled history for this sensor before, use that as a starting point last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] new_state = old_state = last_stats[entity_id][0]["state"] - _sum = last_stats[entity_id][0]["sum"] + _sum = last_stats[entity_id][0]["sum"] or 0 for fstate, state in fstates: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index c7f356e49ee..2e300b9c748 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -10,6 +10,7 @@ from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat from homeassistant.components.recorder.statistics import ( + get_metadata, list_statistic_ids, statistics_during_period, ) @@ -1037,6 +1038,95 @@ def test_compile_hourly_statistics_changing_units_2( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_statistics( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): + """Test compiling hourly statistics where units change during an hour.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes_1 = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + attributes_2 = { + "device_class": device_class, + "state_class": "total_increasing", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes_1) + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": None} + ] + metadata = get_metadata(hass, "sensor.test1") + assert metadata == { + "has_mean": True, + "has_sum": False, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + } + + # Add more states, with changed state class + four, _states = record_states( + hass, zero + timedelta(hours=1), "sensor.test1", attributes_2 + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": None} + ] + metadata = get_metadata(hass, "sensor.test1") + assert metadata == { + "has_mean": False, + "has_sum": True, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + } + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": approx(30.0), + "sum": approx(30.0), + }, + ] + } + + assert "Error while processing event StatisticsTask" not in caplog.text + + def record_states(hass, zero, entity_id, attributes): """Record some test states. From 34f0fecef8db20d236fdb35ab711a98cd4854feb Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 27 Aug 2021 12:25:40 -0400 Subject: [PATCH 841/903] Fix sonos alarm schema (#55318) --- homeassistant/components/sonos/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 4fdc5c6f320..5cb6e225510 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -223,6 +223,7 @@ async def async_setup_entry( { vol.Required(ATTR_ALARM_ID): cv.positive_int, vol.Optional(ATTR_TIME): cv.time, + vol.Optional(ATTR_VOLUME): cv.small_float, vol.Optional(ATTR_ENABLED): cv.boolean, vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, }, From e097e4c1c255312cd6face8b1219da5bbd2601bb Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Fri, 27 Aug 2021 11:04:07 -0500 Subject: [PATCH 842/903] Fix reauth for sonarr (#55329) * fix reauth for sonarr * Update config_flow.py * Update config_flow.py * Update config_flow.py * Update test_config_flow.py * Update config_flow.py * Update test_config_flow.py * Update config_flow.py --- .../components/sonarr/config_flow.py | 30 +++++++------------ tests/components/sonarr/test_config_flow.py | 10 ++++--- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index db82e729483..cc35a8db4af 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -64,9 +64,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the flow.""" - self._reauth = False - self._entry_id = None - self._entry_data = {} + self.entry = None @staticmethod @callback @@ -76,10 +74,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle configuration by re-auth.""" - self._reauth = True - self._entry_data = dict(data) - entry = await self.async_set_unique_id(self.unique_id) - self._entry_id = entry.entry_id + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() @@ -90,7 +85,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="reauth_confirm", - description_placeholders={"host": self._entry_data[CONF_HOST]}, + description_placeholders={"host": self.entry.data[CONF_HOST]}, data_schema=vol.Schema({}), errors={}, ) @@ -104,8 +99,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - if self._reauth: - user_input = {**self._entry_data, **user_input} + if self.entry: + user_input = {**self.entry.data, **user_input} if CONF_VERIFY_SSL not in user_input: user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL @@ -120,10 +115,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: - if self._reauth: - return await self._async_reauth_update_entry( - self._entry_id, user_input - ) + if self.entry: + return await self._async_reauth_update_entry(user_input) return self.async_create_entry( title=user_input[CONF_HOST], data=user_input @@ -136,17 +129,16 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_reauth_update_entry(self, entry_id: str, data: dict) -> FlowResult: + async def _async_reauth_update_entry(self, data: dict) -> FlowResult: """Update existing config entry.""" - entry = self.hass.config_entries.async_get_entry(entry_id) - self.hass.config_entries.async_update_entry(entry, data=data) - await self.hass.config_entries.async_reload(entry.entry_id) + self.hass.config_entries.async_update_entry(self.entry, data=data) + await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") def _get_user_data_schema(self) -> dict[str, Any]: """Get the data schema to display user form.""" - if self._reauth: + if self.entry: return {vol.Required(CONF_API_KEY): str} data_schema = { diff --git a/tests/components/sonarr/test_config_flow.py b/tests/components/sonarr/test_config_flow.py index c1896061f79..87b38e52742 100644 --- a/tests/components/sonarr/test_config_flow.py +++ b/tests/components/sonarr/test_config_flow.py @@ -100,14 +100,16 @@ async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test the manual reauth flow from start to finish.""" - entry = await setup_integration( - hass, aioclient_mock, skip_entry_setup=True, unique_id="any" - ) + entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True) assert entry result = await hass.config_entries.flow.async_init( DOMAIN, - context={CONF_SOURCE: SOURCE_REAUTH, "unique_id": entry.unique_id}, + context={ + CONF_SOURCE: SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, data=entry.data, ) From b3e0b7b86e976ba3190a2259d0ac74747622c477 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 27 Aug 2021 18:26:57 +0200 Subject: [PATCH 843/903] Add modbus name to log_error (#55336) --- homeassistant/components/modbus/modbus.py | 2 +- tests/components/modbus/test_init.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index c2cae9f4ec3..4889b27faf0 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -243,7 +243,7 @@ class ModbusHub: self._msg_wait = 0 def _log_error(self, text: str, error_state=True): - log_text = f"Pymodbus: {text}" + log_text = f"Pymodbus: {self.name}: {text}" if self._in_error: _LOGGER.debug(log_text) else: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index b24115ee964..1bb538a886a 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -593,6 +593,7 @@ async def test_pymodbus_constructor_fail(hass, caplog): config = { DOMAIN: [ { + CONF_NAME: TEST_MODBUS_NAME, CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, @@ -606,7 +607,8 @@ async def test_pymodbus_constructor_fail(hass, caplog): mock_pb.side_effect = ModbusException("test no class") assert await async_setup_component(hass, DOMAIN, config) is False await hass.async_block_till_done() - assert caplog.messages[0].startswith("Pymodbus: Modbus Error: test") + message = f"Pymodbus: {TEST_MODBUS_NAME}: Modbus Error: test" + assert caplog.messages[0].startswith(message) assert caplog.records[0].levelname == "ERROR" assert mock_pb.called From d8b64be41cf59ffd30fba9200bb0bb253257b140 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Aug 2021 11:53:29 -0500 Subject: [PATCH 844/903] Retrigger config flow when the ssdp location changes for a UDN (#55343) Fixes #55229 --- homeassistant/components/ssdp/__init__.py | 12 ++- tests/components/ssdp/test_init.py | 124 ++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 1fd2bba77cc..6e9441534ab 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -286,6 +286,11 @@ class Scanner: if header_st is not None: self.seen.add((header_st, header_location)) + def _async_unsee(self, header_st: str | None, header_location: str | None) -> None: + """If we see a device in a new location, unsee the original location.""" + if header_st is not None: + self.seen.remove((header_st, header_location)) + async def _async_process_entry(self, headers: Mapping[str, str]) -> None: """Process SSDP entries.""" _LOGGER.debug("_async_process_entry: %s", headers) @@ -293,7 +298,12 @@ class Scanner: h_location = headers.get("location") if h_st and (udn := _udn_from_usn(headers.get("usn"))): - self.cache[(udn, h_st)] = headers + cache_key = (udn, h_st) + if old_headers := self.cache.get(cache_key): + old_h_location = old_headers.get("location") + if h_location != old_h_location: + self._async_unsee(old_headers.get("st"), old_h_location) + self.cache[cache_key] = headers callbacks = self._async_get_matching_callbacks(headers) if self._async_seen(h_st, h_location) and not callbacks: diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 2c5dc74db44..43b7fd98cd0 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -926,3 +926,127 @@ async def test_ipv4_does_additional_search_for_sonos(hass, caplog): ), ), } + + +async def test_location_change_evicts_prior_location_from_cache(hass, aioclient_mock): + """Test that a location change for a UDN will evict the prior location from the cache.""" + mock_get_ssdp = { + "hue": [{"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}] + } + + hue_response = """ + + +1 +0 + +http://{ip_address}:80/ + +urn:schemas-upnp-org:device:Basic:1 +Philips hue ({ip_address}) +Signify +http://www.philips-hue.com +Philips hue Personal Wireless Lighting +Philips hue bridge 2015 +BSB002 +http://www.philips-hue.com +001788a36abf +uuid:2f402f80-da50-11e1-9b23-001788a36abf + + + """ + + aioclient_mock.get( + "http://192.168.212.23/description.xml", + text=hue_response.format(ip_address="192.168.212.23"), + ) + aioclient_mock.get( + "http://169.254.8.155/description.xml", + text=hue_response.format(ip_address="169.254.8.155"), + ) + ssdp_response_without_location = { + "ST": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", + "_udn": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", + "USN": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", + "SERVER": "Hue/1.0 UPnP/1.0 IpBridge/1.44.0", + "hue-bridgeid": "001788FFFEA36ABF", + "EXT": "", + } + + mock_good_ip_ssdp_response = CaseInsensitiveDict( + **ssdp_response_without_location, + **{"LOCATION": "http://192.168.212.23/description.xml"}, + ) + mock_link_local_ip_ssdp_response = CaseInsensitiveDict( + **ssdp_response_without_location, + **{"LOCATION": "http://169.254.8.155/description.xml"}, + ) + mock_ssdp_response = mock_good_ip_ssdp_response + + def _generate_fake_ssdp_listener(*args, **kwargs): + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + pass + + @callback + def _callback(*_): + import pprint + + pprint.pprint(mock_ssdp_response) + hass.async_create_task(listener.async_callback(mock_ssdp_response)) + + listener.async_start = _async_callback + listener.async_search = _callback + return listener + + with patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value=mock_get_ssdp, + ), patch( + "homeassistant.components.ssdp.SSDPListener", + new=_generate_fake_ssdp_listener, + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_init: + assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == "hue" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == mock_good_ip_ssdp_response["location"] + ) + + mock_init.reset_mock() + mock_ssdp_response = mock_link_local_ip_ssdp_response + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) + await hass.async_block_till_done() + assert mock_init.mock_calls[0][1][0] == "hue" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == mock_link_local_ip_ssdp_response["location"] + ) + + mock_init.reset_mock() + mock_ssdp_response = mock_good_ip_ssdp_response + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) + await hass.async_block_till_done() + assert mock_init.mock_calls[0][1][0] == "hue" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == mock_good_ip_ssdp_response["location"] + ) From 76bb036968655a3696cdf5d9ed2e3505699f3af4 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 27 Aug 2021 18:53:42 +0200 Subject: [PATCH 845/903] Upgrade aiolifx to 0.6.10 (#55344) --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 9e1a4fc2689..847c75b4fa5 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,7 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.6.9", "aiolifx_effects==0.2.2"], + "requirements": ["aiolifx==0.6.10", "aiolifx_effects==0.2.2"], "homekit": { "models": ["LIFX"] }, diff --git a/requirements_all.txt b/requirements_all.txt index 32eb110b1d8..f17978a3c80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aiokafka==0.6.0 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.6.9 +aiolifx==0.6.10 # homeassistant.components.lifx aiolifx_effects==0.2.2 From d0ada6c6e23260798ef2715daeac5421cea15c81 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 Aug 2021 10:00:20 -0700 Subject: [PATCH 846/903] Bumped version to 2021.9.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3f7a7c89dee..c523d88606a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 8641740ed8d656cd187f4ee368867b79bd4a841a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Aug 2021 12:43:53 -0500 Subject: [PATCH 847/903] Ensure yeelights resync state if they are busy on first connect (#55333) --- homeassistant/components/yeelight/__init__.py | 27 +++++++++++--- homeassistant/components/yeelight/light.py | 21 +++++------ tests/components/yeelight/__init__.py | 36 ++++++++++++++----- tests/components/yeelight/test_init.py | 27 ++++++++++++++ 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 8684e331fad..a0deb0fdf21 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -163,6 +163,8 @@ UPDATE_REQUEST_PROPERTIES = [ "active_mode", ] +BULB_EXCEPTIONS = (BulbException, asyncio.TimeoutError) + PLATFORMS = ["binary_sensor", "light"] @@ -272,7 +274,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.data.get(CONF_HOST): try: device = await _async_get_device(hass, entry.data[CONF_HOST], entry) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: # If CONF_ID is not valid we cannot fallback to discovery # so we must retry by raising ConfigEntryNotReady if not entry.data.get(CONF_ID): @@ -287,7 +289,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = urlparse(capabilities["location"]).hostname try: await _async_initialize(hass, entry, host) - except BulbException: + except BULB_EXCEPTIONS: _LOGGER.exception("Failed to connect to bulb at %s", host) # discovery @@ -552,6 +554,7 @@ class YeelightDevice: self._device_type = None self._available = False self._initialized = False + self._did_first_update = False self._name = None @property @@ -647,14 +650,14 @@ class YeelightDevice: await self.bulb.async_turn_on( duration=duration, light_type=light_type, power_mode=power_mode ) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to turn the bulb on: %s", ex) async def async_turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn off device.""" try: await self.bulb.async_turn_off(duration=duration, light_type=light_type) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error( "Unable to turn the bulb off: %s, %s: %s", self._host, self.name, ex ) @@ -670,7 +673,7 @@ class YeelightDevice: if not self._initialized: self._initialized = True async_dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: if self._available: # just inform once _LOGGER.error( "Unable to update device %s, %s: %s", self._host, self.name, ex @@ -696,6 +699,7 @@ class YeelightDevice: async def async_update(self, force=False): """Update device properties and send data updated signal.""" + self._did_first_update = True if not force and self._initialized and self._available: # No need to poll unless force, already connected return @@ -705,7 +709,20 @@ class YeelightDevice: @callback def async_update_callback(self, data): """Update push from device.""" + was_available = self._available self._available = data.get(KEY_CONNECTED, True) + if self._did_first_update and not was_available and self._available: + # On reconnect the properties may be out of sync + # + # We need to make sure the DEVICE_INITIALIZED dispatcher is setup + # before we can update on reconnect by checking self._did_first_update + # + # If the device drops the connection right away, we do not want to + # do a property resync via async_update since its about + # to be called when async_setup_entry reaches the end of the + # function + # + asyncio.create_task(self.async_update(True)) async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index be876690b06..e0c21f21fc7 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -6,7 +6,7 @@ import math import voluptuous as vol import yeelight -from yeelight import Bulb, BulbException, Flow, RGBTransition, SleepTransition, flows +from yeelight import Bulb, Flow, RGBTransition, SleepTransition, flows from yeelight.enums import BulbType, LightType, PowerMode, SceneClass from homeassistant.components.light import ( @@ -49,6 +49,7 @@ from . import ( ATTR_COUNT, ATTR_MODE_MUSIC, ATTR_TRANSITIONS, + BULB_EXCEPTIONS, CONF_FLOW_PARAMS, CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH, @@ -241,7 +242,7 @@ def _async_cmd(func): try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) return await func(self, *args, **kwargs) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Error when calling %s: %s", func, ex) return _async_wrap @@ -678,7 +679,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): flow = Flow(count=count, transitions=transitions) try: await self._bulb.async_start_flow(flow, light_type=self.light_type) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set flash: %s", ex) @_async_cmd @@ -709,7 +710,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): try: await self._bulb.async_start_flow(flow, light_type=self.light_type) self._effect = effect - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set effect: %s", ex) async def async_turn_on(self, **kwargs) -> None: @@ -737,7 +738,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): await self.hass.async_add_executor_job( self.set_music_mode, self.config[CONF_MODE_MUSIC] ) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error( "Unable to turn on music mode, consider disabling it: %s", ex ) @@ -750,7 +751,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): await self.async_set_brightness(brightness, duration) await self.async_set_flash(flash) await self.async_set_effect(effect) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set bulb properties: %s", ex) return @@ -758,7 +759,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb): try: await self.async_set_default() - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set the defaults: %s", ex) return @@ -784,7 +785,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """Set a power mode.""" try: await self._bulb.async_set_power_mode(PowerMode[mode.upper()]) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set the power mode: %s", ex) async def async_start_flow(self, transitions, count=0, action=ACTION_RECOVER): @@ -795,7 +796,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): ) await self._bulb.async_start_flow(flow, light_type=self.light_type) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set effect: %s", ex) async def async_set_scene(self, scene_class, *args): @@ -806,7 +807,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """ try: await self._bulb.async_set_scene(scene_class, *args) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set scene: %s", ex) diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 06c0243e918..4a862fa13dd 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -90,11 +90,33 @@ YAML_CONFIGURATION = { CONFIG_ENTRY_DATA = {CONF_ID: ID} +class MockAsyncBulb: + """A mock for yeelight.aio.AsyncBulb.""" + + def __init__(self, model, bulb_type, cannot_connect): + """Init the mock.""" + self.model = model + self.bulb_type = bulb_type + self._async_callback = None + self._cannot_connect = cannot_connect + + async def async_listen(self, callback): + """Mock the listener.""" + if self._cannot_connect: + raise BulbException + self._async_callback = callback + + async def async_stop_listening(self): + """Drop the listener.""" + self._async_callback = None + + def set_capabilities(self, capabilities): + """Mock setting capabilities.""" + self.capabilities = capabilities + + def _mocked_bulb(cannot_connect=False): - bulb = MagicMock() - type(bulb).async_listen = AsyncMock( - side_effect=BulbException if cannot_connect else None - ) + bulb = MockAsyncBulb(MODEL, BulbType.Color, cannot_connect) type(bulb).async_get_properties = AsyncMock( side_effect=BulbException if cannot_connect else None ) @@ -102,14 +124,10 @@ def _mocked_bulb(cannot_connect=False): side_effect=BulbException if cannot_connect else None ) type(bulb).get_model_specs = MagicMock(return_value=_MODEL_SPECS[MODEL]) - bulb.capabilities = CAPABILITIES.copy() - bulb.model = MODEL - bulb.bulb_type = BulbType.Color bulb.last_properties = PROPERTIES.copy() bulb.music_mode = False bulb.async_get_properties = AsyncMock() - bulb.async_stop_listening = AsyncMock() bulb.async_update = AsyncMock() bulb.async_turn_on = AsyncMock() bulb.async_turn_off = AsyncMock() @@ -122,7 +140,7 @@ def _mocked_bulb(cannot_connect=False): bulb.async_set_power_mode = AsyncMock() bulb.async_set_scene = AsyncMock() bulb.async_set_default = AsyncMock() - + bulb.start_music = MagicMock() return bulb diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 4414909d8e0..4b3ac8e0e83 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from yeelight import BulbException, BulbType +from yeelight.aio import KEY_CONNECTED from homeassistant.components.yeelight import ( CONF_MODEL, @@ -414,3 +415,29 @@ async def test_async_setup_with_missing_id(hass: HomeAssistant): assert config_entry.state is ConfigEntryState.LOADED assert config_entry.data[CONF_ID] == ID + + +async def test_connection_dropped_resyncs_properties(hass: HomeAssistant): + """Test handling a connection drop results in a property resync.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ID, + data={CONF_HOST: "127.0.0.1"}, + options={CONF_NAME: "Test name"}, + ) + config_entry.add_to_hass(hass) + mocked_bulb = _mocked_bulb() + + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mocked_bulb.async_get_properties.mock_calls) == 1 + mocked_bulb._async_callback({KEY_CONNECTED: False}) + await hass.async_block_till_done() + assert len(mocked_bulb.async_get_properties.mock_calls) == 1 + mocked_bulb._async_callback({KEY_CONNECTED: True}) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(mocked_bulb.async_get_properties.mock_calls) == 2 From 08ca43221f1d38903f1cfac0742a7bfff89ea3e3 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 27 Aug 2021 18:01:20 -0400 Subject: [PATCH 848/903] Listen to node events in the zwave_js node status sensor (#55341) --- homeassistant/components/zwave_js/sensor.py | 6 +-- tests/components/zwave_js/conftest.py | 10 +++++ tests/components/zwave_js/test_sensor.py | 41 ++++++++++++++++++++- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 5c8ed8633f1..40159b383a6 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -462,6 +462,7 @@ class ZWaveNodeStatusSensor(SensorEntity): """Poll a value.""" raise ValueError("There is no value to poll for this entity") + @callback def _status_changed(self, _: dict) -> None: """Call when status event is received.""" self._attr_native_value = self.node.status.name.lower() @@ -480,8 +481,3 @@ class ZWaveNodeStatusSensor(SensorEntity): ) ) self.async_write_ha_state() - - @property - def available(self) -> bool: - """Return entity availability.""" - return self.client.connected and bool(self.node.ready) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 900a7937539..6634fdf759d 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -769,6 +769,16 @@ def lock_id_lock_as_id150(client, lock_id_lock_as_id150_state): return node +@pytest.fixture(name="lock_id_lock_as_id150_not_ready") +def node_not_ready(client, lock_id_lock_as_id150_state): + """Mock an id lock id-150 lock node that's not ready.""" + state = copy.deepcopy(lock_id_lock_as_id150_state) + state["ready"] = False + node = Node(client, state) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="climate_radio_thermostat_ct101_multiple_temp_units") def climate_radio_thermostat_ct101_multiple_temp_units_fixture( client, climate_radio_thermostat_ct101_multiple_temp_units_state diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 6d64f6f92dd..b595b6462b3 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_WATT, + STATE_UNAVAILABLE, TEMP_CELSIUS, ) from homeassistant.helpers import entity_registry as er @@ -136,7 +137,7 @@ async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration) assert entity_entry.disabled -async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): +async def test_node_status_sensor(hass, client, lock_id_lock_as_id150, integration): """Test node status sensor is created and gets updated on node state changes.""" NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150 @@ -179,6 +180,44 @@ async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + # Disconnect the client and make sure the entity is still available + await client.disconnect() + assert hass.states.get(NODE_STATUS_ENTITY).state != STATE_UNAVAILABLE + + +async def test_node_status_sensor_not_ready( + hass, + client, + lock_id_lock_as_id150_not_ready, + lock_id_lock_as_id150_state, + integration, +): + """Test node status sensor is created and available if node is not ready.""" + NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" + node = lock_id_lock_as_id150_not_ready + assert not node.ready + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(NODE_STATUS_ENTITY) + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + assert not updated_entry.disabled + assert hass.states.get(NODE_STATUS_ENTITY) + assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + + # Mark node as ready + event = Event("ready", {"nodeState": lock_id_lock_as_id150_state}) + node.receive_event(event) + assert node.ready + assert hass.states.get(NODE_STATUS_ENTITY) + assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + async def test_reset_meter( hass, From 06b47ee2f515c25016fd9be78fc795c6ae2e4e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 28 Aug 2021 17:57:57 +0200 Subject: [PATCH 849/903] Tractive name (#55342) --- homeassistant/components/tractive/sensor.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index fdc38d8b83a..aa578bf046b 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -40,6 +40,7 @@ class TractiveSensor(TractiveEntity, SensorEntity): """Initialize sensor entity.""" super().__init__(user_id, trackable, tracker_details) + self._attr_name = f"{trackable['details']['name']} {description.name}" self._attr_unique_id = unique_id self.entity_description = description @@ -53,11 +54,6 @@ class TractiveSensor(TractiveEntity, SensorEntity): class TractiveHardwareSensor(TractiveSensor): """Tractive hardware sensor.""" - def __init__(self, user_id, trackable, tracker_details, unique_id, description): - """Initialize sensor entity.""" - super().__init__(user_id, trackable, tracker_details, unique_id, description) - self._attr_name = f"{self._tracker_id} {description.name}" - @callback def handle_hardware_status_update(self, event): """Handle hardware status update.""" @@ -88,11 +84,6 @@ class TractiveHardwareSensor(TractiveSensor): class TractiveActivitySensor(TractiveSensor): """Tractive active sensor.""" - def __init__(self, user_id, trackable, tracker_details, unique_id, description): - """Initialize sensor entity.""" - super().__init__(user_id, trackable, tracker_details, unique_id, description) - self._attr_name = f"{trackable['details']['name']} {description.name}" - @callback def handle_activity_status_update(self, event): """Handle activity status update.""" From efc3894303c5cd0681ef81e7b7c641d817d54b7c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 Aug 2021 14:59:55 -0700 Subject: [PATCH 850/903] Convert solarlog to coordinator (#55345) --- homeassistant/components/solarlog/__init__.py | 86 +++++++++++ homeassistant/components/solarlog/const.py | 6 +- homeassistant/components/solarlog/sensor.py | 133 +++--------------- 3 files changed, 108 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index b3cfebe9abc..e32f1d85564 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -1,12 +1,28 @@ """Solar-Log integration.""" +from datetime import timedelta +import logging +from urllib.parse import ParseResult, urlparse + +from requests.exceptions import HTTPError, Timeout +from sunwatcher.solarlog.solarlog import SolarLog + from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) PLATFORMS = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for solarlog.""" + coordinator = SolarlogData(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -14,3 +30,73 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass, entry): """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class SolarlogData(update_coordinator.DataUpdateCoordinator): + """Get and update the latest data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + super().__init__( + hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) + ) + + host_entry = entry.data[CONF_HOST] + + 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.name = entry.title + self.host = url.geturl() + + async def _async_update_data(self): + """Update the data from the SolarLog device.""" + try: + api = await self.hass.async_add_executor_job(SolarLog, self.host) + except (OSError, Timeout, HTTPError) as err: + raise update_coordinator.UpdateFailed(err) + + if api.time.year == 1999: + raise update_coordinator.UpdateFailed( + "Invalid data returned (can happen after Solarlog restart)." + ) + + self.logger.debug( + "Connection to Solarlog successful. Retrieving latest Solarlog update of %s", + api.time, + ) + + data = {} + + try: + data["TIME"] = api.time + data["powerAC"] = api.power_ac + data["powerDC"] = api.power_dc + data["voltageAC"] = api.voltage_ac + data["voltageDC"] = api.voltage_dc + data["yieldDAY"] = api.yield_day / 1000 + data["yieldYESTERDAY"] = api.yield_yesterday / 1000 + data["yieldMONTH"] = api.yield_month / 1000 + data["yieldYEAR"] = api.yield_year / 1000 + data["yieldTOTAL"] = api.yield_total / 1000 + data["consumptionAC"] = api.consumption_ac + data["consumptionDAY"] = api.consumption_day / 1000 + data["consumptionYESTERDAY"] = api.consumption_yesterday / 1000 + data["consumptionMONTH"] = api.consumption_month / 1000 + data["consumptionYEAR"] = api.consumption_year / 1000 + data["consumptionTOTAL"] = api.consumption_total / 1000 + data["totalPOWER"] = api.total_power + data["alternatorLOSS"] = api.alternator_loss + data["CAPACITY"] = round(api.capacity * 100, 0) + data["EFFICIENCY"] = round(api.efficiency * 100, 0) + data["powerAVAILABLE"] = api.power_available + data["USAGE"] = round(api.usage * 100, 0) + except AttributeError as err: + raise update_coordinator.UpdateFailed( + f"Missing details data in Solarlog response: {err}" + ) from err + + _LOGGER.debug("Updated Solarlog overview data: %s", data) + return data diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index e4e10b3a7e6..eecf73b6a09 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import timedelta from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -23,13 +22,10 @@ from homeassistant.const import ( DOMAIN = "solarlog" -"""Default config for solarlog.""" +# Default config for solarlog. DEFAULT_HOST = "http://solar-log" DEFAULT_NAME = "solarlog" -"""Fixed constants.""" -SCAN_INTERVAL = timedelta(seconds=60) - @dataclass class SolarlogRequiredKeysMixin: diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index e87977f64e5..ee7425cf2d7 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -1,133 +1,42 @@ """Platform for solarlog sensors.""" -import logging -from urllib.parse import ParseResult, urlparse - -from requests.exceptions import HTTPError, Timeout -from sunwatcher.solarlog.solarlog import SolarLog - from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_HOST -from homeassistant.util import Throttle +from homeassistant.helpers import update_coordinator +from homeassistant.helpers.entity import StateType -from .const import DOMAIN, SCAN_INTERVAL, SENSOR_TYPES, SolarLogSensorEntityDescription - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the solarlog platform.""" - _LOGGER.warning( - "Configuration of the solarlog platform in configuration.yaml is deprecated " - "in Home Assistant 0.119. Please remove entry from your configuration" - ) +from . import SolarlogData +from .const import DOMAIN, SENSOR_TYPES, SolarLogSensorEntityDescription async def async_setup_entry(hass, entry, async_add_entities): """Add solarlog entry.""" - host_entry = entry.data[CONF_HOST] - device_name = entry.title - - 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:]) - host = url.geturl() - - try: - api = await hass.async_add_executor_job(SolarLog, host) - _LOGGER.debug("Connected to Solar-Log device, setting up entries") - except (OSError, HTTPError, Timeout): - _LOGGER.error( - "Could not connect to Solar-Log device at %s, check host ip address", host - ) - return - - # Create solarlog data service which will retrieve and update the data. - data = await hass.async_add_executor_job(SolarlogData, hass, api, host) - - # Create a new sensor for each sensor type. - entities = [ - SolarlogSensor(entry.entry_id, device_name, data, description) - for description in SENSOR_TYPES - ] - async_add_entities(entities, True) - return True + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + SolarlogSensor(coordinator, description) for description in SENSOR_TYPES + ) -class SolarlogData: - """Get and update the latest data.""" - - def __init__(self, hass, api, host): - """Initialize the data object.""" - self.api = api - self.hass = hass - self.host = host - self.update = Throttle(SCAN_INTERVAL)(self._update) - self.data = {} - - def _update(self): - """Update the data from the SolarLog device.""" - try: - self.api = SolarLog(self.host) - response = self.api.time - _LOGGER.debug( - "Connection to Solarlog successful. Retrieving latest Solarlog update of %s", - response, - ) - except (OSError, Timeout, HTTPError): - _LOGGER.error("Connection error, Could not retrieve data, skipping update") - return - - try: - self.data["TIME"] = self.api.time - self.data["powerAC"] = self.api.power_ac - self.data["powerDC"] = self.api.power_dc - self.data["voltageAC"] = self.api.voltage_ac - self.data["voltageDC"] = self.api.voltage_dc - self.data["yieldDAY"] = self.api.yield_day / 1000 - self.data["yieldYESTERDAY"] = self.api.yield_yesterday / 1000 - self.data["yieldMONTH"] = self.api.yield_month / 1000 - self.data["yieldYEAR"] = self.api.yield_year / 1000 - self.data["yieldTOTAL"] = self.api.yield_total / 1000 - self.data["consumptionAC"] = self.api.consumption_ac - self.data["consumptionDAY"] = self.api.consumption_day / 1000 - self.data["consumptionYESTERDAY"] = self.api.consumption_yesterday / 1000 - self.data["consumptionMONTH"] = self.api.consumption_month / 1000 - self.data["consumptionYEAR"] = self.api.consumption_year / 1000 - self.data["consumptionTOTAL"] = self.api.consumption_total / 1000 - self.data["totalPOWER"] = self.api.total_power - self.data["alternatorLOSS"] = self.api.alternator_loss - self.data["CAPACITY"] = round(self.api.capacity * 100, 0) - self.data["EFFICIENCY"] = round(self.api.efficiency * 100, 0) - self.data["powerAVAILABLE"] = self.api.power_available - self.data["USAGE"] = round(self.api.usage * 100, 0) - _LOGGER.debug("Updated Solarlog overview data: %s", self.data) - except AttributeError: - _LOGGER.error("Missing details data in Solarlog response") - - -class SolarlogSensor(SensorEntity): +class SolarlogSensor(update_coordinator.CoordinatorEntity, SensorEntity): """Representation of a Sensor.""" + entity_description: SolarLogSensorEntityDescription + def __init__( self, - entry_id: str, - device_name: str, - data: SolarlogData, + coordinator: SolarlogData, description: SolarLogSensorEntityDescription, ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = description - self.data = data - self._attr_name = f"{device_name} {description.name}" - self._attr_unique_id = f"{entry_id}_{description.key}" + self._attr_name = f"{coordinator.name} {description.name}" + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" self._attr_device_info = { - "identifiers": {(DOMAIN, entry_id)}, - "name": device_name, + "identifiers": {(DOMAIN, coordinator.unique_id)}, + "name": coordinator.name, "manufacturer": "Solar-Log", } - def update(self): - """Get the latest data from the sensor and update the state.""" - self.data.update() - self._attr_native_value = self.data.data[self.entity_description.json_key] + @property + def native_value(self) -> StateType: + """Return the native sensor value.""" + return self.coordinator.data[self.entity_description.json_key] From d96e416d260732ce6e46147d2b992a2012792a5c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 27 Aug 2021 16:00:17 -0600 Subject: [PATCH 851/903] Ensure ReCollect Waste starts up even if no future pickup is found (#55349) --- .../components/recollect_waste/config_flow.py | 2 +- tests/components/recollect_waste/test_config_flow.py | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 92f94a314ee..5d6b66d8abd 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -59,7 +59,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - await client.async_get_next_pickup_event() + await client.async_get_pickup_events() except RecollectError as err: LOGGER.error("Error during setup of integration: %s", err) return self.async_show_form( diff --git a/tests/components/recollect_waste/test_config_flow.py b/tests/components/recollect_waste/test_config_flow.py index cabcb1a8f9e..22f32983055 100644 --- a/tests/components/recollect_waste/test_config_flow.py +++ b/tests/components/recollect_waste/test_config_flow.py @@ -36,7 +36,7 @@ async def test_invalid_place_or_service_id(hass): conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} with patch( - "aiorecollect.client.Client.async_get_next_pickup_event", + "aiorecollect.client.Client.async_get_pickup_events", side_effect=RecollectError, ): result = await hass.config_entries.flow.async_init( @@ -87,9 +87,7 @@ async def test_step_import(hass): with patch( "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch( - "aiorecollect.client.Client.async_get_next_pickup_event", return_value=True - ): + ), patch("aiorecollect.client.Client.async_get_pickup_events", return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=conf ) @@ -105,9 +103,7 @@ async def test_step_user(hass): with patch( "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch( - "aiorecollect.client.Client.async_get_next_pickup_event", return_value=True - ): + ), patch("aiorecollect.client.Client.async_get_pickup_events", return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) From a275e7aa6725bf5559a57eae399bf1c743c0cfb6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 Aug 2021 14:59:28 -0700 Subject: [PATCH 852/903] Fix wolflink super call (#55359) --- homeassistant/components/wolflink/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 92f18e04de4..975ddbdd068 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -148,7 +148,7 @@ class WolfLinkState(WolfLinkSensor): @property def native_value(self): """Return the state converting with supported values.""" - state = super().state + state = super().native_value resolved_state = [ item for item in self.wolf_object.items if item.value == int(state) ] From bde4c0e46fabca6c93789ae528fa4d85d1d8084d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 27 Aug 2021 21:58:21 -0600 Subject: [PATCH 853/903] Bump pylitterbot to 2021.8.1 (#55360) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index facf79a7bd7..543a15736fe 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -3,7 +3,7 @@ "name": "Litter-Robot", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litterrobot", - "requirements": ["pylitterbot==2021.8.0"], + "requirements": ["pylitterbot==2021.8.1"], "codeowners": ["@natekspencer"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index f17978a3c80..5dbfe7494ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1578,7 +1578,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.8.0 +pylitterbot==2021.8.1 # homeassistant.components.loopenergy pyloopenergy==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb6f5c4c2ce..f73f8d63238 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -906,7 +906,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.8.0 +pylitterbot==2021.8.1 # homeassistant.components.lutron_caseta pylutron-caseta==0.11.0 From c1bce68549c3af891cdce051371f9b9c00f73da5 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Fri, 27 Aug 2021 20:34:32 -0400 Subject: [PATCH 854/903] close connection on connection retry, bump onvif lib (#55363) --- homeassistant/components/onvif/device.py | 1 + homeassistant/components/onvif/manifest.json | 6 +----- requirements_all.txt | 5 +---- requirements_test_all.txt | 5 +---- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 87b68508fa1..9ebf87a4132 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -130,6 +130,7 @@ class ONVIFDevice: err, ) self.available = False + await self.device.close() except Fault as err: LOGGER.error( "Couldn't connect to camera '%s', please verify " diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 641497f5204..a7faa60cdcd 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -2,11 +2,7 @@ "domain": "onvif", "name": "ONVIF", "documentation": "https://www.home-assistant.io/integrations/onvif", - "requirements": [ - "onvif-zeep-async==1.0.0", - "WSDiscovery==2.0.0", - "zeep[async]==4.0.0" - ], + "requirements": ["onvif-zeep-async==1.2.0", "WSDiscovery==2.0.0"], "dependencies": ["ffmpeg"], "codeowners": ["@hunterjm"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 5dbfe7494ef..fe9979c28d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.0.0 +onvif-zeep-async==1.2.0 # homeassistant.components.opengarage open-garage==0.1.5 @@ -2449,9 +2449,6 @@ youless-api==0.12 # homeassistant.components.media_extractor youtube_dl==2021.04.26 -# homeassistant.components.onvif -zeep[async]==4.0.0 - # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f73f8d63238..013fae0eb26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -632,7 +632,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.0.0 +onvif-zeep-async==1.2.0 # homeassistant.components.openerz openerz-api==0.1.0 @@ -1372,9 +1372,6 @@ yeelight==0.7.4 # homeassistant.components.youless youless-api==0.12 -# homeassistant.components.onvif -zeep[async]==4.0.0 - # homeassistant.components.zeroconf zeroconf==0.36.0 From baf0d9b2d973d51991f3a2b5bb97338abc74b4b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 28 Aug 2021 15:00:14 +0200 Subject: [PATCH 855/903] Pin regex to 2021.8.28 (#55368) --- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37649dcf42f..510d27ccccb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -72,3 +72,8 @@ uuid==1000000000.0.0 # Temporary constraint on pandas, to unblock 2021.7 releases # until we have fixed the wheels builds for newer versions. pandas==1.3.0 + +# regex causes segfault with version 2021.8.27 +# https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error +# This is fixed in 2021.8.28 +regex==2021.8.28 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c2c98191a85..f535958412d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -93,6 +93,11 @@ uuid==1000000000.0.0 # Temporary constraint on pandas, to unblock 2021.7 releases # until we have fixed the wheels builds for newer versions. pandas==1.3.0 + +# regex causes segfault with version 2021.8.27 +# https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error +# This is fixed in 2021.8.28 +regex==2021.8.28 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From 910cb5865a839310293c217062201f8175998bd1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 28 Aug 2021 17:49:34 +0200 Subject: [PATCH 856/903] Address late review for Tractive integration (#55371) --- homeassistant/components/tractive/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index aa578bf046b..ba2f330f894 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -29,7 +29,6 @@ from .entity import TractiveEntity class TractiveSensorEntityDescription(SensorEntityDescription): """Class describing Tractive sensor entities.""" - attributes: tuple = () entity_class: type[TractiveSensor] | None = None @@ -88,10 +87,6 @@ class TractiveActivitySensor(TractiveSensor): def handle_activity_status_update(self, event): """Handle activity status update.""" self._attr_native_value = event[self.entity_description.key] - self._attr_extra_state_attributes = { - attr: event[attr] if attr in event else None - for attr in self.entity_description.attributes - } self._attr_available = True self.async_write_ha_state() @@ -128,7 +123,13 @@ SENSOR_TYPES = ( name="Minutes Active", icon="mdi:clock-time-eight-outline", native_unit_of_measurement=TIME_MINUTES, - attributes=(ATTR_DAILY_GOAL,), + entity_class=TractiveActivitySensor, + ), + TractiveSensorEntityDescription( + key=ATTR_DAILY_GOAL, + name="Daily Goal", + icon="mdi:flag-checkered", + native_unit_of_measurement=TIME_MINUTES, entity_class=TractiveActivitySensor, ), ) From adaebdeea888069016dd8e022122a64023a566ec Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Aug 2021 08:59:25 -0700 Subject: [PATCH 857/903] Bumped version to 2021.9.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c523d88606a..973af31f77d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From fbd144de466e33e542e5e5e951a4fd7f4028caba Mon Sep 17 00:00:00 2001 From: Matt Krasowski <4535195+mkrasowski@users.noreply.github.com> Date: Sun, 29 Aug 2021 08:52:12 -0400 Subject: [PATCH 858/903] Handle incorrect values reported by some Shelly devices (#55042) --- homeassistant/components/shelly/binary_sensor.py | 6 ++++-- homeassistant/components/shelly/sensor.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 96d62152830..f4b2daf8159 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -1,7 +1,7 @@ """Binary sensor for Shelly.""" from __future__ import annotations -from typing import Final +from typing import Final, cast from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, @@ -46,7 +46,9 @@ SENSORS: Final = { name="Overpowering", device_class=DEVICE_CLASS_PROBLEM ), ("sensor", "dwIsOpened"): BlockAttributeDescription( - name="Door", device_class=DEVICE_CLASS_OPENING + name="Door", + device_class=DEVICE_CLASS_OPENING, + available=lambda block: cast(bool, block.dwIsOpened != -1), ), ("sensor", "flood"): BlockAttributeDescription( name="Flood", device_class=DEVICE_CLASS_MOISTURE diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 13cf56d3b3d..d8d530ed94c 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -40,6 +40,7 @@ SENSORS: Final = { device_class=sensor.DEVICE_CLASS_BATTERY, state_class=sensor.STATE_CLASS_MEASUREMENT, removal_condition=lambda settings, _: settings.get("external_power") == 1, + available=lambda block: cast(bool, block.battery != -1), ), ("device", "deviceTemp"): BlockAttributeDescription( name="Device Temperature", @@ -176,6 +177,7 @@ SENSORS: Final = { unit=LIGHT_LUX, device_class=sensor.DEVICE_CLASS_ILLUMINANCE, state_class=sensor.STATE_CLASS_MEASUREMENT, + available=lambda block: cast(bool, block.luminosity != -1), ), ("sensor", "tilt"): BlockAttributeDescription( name="Tilt", From ff6015ff8992fe4e0fe78486322c98cada9d4773 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 22:38:41 -0500 Subject: [PATCH 859/903] Implement import of consider_home in nmap_tracker to avoid breaking change (#55379) --- .../components/nmap_tracker/__init__.py | 46 +++++++++++++++---- .../components/nmap_tracker/config_flow.py | 15 +++++- .../components/nmap_tracker/device_tracker.py | 15 +++++- .../components/nmap_tracker/strings.json | 1 + .../nmap_tracker/translations/en.json | 4 +- .../nmap_tracker/test_config_flow.py | 10 +++- 6 files changed, 75 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index dfd8987484c..21469f197f4 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -14,7 +14,11 @@ from getmac import get_mac_address from mac_vendor_lookup import AsyncMacLookup from nmap import PortScanner, PortScannerError -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback @@ -37,7 +41,6 @@ from .const import ( # Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n' NMAP_TRANSIENT_FAILURE: Final = "Assertion failed: htn.toclock_running == true" MAX_SCAN_ATTEMPTS: Final = 16 -OFFLINE_SCANS_TO_MARK_UNAVAILABLE: Final = 3 def short_hostname(hostname: str) -> str: @@ -65,7 +68,7 @@ class NmapDevice: manufacturer: str reason: str last_update: datetime - offline_scans: int + first_offline: datetime | None class NmapTrackedDevices: @@ -137,6 +140,7 @@ class NmapDeviceScanner: """Initialize the scanner.""" self.devices = devices self.home_interval = None + self.consider_home = DEFAULT_CONSIDER_HOME self._hass = hass self._entry = entry @@ -170,6 +174,10 @@ class NmapDeviceScanner: self.home_interval = timedelta( minutes=cv.positive_int(config[CONF_HOME_INTERVAL]) ) + if config.get(CONF_CONSIDER_HOME): + self.consider_home = timedelta( + seconds=cv.positive_float(config[CONF_CONSIDER_HOME]) + ) self._scan_lock = asyncio.Lock() if self._hass.state == CoreState.running: await self._async_start_scanner() @@ -320,16 +328,35 @@ class NmapDeviceScanner: return result @callback - def _async_increment_device_offline(self, ipv4, reason): + def _async_device_offline(self, ipv4: str, reason: str, now: datetime) -> None: """Mark an IP offline.""" if not (formatted_mac := self.devices.ipv4_last_mac.get(ipv4)): return if not (device := self.devices.tracked.get(formatted_mac)): # Device was unloaded return - device.offline_scans += 1 - if device.offline_scans < OFFLINE_SCANS_TO_MARK_UNAVAILABLE: + if not device.first_offline: + _LOGGER.debug( + "Setting first_offline for %s (%s) to: %s", ipv4, formatted_mac, now + ) + device.first_offline = now return + if device.first_offline + self.consider_home > now: + _LOGGER.debug( + "Device %s (%s) has NOT been offline (first offline at: %s) long enough to be considered not home: %s", + ipv4, + formatted_mac, + device.first_offline, + self.consider_home, + ) + return + _LOGGER.debug( + "Device %s (%s) has been offline (first offline at: %s) long enough to be considered not home: %s", + ipv4, + formatted_mac, + device.first_offline, + self.consider_home, + ) device.reason = reason async_dispatcher_send(self._hass, signal_device_update(formatted_mac), False) del self.devices.ipv4_last_mac[ipv4] @@ -347,7 +374,7 @@ class NmapDeviceScanner: status = info["status"] reason = status["reason"] if status["state"] != "up": - self._async_increment_device_offline(ipv4, reason) + self._async_device_offline(ipv4, reason, now) continue # Mac address only returned if nmap ran as root mac = info["addresses"].get( @@ -356,12 +383,11 @@ class NmapDeviceScanner: partial(get_mac_address, ip=ipv4) ) if mac is None: - self._async_increment_device_offline(ipv4, "No MAC address found") + self._async_device_offline(ipv4, "No MAC address found", now) _LOGGER.info("No MAC address found for %s", ipv4) continue formatted_mac = format_mac(mac) - if ( devices.config_entry_owner.setdefault(formatted_mac, entry_id) != entry_id @@ -372,7 +398,7 @@ class NmapDeviceScanner: vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac) name = human_readable_name(hostname, vendor, mac) device = NmapDevice( - formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 + formatted_mac, hostname, name, ipv4, vendor, reason, now, None ) new = formatted_mac not in devices.tracked diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 2d25b62f1d2..c9e9706e4ba 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -8,7 +8,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import network -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.components.network.const import MDNS_TARGET_IP from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS @@ -24,6 +28,8 @@ from .const import ( TRACKER_SCAN_INTERVAL, ) +MAX_SCAN_INTERVAL = 3600 +MAX_CONSIDER_HOME = MAX_SCAN_INTERVAL * 6 DEFAULT_NETWORK_PREFIX = 24 @@ -116,7 +122,12 @@ async def _async_build_schema_with_user_input( vol.Optional( CONF_SCAN_INTERVAL, default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL), - ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), + ): vol.All(vol.Coerce(int), vol.Range(min=10, max=MAX_SCAN_INTERVAL)), + vol.Optional( + CONF_CONSIDER_HOME, + default=user_input.get(CONF_CONSIDER_HOME) + or DEFAULT_CONSIDER_HOME.total_seconds(), + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=MAX_CONSIDER_HOME)), } ) return vol.Schema(schema) diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 5ec9f2fcb9a..e475afd24c8 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -12,7 +12,11 @@ from homeassistant.components.device_tracker import ( SOURCE_TYPE_ROUTER, ) from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback @@ -38,6 +42,9 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOSTS): cv.ensure_list, vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, + vol.Required( + CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME.total_seconds() + ): cv.time_period, vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS): cv.string, } @@ -53,9 +60,15 @@ async def async_get_scanner(hass: HomeAssistant, config: ConfigType) -> None: else: scan_interval = TRACKER_SCAN_INTERVAL + if CONF_CONSIDER_HOME in validated_config: + consider_home = validated_config[CONF_CONSIDER_HOME].total_seconds() + else: + consider_home = DEFAULT_CONSIDER_HOME.total_seconds() + import_config = { CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], + CONF_CONSIDER_HOME: consider_home, CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), CONF_OPTIONS: validated_config[CONF_OPTIONS], CONF_SCAN_INTERVAL: scan_interval, diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index d42e1067503..ed5a8cb0b05 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -7,6 +7,7 @@ "data": { "hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]", "home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]", + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]", "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]", "interval_seconds": "Scan interval" diff --git a/homeassistant/components/nmap_tracker/translations/en.json b/homeassistant/components/nmap_tracker/translations/en.json index 6b83532a0e2..feeea1ff8be 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -25,12 +25,12 @@ "step": { "init": { "data": { + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "Network addresses (comma seperated) to exclude from scanning", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", "hosts": "Network addresses (comma seperated) to scan", "interval_seconds": "Scan interval", - "scan_options": "Raw configurable scan options for Nmap", - "track_new_devices": "Track new devices" + "scan_options": "Raw configurable scan options for Nmap" }, "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)." } diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 6365dd7407a..74997df5a4f 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -4,7 +4,10 @@ from unittest.mock import patch import pytest from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, +) from homeassistant.components.nmap_tracker.const import ( CONF_HOME_INTERVAL, CONF_OPTIONS, @@ -206,6 +209,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_EXCLUDE: "4.4.4.4", CONF_HOME_INTERVAL: 3, CONF_HOSTS: "192.168.1.0/24", + CONF_CONSIDER_HOME: 180, CONF_SCAN_INTERVAL: 120, CONF_OPTIONS: "-F -T4 --min-rate 10 --host-timeout 5s", } @@ -219,6 +223,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: user_input={ CONF_HOSTS: "192.168.1.0/24, 192.168.2.0/24", CONF_HOME_INTERVAL: 5, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", CONF_SCAN_INTERVAL: 10, @@ -230,6 +235,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert config_entry.options == { CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24", CONF_HOME_INTERVAL: 5, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4,5.5.5.5", CONF_SCAN_INTERVAL: 10, @@ -250,6 +256,7 @@ async def test_import(hass: HomeAssistant) -> None: data={ CONF_HOSTS: "1.2.3.4/20", CONF_HOME_INTERVAL: 3, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", CONF_SCAN_INTERVAL: 2000, @@ -263,6 +270,7 @@ async def test_import(hass: HomeAssistant) -> None: assert result["options"] == { CONF_HOSTS: "1.2.3.4/20", CONF_HOME_INTERVAL: 3, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4,6.4.3.2", CONF_SCAN_INTERVAL: 2000, From 4b7803ed03df6bd6dc43ba4b756c7a0d1396ac9d Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 28 Aug 2021 12:57:02 -0600 Subject: [PATCH 860/903] Bump simplisafe-python to 11.0.6 (#55385) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 2d524d4c381..c6bc3ae61fa 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==11.0.5"], + "requirements": ["simplisafe-python==11.0.6"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index fe9979c28d2..9a2b1c033a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2131,7 +2131,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.5 +simplisafe-python==11.0.6 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 013fae0eb26..28a2a7b8e3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1191,7 +1191,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.5 +simplisafe-python==11.0.6 # homeassistant.components.slack slackclient==2.5.0 From 69d8f94e3bf0b0f05c1dfd2ba53f0e28b679a05e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 02:01:04 -0500 Subject: [PATCH 861/903] Show device_id in HomeKit when the device registry entry is missing a name (#55391) - Reported at: https://community.home-assistant.io/t/homekit-unknown-error-occurred/333385 --- homeassistant/components/homekit/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index fdad10f873f..03df55a9026 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -498,7 +498,10 @@ async def _async_get_supported_devices(hass): """Return all supported devices.""" results = await device_automation.async_get_device_automations(hass, "trigger") dev_reg = device_registry.async_get(hass) - unsorted = {device_id: dev_reg.async_get(device_id).name for device_id in results} + unsorted = { + device_id: dev_reg.async_get(device_id).name or device_id + for device_id in results + } return dict(sorted(unsorted.items(), key=lambda item: item[1])) From 47e2d1caa50b83407da1c4fc9c784c2d2e087ef4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 30 Aug 2021 05:30:54 +0200 Subject: [PATCH 862/903] Fix device_class - qnap drive_temp sensor (#55409) --- homeassistant/components/qnap/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 333ce46599a..b02c977d98d 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -91,7 +91,7 @@ _DRIVE_MON_COND = { "mdi:checkbox-marked-circle-outline", None, ], - "drive_temp": ["Temperature", TEMP_CELSIUS, None, None, DEVICE_CLASS_TEMPERATURE], + "drive_temp": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], } _VOLUME_MON_COND = { "volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie", None], From 6cf799459b5c0c02429d0c648996931f9c5fc89f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 29 Aug 2021 21:27:34 -0600 Subject: [PATCH 863/903] Ensure ReCollect Waste shows pickups for midnight on the actual day (#55424) --- .../components/recollect_waste/sensor.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 434d24be22a..9c9bc9d6bf4 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -1,6 +1,8 @@ """Support for ReCollect Waste sensors.""" from __future__ import annotations +from datetime import date, datetime, time + from aiorecollect.client import PickupType import voluptuous as vol @@ -20,6 +22,7 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util.dt import as_utc from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN, LOGGER @@ -73,6 +76,12 @@ async def async_setup_platform( ) +@callback +def async_get_utc_midnight(target_date: date) -> datetime: + """Get UTC midnight for a given date.""" + return as_utc(datetime.combine(target_date, time(0))) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -123,7 +132,9 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): ATTR_NEXT_PICKUP_TYPES: async_get_pickup_type_names( self._entry, next_pickup_event.pickup_types ), - ATTR_NEXT_PICKUP_DATE: next_pickup_event.date.isoformat(), + ATTR_NEXT_PICKUP_DATE: async_get_utc_midnight( + next_pickup_event.date + ).isoformat(), } ) - self._attr_native_value = pickup_event.date.isoformat() + self._attr_native_value = async_get_utc_midnight(pickup_event.date).isoformat() From 10df9f354205ddfd9a026fb890e1b14e1fff73c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 22:29:46 -0500 Subject: [PATCH 864/903] Bump zeroconf to 0.36.1 (#55425) - Fixes duplicate records in the cache - Changelog: https://github.com/jstasiak/python-zeroconf/compare/0.36.0...0.36.1 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 84f9f4698e9..dea3b3c356e 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.0"], + "requirements": ["zeroconf==0.36.1"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 510d27ccccb..8beb6789b54 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.0 +zeroconf==0.36.1 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 9a2b1c033a9..4492479fa1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2453,7 +2453,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.0 +zeroconf==0.36.1 # homeassistant.components.zha zha-quirks==0.0.60 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28a2a7b8e3c..94bd3c02094 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1373,7 +1373,7 @@ yeelight==0.7.4 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.0 +zeroconf==0.36.1 # homeassistant.components.zha zha-quirks==0.0.60 From 2c0d9105ac3630a4c66f4de336b4a50ab1cd9189 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 30 Aug 2021 05:29:37 +0200 Subject: [PATCH 865/903] Update entity names for P1 Monitor integration (#55430) --- .../components/p1_monitor/manifest.json | 2 +- homeassistant/components/p1_monitor/sensor.py | 20 +++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/p1_monitor/test_sensor.py | 16 +++++++-------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json index 1a4beb36f5d..00b50bb029b 100644 --- a/homeassistant/components/p1_monitor/manifest.json +++ b/homeassistant/components/p1_monitor/manifest.json @@ -3,7 +3,7 @@ "name": "P1 Monitor", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/p1_monitor", - "requirements": ["p1monitor==0.2.0"], + "requirements": ["p1monitor==1.0.0"], "codeowners": ["@klaasnicolaas"], "quality_scale": "platinum", "iot_class": "local_polling" diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 36a991c7333..ea18854f748 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -192,33 +192,33 @@ SENSORS: dict[ ), SERVICE_SETTINGS: ( SensorEntityDescription( - key="gas_consumption_tariff", - name="Gas Consumption - Tariff", + key="gas_consumption_price", + name="Gas Consumption Price", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_consumption_low_tariff", - name="Energy Consumption - Low Tariff", + key="energy_consumption_price_low", + name="Energy Consumption Price - Low", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_consumption_high_tariff", - name="Energy Consumption - High Tariff", + key="energy_consumption_price_high", + name="Energy Consumption Price - High", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_production_low_tariff", - name="Energy Production - Low Tariff", + key="energy_production_price_low", + name="Energy Production Price - Low", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_production_high_tariff", - name="Energy Production - High Tariff", + key="energy_production_price_high", + name="Energy Production Price - High", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), diff --git a/requirements_all.txt b/requirements_all.txt index 4492479fa1d..0264d83665b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1139,7 +1139,7 @@ orvibo==1.1.1 ovoenergy==1.1.12 # homeassistant.components.p1_monitor -p1monitor==0.2.0 +p1monitor==1.0.0 # homeassistant.components.mqtt # homeassistant.components.shiftr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94bd3c02094..dec96024ce1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -641,7 +641,7 @@ openerz-api==0.1.0 ovoenergy==1.1.12 # homeassistant.components.p1_monitor -p1monitor==0.2.0 +p1monitor==1.0.0 # homeassistant.components.mqtt # homeassistant.components.shiftr diff --git a/tests/components/p1_monitor/test_sensor.py b/tests/components/p1_monitor/test_sensor.py index baf73811636..90733ce8941 100644 --- a/tests/components/p1_monitor/test_sensor.py +++ b/tests/components/p1_monitor/test_sensor.py @@ -151,23 +151,23 @@ async def test_settings( entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) - state = hass.states.get("sensor.monitor_energy_consumption_low_tariff") - entry = entity_registry.async_get("sensor.monitor_energy_consumption_low_tariff") + state = hass.states.get("sensor.monitor_energy_consumption_price_low") + entry = entity_registry.async_get("sensor.monitor_energy_consumption_price_low") assert entry assert state - assert entry.unique_id == f"{entry_id}_settings_energy_consumption_low_tariff" + assert entry.unique_id == f"{entry_id}_settings_energy_consumption_price_low" assert state.state == "0.20522" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption - Low Tariff" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption Price - Low" assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO - state = hass.states.get("sensor.monitor_energy_production_low_tariff") - entry = entity_registry.async_get("sensor.monitor_energy_production_low_tariff") + state = hass.states.get("sensor.monitor_energy_production_price_low") + entry = entity_registry.async_get("sensor.monitor_energy_production_price_low") assert entry assert state - assert entry.unique_id == f"{entry_id}_settings_energy_production_low_tariff" + assert entry.unique_id == f"{entry_id}_settings_energy_production_price_low" assert state.state == "0.20522" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production - Low Tariff" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production Price - Low" assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO From 948f191f16a1edc7927e779a4af315439b12e114 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 29 Aug 2021 23:25:47 -0400 Subject: [PATCH 866/903] Make zwave_js discovery log message more descriptive (#55432) --- homeassistant/components/zwave_js/__init__.py | 2 +- homeassistant/components/zwave_js/discovery.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index c8f2bd19776..f38594c1594 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -145,7 +145,7 @@ async def async_setup_entry( # noqa: C901 value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {} # run discovery on all node values and create/update entities - for disc_info in async_discover_values(node): + for disc_info in async_discover_values(node, device): platform = disc_info.platform # This migration logic was added in 2021.3 to handle a breaking change to diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 7a4955d693a..7232279f4c6 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -13,6 +13,7 @@ from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry from .const import LOGGER from .discovery_data_template import ( @@ -667,7 +668,9 @@ DISCOVERY_SCHEMAS = [ @callback -def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None, None]: +def async_discover_values( + node: ZwaveNode, device: DeviceEntry +) -> Generator[ZwaveDiscoveryInfo, None, None]: """Run discovery on ZWave node and return matching (primary) values.""" for value in node.values.values(): for schema in DISCOVERY_SCHEMAS: @@ -758,7 +761,11 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None resolved_data = schema.data_template.resolve_data(value) except UnknownValueData as err: LOGGER.error( - "Discovery for value %s will be skipped: %s", value, err + "Discovery for value %s on device '%s' (%s) will be skipped: %s", + value, + device.name_by_user or device.name, + node, + err, ) continue additional_value_ids_to_watch = schema.data_template.value_ids_to_watch( From fb06acf39d84f731d702c1c1ea7a8b1109c8e079 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 29 Aug 2021 20:45:45 -0700 Subject: [PATCH 867/903] Bumped version to 2021.9.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 973af31f77d..d3c6fb3d606 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 5dcc760755a45dbcc24510915415f79b56c9adee Mon Sep 17 00:00:00 2001 From: Christopher Kochan <5183896+crkochan@users.noreply.github.com> Date: Mon, 30 Aug 2021 10:01:26 -0500 Subject: [PATCH 868/903] Add Sense energy sensors (#54833) Co-authored-by: Paulus Schoutsen --- homeassistant/components/sense/const.py | 10 ++++ homeassistant/components/sense/sensor.py | 71 +++++++++++++++++------- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index 783fcb5508a..af8454bbeab 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -23,6 +23,16 @@ CONSUMPTION_NAME = "Usage" CONSUMPTION_ID = "usage" PRODUCTION_NAME = "Production" PRODUCTION_ID = "production" +PRODUCTION_PCT_NAME = "Net Production Percentage" +PRODUCTION_PCT_ID = "production_pct" +NET_PRODUCTION_NAME = "Net Production" +NET_PRODUCTION_ID = "net_production" +TO_GRID_NAME = "To Grid" +TO_GRID_ID = "to_grid" +FROM_GRID_NAME = "From Grid" +FROM_GRID_ID = "from_grid" +SOLAR_POWERED_NAME = "Solar Powered Percentage" +SOLAR_POWERED_ID = "solar_powered" ICON = "mdi:flash" diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 6be24a73a21..ce22551eff2 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -10,6 +10,7 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, + PERCENTAGE, POWER_WATT, ) from homeassistant.core import callback @@ -22,15 +23,25 @@ from .const import ( CONSUMPTION_ID, CONSUMPTION_NAME, DOMAIN, + FROM_GRID_ID, + FROM_GRID_NAME, ICON, MDI_ICONS, + NET_PRODUCTION_ID, + NET_PRODUCTION_NAME, PRODUCTION_ID, PRODUCTION_NAME, + PRODUCTION_PCT_ID, + PRODUCTION_PCT_NAME, SENSE_DATA, SENSE_DEVICE_UPDATE, SENSE_DEVICES_DATA, SENSE_DISCOVERED_DEVICES_DATA, SENSE_TRENDS_COORDINATOR, + SOLAR_POWERED_ID, + SOLAR_POWERED_NAME, + TO_GRID_ID, + TO_GRID_NAME, ) @@ -55,7 +66,16 @@ TRENDS_SENSOR_TYPES = { } # Production/consumption variants -SENSOR_VARIANTS = [PRODUCTION_ID, CONSUMPTION_ID] +SENSOR_VARIANTS = [(PRODUCTION_ID, PRODUCTION_NAME), (CONSUMPTION_ID, CONSUMPTION_NAME)] + +# Trend production/consumption variants +TREND_SENSOR_VARIANTS = SENSOR_VARIANTS + [ + (PRODUCTION_PCT_ID, PRODUCTION_PCT_NAME), + (NET_PRODUCTION_ID, NET_PRODUCTION_NAME), + (FROM_GRID_ID, FROM_GRID_NAME), + (TO_GRID_ID, TO_GRID_NAME), + (SOLAR_POWERED_ID, SOLAR_POWERED_NAME), +] def sense_to_mdi(sense_icon): @@ -86,15 +106,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if device["tags"]["DeviceListAllowed"] == "true" ] - for var in SENSOR_VARIANTS: + for variant_id, variant_name in SENSOR_VARIANTS: name = ACTIVE_SENSOR_TYPE.name sensor_type = ACTIVE_SENSOR_TYPE.sensor_type - is_production = var == PRODUCTION_ID - unique_id = f"{sense_monitor_id}-active-{var}" + unique_id = f"{sense_monitor_id}-active-{variant_id}" devices.append( SenseActiveSensor( - data, name, sensor_type, is_production, sense_monitor_id, var, unique_id + data, + name, + sensor_type, + sense_monitor_id, + variant_id, + variant_name, + unique_id, ) ) @@ -102,18 +127,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices.append(SenseVoltageSensor(data, i, sense_monitor_id)) for type_id, typ in TRENDS_SENSOR_TYPES.items(): - for var in SENSOR_VARIANTS: + for variant_id, variant_name in TREND_SENSOR_VARIANTS: name = typ.name sensor_type = typ.sensor_type - is_production = var == PRODUCTION_ID - unique_id = f"{sense_monitor_id}-{type_id}-{var}" + unique_id = f"{sense_monitor_id}-{type_id}-{variant_id}" devices.append( SenseTrendsSensor( data, name, sensor_type, - is_production, + variant_id, + variant_name, trends_coordinator, unique_id, ) @@ -137,19 +162,19 @@ class SenseActiveSensor(SensorEntity): data, name, sensor_type, - is_production, sense_monitor_id, - sensor_id, + variant_id, + variant_name, unique_id, ): """Initialize the Sense sensor.""" - name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._attr_name = f"{name} {name_type}" + self._attr_name = f"{name} {variant_name}" self._attr_unique_id = unique_id self._data = data self._sense_monitor_id = sense_monitor_id self._sensor_type = sensor_type - self._is_production = is_production + self._variant_id = variant_id + self._variant_name = variant_name async def async_added_to_hass(self): """Register callbacks.""" @@ -166,7 +191,7 @@ class SenseActiveSensor(SensorEntity): """Update the sensor from the data. Must not do I/O.""" new_state = round( self._data.active_solar_power - if self._is_production + if self._variant_id == PRODUCTION_ID else self._data.active_power ) if self._attr_available and self._attr_native_value == new_state: @@ -235,24 +260,30 @@ class SenseTrendsSensor(SensorEntity): data, name, sensor_type, - is_production, + variant_id, + variant_name, trends_coordinator, unique_id, ): """Initialize the Sense sensor.""" - name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._attr_name = f"{name} {name_type}" + self._attr_name = f"{name} {variant_name}" self._attr_unique_id = unique_id self._data = data self._sensor_type = sensor_type self._coordinator = trends_coordinator - self._is_production = is_production + self._variant_id = variant_id self._had_any_update = False + if variant_id in [PRODUCTION_PCT_ID, SOLAR_POWERED_ID]: + self._attr_native_unit_of_measurement = PERCENTAGE + self._attr_entity_registry_enabled_default = False + self._attr_state_class = None + self._attr_device_class = None + @property def native_value(self): """Return the state of the sensor.""" - return round(self._data.get_trend(self._sensor_type, self._is_production), 1) + return round(self._data.get_trend(self._sensor_type, self._variant_id), 1) @property def available(self): From b546fc5067e767cd27f8e731d383dfee1ee2e971 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 30 Aug 2021 11:48:36 -0400 Subject: [PATCH 869/903] Don't set zwave_js sensor device class to energy when unit is wrong (#55434) --- homeassistant/components/zwave_js/const.py | 2 ++ .../zwave_js/discovery_data_template.py | 16 ++++++++++++++++ homeassistant/components/zwave_js/sensor.py | 12 ++++++++++++ 3 files changed, 30 insertions(+) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 8e545975faa..e4486a681e1 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -96,3 +96,5 @@ ENTITY_DESC_KEY_SIGNAL_STRENGTH = "signal_strength" ENTITY_DESC_KEY_TEMPERATURE = "temperature" ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" ENTITY_DESC_KEY_TIMESTAMP = "timestamp" +ENTITY_DESC_KEY_MEASUREMENT = "measurement" +ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 3ef74a7e17d..dd338de63eb 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -24,6 +24,7 @@ from zwave_js_server.const import ( VOLTAGE_METER_TYPES, VOLTAGE_SENSORS, CommandClass, + ElectricScale, MeterScaleType, MultilevelSensorType, ) @@ -43,6 +44,7 @@ from .const import ( ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, ENTITY_DESC_KEY_HUMIDITY, ENTITY_DESC_KEY_ILLUMINANCE, + ENTITY_DESC_KEY_MEASUREMENT, ENTITY_DESC_KEY_POWER, ENTITY_DESC_KEY_POWER_FACTOR, ENTITY_DESC_KEY_PRESSURE, @@ -50,6 +52,7 @@ from .const import ( ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TIMESTAMP, + ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_VOLTAGE, ) @@ -187,6 +190,19 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): if value.command_class == CommandClass.METER: scale_type = get_meter_scale_type(value) + # We do this because even though these are energy scales, they don't meet + # the unit requirements for the energy device class. + if scale_type in ( + ElectricScale.PULSE, + ElectricScale.KILOVOLT_AMPERE_HOUR, + ElectricScale.KILOVOLT_AMPERE_REACTIVE_HOUR, + ): + return ENTITY_DESC_KEY_TOTAL_INCREASING + # We do this because even though these are power scales, they don't meet + # the unit requirements for the energy power class. + if scale_type == ElectricScale.KILOVOLT_AMPERE_REACTIVE: + return ENTITY_DESC_KEY_MEASUREMENT + for key, scale_type_set in METER_DEVICE_CLASS_MAP.items(): if scale_type in scale_type_set: return key diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 40159b383a6..c71a1d87653 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -63,6 +63,7 @@ from .const import ( ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, ENTITY_DESC_KEY_HUMIDITY, ENTITY_DESC_KEY_ILLUMINANCE, + ENTITY_DESC_KEY_MEASUREMENT, ENTITY_DESC_KEY_POWER, ENTITY_DESC_KEY_POWER_FACTOR, ENTITY_DESC_KEY_PRESSURE, @@ -70,6 +71,7 @@ from .const import ( ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TIMESTAMP, + ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_VOLTAGE, SERVICE_RESET_METER, ) @@ -168,6 +170,16 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, ZwaveSensorEntityDescription] = { device_class=DEVICE_CLASS_TEMPERATURE, state_class=None, ), + ENTITY_DESC_KEY_MEASUREMENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_MEASUREMENT, + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_TOTAL_INCREASING: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TOTAL_INCREASING, + device_class=None, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } From 4052a0db8915e591046fc9659c3400be5455e207 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 12:51:46 +0200 Subject: [PATCH 870/903] Improve statistics error messages when sensor's unit is changing (#55436) * Improve error messages when sensor's unit is changing * Improve test coverage --- homeassistant/components/sensor/recorder.py | 13 +++- tests/components/sensor/test_recorder.py | 85 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 2b59592dd17..6ab75f88dbd 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -200,11 +200,18 @@ def _normalize_states( hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: hass.data[WARN_UNSTABLE_UNIT].add(entity_id) + extra = "" + if old_metadata := statistics.get_metadata(hass, entity_id): + extra = ( + " and matches the unit of already compiled statistics " + f"({old_metadata['unit_of_measurement']})" + ) _LOGGER.warning( - "The unit of %s is changing, got %s, generation of long term " - "statistics will be suppressed unless the unit is stable", + "The unit of %s is changing, got multiple %s, generation of long term " + "statistics will be suppressed unless the unit is stable%s", entity_id, all_units, + extra, ) return None, [] unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -320,7 +327,7 @@ def compile_statistics( entity_id, unit, old_metadata["unit_of_measurement"], - unit, + old_metadata["unit_of_measurement"], ) continue diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 2e300b9c748..6c4c899eb14 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1028,6 +1028,7 @@ def test_compile_hourly_statistics_changing_units_2( recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(minutes=30)) wait_recording_done(hass) assert "The unit of sensor.test1 is changing" in caplog.text + assert "and matches the unit of already compiled statistics" not in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": "cats"} @@ -1038,6 +1039,90 @@ def test_compile_hourly_statistics_changing_units_2( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + (None, "%", "%", 16.440677, 10, 30), + ("battery", "%", "%", 16.440677, 10, 30), + ("battery", None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_units_3( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): + """Test compiling hourly statistics where units change from one hour to the next.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + four, _states = record_states( + hass, zero + timedelta(hours=1), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, zero + timedelta(hours=2), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + assert "does not match the unit of already compiled" not in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + wait_recording_done(hass) + assert "The unit of sensor.test1 is changing" in caplog.text + assert f"matches the unit of already compiled statistics ({unit})" in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ From 65ad99d51cba12b7bccd02666c4a805f6bdf050f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 10:29:39 +0200 Subject: [PATCH 871/903] Fix crash in buienradar sensor due to self.hass not set (#55438) --- homeassistant/components/buienradar/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 6dfbef9f931..2c6390f959b 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -699,7 +699,7 @@ class BrSensor(SensorEntity): @callback def data_updated(self, data): """Update data.""" - if self._load_data(data) and self.hass: + if self.hass and self._load_data(data): self.async_write_ha_state() @callback From a474534c085d75d16dd302d69b522489ef0eb04c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 12:08:21 +0200 Subject: [PATCH 872/903] Fix exception when shutting down DSMR (#55441) * Fix exception when shutting down DSMR * Update homeassistant/components/dsmr/sensor.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/dsmr/sensor.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index d3dfb68d425..bd02be7d63e 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.helpers.typing import ConfigType, EventType, StateType from homeassistant.util import Throttle from .const import ( @@ -146,8 +146,15 @@ async def async_setup_entry( if transport: # Register listener to close transport on HA shutdown + @callback + def close_transport(_event: EventType) -> None: + """Close the transport on HA shutdown.""" + if not transport: + return + transport.close() + stop_listener = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, transport.close + EVENT_HOMEASSISTANT_STOP, close_transport ) # Wait for reader to close From 707778229b6bda7708d542dd94aa11fd11a2c3b9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 30 Aug 2021 17:43:11 +0200 Subject: [PATCH 873/903] Fix noise/attenuation units to UI display for Fritz (#55447) --- homeassistant/components/fritz/sensor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 7b6a6528eab..bc579b1125e 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -113,28 +113,28 @@ def _retrieve_link_noise_margin_sent_state( status: FritzStatus, last_value: str ) -> float: """Return upload noise margin.""" - return status.noise_margin[0] # type: ignore[no-any-return] + return status.noise_margin[0] / 10 # type: ignore[no-any-return] def _retrieve_link_noise_margin_received_state( status: FritzStatus, last_value: str ) -> float: """Return download noise margin.""" - return status.noise_margin[1] # type: ignore[no-any-return] + return status.noise_margin[1] / 10 # type: ignore[no-any-return] def _retrieve_link_attenuation_sent_state( status: FritzStatus, last_value: str ) -> float: """Return upload line attenuation.""" - return status.attenuation[0] # type: ignore[no-any-return] + return status.attenuation[0] / 10 # type: ignore[no-any-return] def _retrieve_link_attenuation_received_state( status: FritzStatus, last_value: str ) -> float: """Return download line attenuation.""" - return status.attenuation[1] # type: ignore[no-any-return] + return status.attenuation[1] / 10 # type: ignore[no-any-return] class SensorData(TypedDict, total=False): From 3b0fe9adde09223698e3e2d28420d063943d0593 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 16:58:48 +0200 Subject: [PATCH 874/903] Revert "Deprecate last_reset options in MQTT sensor" (#55457) This reverts commit f9fa5fa804291cdc3c2ab9592b3841fb2444bb72. --- homeassistant/components/mqtt/sensor.py | 29 ++++++++++--------------- tests/components/mqtt/test_sensor.py | 22 ------------------- 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 16c19c8fc51..eac136d3f84 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -53,23 +53,18 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False -PLATFORM_SCHEMA = vol.All( - # Deprecated, remove in Home Assistant 2021.11 - cv.deprecated(CONF_LAST_RESET_TOPIC), - cv.deprecated(CONF_LAST_RESET_VALUE_TEMPLATE), - mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } - ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), -) +PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) async def async_setup_platform( diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 724dec1c93f..15ca9870077 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -306,28 +306,6 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" -async def test_last_reset_deprecated(hass, mqtt_mock, caplog): - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", - "last_reset_value_template": "{{ value_json.last_reset }}", - } - }, - ) - await hass.async_block_till_done() - - assert "The 'last_reset_topic' option is deprecated" in caplog.text - assert "The 'last_reset_value_template' option is deprecated" in caplog.text - - async def test_force_update_disabled(hass, mqtt_mock): """Test force update option.""" assert await async_setup_component( From 39f11bb46d622bceada33688a8d139f841b015db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Aug 2021 10:59:41 -0500 Subject: [PATCH 875/903] Bump zeroconf to 0.36.2 (#55459) - Now sends NSEC records when requesting non-existent address types Implements RFC6762 sec 6.2 (http://datatracker.ietf.org/doc/html/rfc6762#section-6.2) - This solves a case where a HomeKit bridge can take a while to update because it is waiting to see if an AAAA (IPv6) address is available --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index dea3b3c356e..6ed4c8d09dd 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.1"], + "requirements": ["zeroconf==0.36.2"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8beb6789b54..c91a512fdd0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.1 +zeroconf==0.36.2 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 0264d83665b..6548dbf1a92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2453,7 +2453,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.1 +zeroconf==0.36.2 # homeassistant.components.zha zha-quirks==0.0.60 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dec96024ce1..20b1b1d96e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1373,7 +1373,7 @@ yeelight==0.7.4 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.1 +zeroconf==0.36.2 # homeassistant.components.zha zha-quirks==0.0.60 From 46ce4e92f6d87222ad0463c1980d4c5eb8b050b8 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 30 Aug 2021 12:40:56 -0400 Subject: [PATCH 876/903] Bump zwave-js-server-python to 0.29.1 (#55460) --- homeassistant/components/zwave_js/climate.py | 4 +-- homeassistant/components/zwave_js/cover.py | 2 +- .../components/zwave_js/discovery.py | 5 ++- .../zwave_js/discovery_data_template.py | 32 ++++++++++--------- homeassistant/components/zwave_js/light.py | 3 +- homeassistant/components/zwave_js/lock.py | 4 +-- .../components/zwave_js/manifest.json | 8 ++--- homeassistant/components/zwave_js/select.py | 3 +- homeassistant/components/zwave_js/sensor.py | 5 ++- homeassistant/components/zwave_js/siren.py | 2 +- homeassistant/components/zwave_js/switch.py | 4 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_lock.py | 2 +- 14 files changed, 43 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 1621e87cfab..1ec5ccbcc01 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -4,14 +4,14 @@ from __future__ import annotations from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, THERMOSTAT_MODE_PROPERTY, THERMOSTAT_MODE_SETPOINT_MAP, THERMOSTAT_MODES, THERMOSTAT_OPERATING_STATE_PROPERTY, THERMOSTAT_SETPOINT_PROPERTY, - CommandClass, ThermostatMode, ThermostatOperatingState, ThermostatSetpointType, diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index f8e575521dc..7fceaf64c0e 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -5,7 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import BarrierState +from zwave_js_server.const.command_class.barrior_operator import BarrierState from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 7232279f4c6..d5af1c072ee 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -6,7 +6,10 @@ from dataclasses import asdict, dataclass, field from typing import Any from awesomeversion import AwesomeVersion -from zwave_js_server.const import THERMOSTAT_CURRENT_TEMP_PROPERTY, CommandClass +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( + THERMOSTAT_CURRENT_TEMP_PROPERTY, +) from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index dd338de63eb..974cd2bfa44 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -5,27 +5,29 @@ from collections.abc import Iterable from dataclasses import dataclass from typing import Any -from zwave_js_server.const import ( - CO2_SENSORS, - CO_SENSORS, +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.meter import ( CURRENT_METER_TYPES, - CURRENT_SENSORS, - ENERGY_METER_TYPES, - ENERGY_SENSORS, - HUMIDITY_SENSORS, - ILLUMINANCE_SENSORS, + ENERGY_TOTAL_INCREASING_METER_TYPES, POWER_FACTOR_METER_TYPES, POWER_METER_TYPES, + VOLTAGE_METER_TYPES, + ElectricScale, + MeterScaleType, +) +from zwave_js_server.const.command_class.multilevel_sensor import ( + CO2_SENSORS, + CO_SENSORS, + CURRENT_SENSORS, + ENERGY_MEASUREMENT_SENSORS, + HUMIDITY_SENSORS, + ILLUMINANCE_SENSORS, POWER_SENSORS, PRESSURE_SENSORS, SIGNAL_STRENGTH_SENSORS, TEMPERATURE_SENSORS, TIMESTAMP_SENSORS, - VOLTAGE_METER_TYPES, VOLTAGE_SENSORS, - CommandClass, - ElectricScale, - MeterScaleType, MultilevelSensorType, ) from zwave_js_server.model.node import Node as ZwaveNode @@ -59,7 +61,7 @@ from .const import ( METER_DEVICE_CLASS_MAP: dict[str, set[MeterScaleType]] = { ENTITY_DESC_KEY_CURRENT: CURRENT_METER_TYPES, ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_METER_TYPES, - ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ENERGY_METER_TYPES, + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ENERGY_TOTAL_INCREASING_METER_TYPES, ENTITY_DESC_KEY_POWER: POWER_METER_TYPES, ENTITY_DESC_KEY_POWER_FACTOR: POWER_FACTOR_METER_TYPES, } @@ -68,7 +70,7 @@ MULTILEVEL_SENSOR_DEVICE_CLASS_MAP: dict[str, set[MultilevelSensorType]] = { ENTITY_DESC_KEY_CO: CO_SENSORS, ENTITY_DESC_KEY_CO2: CO2_SENSORS, ENTITY_DESC_KEY_CURRENT: CURRENT_SENSORS, - ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ENERGY_SENSORS, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ENERGY_MEASUREMENT_SENSORS, ENTITY_DESC_KEY_HUMIDITY: HUMIDITY_SENSORS, ENTITY_DESC_KEY_ILLUMINANCE: ILLUMINANCE_SENSORS, ENTITY_DESC_KEY_POWER: POWER_SENSORS, @@ -193,7 +195,7 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): # We do this because even though these are energy scales, they don't meet # the unit requirements for the energy device class. if scale_type in ( - ElectricScale.PULSE, + ElectricScale.PULSE_COUNT, ElectricScale.KILOVOLT_AMPERE_HOUR, ElectricScale.KILOVOLT_AMPERE_REACTIVE_HOUR, ): diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 91a7f191e5d..0857b43e4ee 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -5,7 +5,8 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ColorComponent, CommandClass +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.color_switch import ColorComponent from homeassistant.components.light import ( ATTR_BRIGHTNESS, diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 696310b5ad1..0f2a0862d7f 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -6,12 +6,12 @@ from typing import Any import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import ( ATTR_CODE_SLOT, ATTR_USERCODE, LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP, LOCK_CMD_CLASS_TO_PROPERTY_MAP, - CommandClass, DoorLockMode, ) from zwave_js_server.model.value import Value as ZwaveValue diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 23a1546a421..7953e33d6e3 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,13 +3,13 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.29.0"], + "requirements": ["zwave-js-server-python==0.29.1"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", "usb": [ - {"vid":"0658","pid":"0200","known_devices":["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"]}, - {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}, - {"vid":"10C4","pid":"EA60","known_devices":["Aeotec Z-Stick 7", "Silicon Labs UZB-7", "Zooz ZST10 700"]} + {"vid":"0658","pid":"0200","known_devices":["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"]}, + {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}, + {"vid":"10C4","pid":"EA60","known_devices":["Aeotec Z-Stick 7", "Silicon Labs UZB-7", "Zooz ZST10 700"]} ] } diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 7aedc6521d9..fae87fd24de 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -2,7 +2,8 @@ from __future__ import annotations from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ToneID +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.sound_switch import ToneID from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index c71a1d87653..09d44f7f24a 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -8,11 +8,10 @@ from typing import cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, - CommandClass, - ConfigurationValueType, ) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index c1b354f4faa..4ef89b9f4cd 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ToneID +from zwave_js_server.const.command_class.sound_switch import ToneID from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN, SirenEntity from homeassistant.components.siren.const import ( diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 0bc6b8d5349..bd86a3b8377 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -5,7 +5,9 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import BarrierEventSignalingSubsystemState +from zwave_js_server.const.command_class.barrior_operator import ( + BarrierEventSignalingSubsystemState, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry diff --git a/requirements_all.txt b/requirements_all.txt index 6548dbf1a92..df5f1b50474 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2486,4 +2486,4 @@ zigpy==0.37.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.29.0 +zwave-js-server-python==0.29.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20b1b1d96e1..fa0cf6c526e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1397,4 +1397,4 @@ zigpy-znp==0.5.4 zigpy==0.37.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.29.0 +zwave-js-server-python==0.29.1 diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 3727ab9d288..9a0735d3dc6 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -1,5 +1,5 @@ """Test the Z-Wave JS lock platform.""" -from zwave_js_server.const import ATTR_CODE_SLOT, ATTR_USERCODE +from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT, ATTR_USERCODE from zwave_js_server.event import Event from zwave_js_server.model.node import NodeStatus From 8be40cbb00ee854a17a26f52855a116e6b81b0bb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Aug 2021 09:41:51 -0700 Subject: [PATCH 877/903] Bumped version to 2021.9.0b5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d3c6fb3d606..8b82b13478b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 802f5613c4edda5f3598cd0c2aa4356c13ea61db Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 30 Aug 2021 12:52:29 -0700 Subject: [PATCH 878/903] Add IoTaWatt integration (#55364) Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 1 + homeassistant/components/iotawatt/__init__.py | 24 ++ .../components/iotawatt/config_flow.py | 107 +++++++++ homeassistant/components/iotawatt/const.py | 12 + .../components/iotawatt/coordinator.py | 56 +++++ .../components/iotawatt/manifest.json | 13 ++ homeassistant/components/iotawatt/sensor.py | 213 ++++++++++++++++++ .../components/iotawatt/strings.json | 23 ++ .../components/iotawatt/translations/en.json | 24 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/iotawatt/__init__.py | 21 ++ tests/components/iotawatt/conftest.py | 27 +++ tests/components/iotawatt/test_config_flow.py | 143 ++++++++++++ tests/components/iotawatt/test_init.py | 31 +++ tests/components/iotawatt/test_sensor.py | 76 +++++++ 17 files changed, 778 insertions(+) create mode 100644 homeassistant/components/iotawatt/__init__.py create mode 100644 homeassistant/components/iotawatt/config_flow.py create mode 100644 homeassistant/components/iotawatt/const.py create mode 100644 homeassistant/components/iotawatt/coordinator.py create mode 100644 homeassistant/components/iotawatt/manifest.json create mode 100644 homeassistant/components/iotawatt/sensor.py create mode 100644 homeassistant/components/iotawatt/strings.json create mode 100644 homeassistant/components/iotawatt/translations/en.json create mode 100644 tests/components/iotawatt/__init__.py create mode 100644 tests/components/iotawatt/conftest.py create mode 100644 tests/components/iotawatt/test_config_flow.py create mode 100644 tests/components/iotawatt/test_init.py create mode 100644 tests/components/iotawatt/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 121d1875202..a1b12a81127 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -248,6 +248,7 @@ homeassistant/components/integration/* @dgomes homeassistant/components/intent/* @home-assistant/core homeassistant/components/intesishome/* @jnimmo homeassistant/components/ios/* @robbiet480 +homeassistant/components/iotawatt/* @gtdiehl homeassistant/components/iperf3/* @rohankapoorcom homeassistant/components/ipma/* @dgomes @abmantis homeassistant/components/ipp/* @ctalkington diff --git a/homeassistant/components/iotawatt/__init__.py b/homeassistant/components/iotawatt/__init__.py new file mode 100644 index 00000000000..7987004e594 --- /dev/null +++ b/homeassistant/components/iotawatt/__init__.py @@ -0,0 +1,24 @@ +"""The iotawatt integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import IotawattUpdater + +PLATFORMS = ("sensor",) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up iotawatt from a config entry.""" + coordinator = IotawattUpdater(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/iotawatt/config_flow.py b/homeassistant/components/iotawatt/config_flow.py new file mode 100644 index 00000000000..9ec860ea76a --- /dev/null +++ b/homeassistant/components/iotawatt/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for iotawatt integration.""" +from __future__ import annotations + +import logging + +from iotawattpy.iotawatt import Iotawatt +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import httpx_client + +from .const import CONNECTION_ERRORS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, str] +) -> dict[str, str]: + """Validate the user input allows us to connect.""" + iotawatt = Iotawatt( + "", + data[CONF_HOST], + httpx_client.get_async_client(hass), + data.get(CONF_USERNAME), + data.get(CONF_PASSWORD), + ) + try: + is_connected = await iotawatt.connect() + except CONNECTION_ERRORS: + return {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return {"base": "unknown"} + + if not is_connected: + return {"base": "invalid_auth"} + + return {} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for iotawatt.""" + + VERSION = 1 + + def __init__(self): + """Initialize.""" + self._data = {} + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + user_input = {} + + schema = vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + } + ) + if not user_input: + return self.async_show_form(step_id="user", data_schema=schema) + + if not (errors := await validate_input(self.hass, user_input)): + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) + + if errors == {"base": "invalid_auth"}: + self._data.update(user_input) + return await self.async_step_auth() + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_auth(self, user_input=None): + """Authenticate user if authentication is enabled on the IoTaWatt device.""" + if user_input is None: + user_input = {} + + data_schema = vol.Schema( + { + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, + } + ) + if not user_input: + return self.async_show_form(step_id="auth", data_schema=data_schema) + + data = {**self._data, **user_input} + + if errors := await validate_input(self.hass, data): + return self.async_show_form( + step_id="auth", data_schema=data_schema, errors=errors + ) + + return self.async_create_entry(title=data[CONF_HOST], data=data) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/iotawatt/const.py b/homeassistant/components/iotawatt/const.py new file mode 100644 index 00000000000..db847f3dfe8 --- /dev/null +++ b/homeassistant/components/iotawatt/const.py @@ -0,0 +1,12 @@ +"""Constants for the IoTaWatt integration.""" +from __future__ import annotations + +import json + +import httpx + +DOMAIN = "iotawatt" +VOLT_AMPERE_REACTIVE = "VAR" +VOLT_AMPERE_REACTIVE_HOURS = "VARh" + +CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError) diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py new file mode 100644 index 00000000000..1a722d52a1e --- /dev/null +++ b/homeassistant/components/iotawatt/coordinator.py @@ -0,0 +1,56 @@ +"""IoTaWatt DataUpdateCoordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from iotawattpy.iotawatt import Iotawatt + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import httpx_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONNECTION_ERRORS + +_LOGGER = logging.getLogger(__name__) + + +class IotawattUpdater(DataUpdateCoordinator): + """Class to manage fetching update data from the IoTaWatt Energy Device.""" + + api: Iotawatt | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize IotaWattUpdater object.""" + self.entry = entry + super().__init__( + hass=hass, + logger=_LOGGER, + name=entry.title, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self): + """Fetch sensors from IoTaWatt device.""" + if self.api is None: + api = Iotawatt( + self.entry.title, + self.entry.data[CONF_HOST], + httpx_client.get_async_client(self.hass), + self.entry.data.get(CONF_USERNAME), + self.entry.data.get(CONF_PASSWORD), + ) + try: + is_authenticated = await api.connect() + except CONNECTION_ERRORS as err: + raise UpdateFailed("Connection failed") from err + + if not is_authenticated: + raise UpdateFailed("Authentication error") + + self.api = api + + await self.api.update() + return self.api.getSensors() diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json new file mode 100644 index 00000000000..d78e546d71f --- /dev/null +++ b/homeassistant/components/iotawatt/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "iotawatt", + "name": "IoTaWatt", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/iotawatt", + "requirements": [ + "iotawattpy==0.0.8" + ], + "codeowners": [ + "@gtdiehl" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py new file mode 100644 index 00000000000..8a8c92a8c51 --- /dev/null +++ b/homeassistant/components/iotawatt/sensor.py @@ -0,0 +1,213 @@ +"""Support for IoTaWatt Energy monitor.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from iotawattpy.sensor import Sensor + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_VOLT_AMPERE, + POWER_WATT, +) +from homeassistant.core import callback +from homeassistant.helpers import entity, entity_registry, update_coordinator +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS +from .coordinator import IotawattUpdater + + +@dataclass +class IotaWattSensorEntityDescription(SensorEntityDescription): + """Class describing IotaWatt sensor entities.""" + + value: Callable | None = None + + +ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { + "Amps": IotaWattSensorEntityDescription( + "Amps", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_CURRENT, + ), + "Hz": IotaWattSensorEntityDescription( + "Hz", + native_unit_of_measurement=FREQUENCY_HERTZ, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "PF": IotaWattSensorEntityDescription( + "PF", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER_FACTOR, + value=lambda value: value * 100, + ), + "Watts": IotaWattSensorEntityDescription( + "Watts", + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER, + ), + "WattHours": IotaWattSensorEntityDescription( + "WattHours", + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + ), + "VA": IotaWattSensorEntityDescription( + "VA", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "VAR": IotaWattSensorEntityDescription( + "VAR", + native_unit_of_measurement=VOLT_AMPERE_REACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "VARh": IotaWattSensorEntityDescription( + "VARh", + native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "Volts": IotaWattSensorEntityDescription( + "Volts", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_VOLTAGE, + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add sensors for passed config_entry in HA.""" + coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id] + created = set() + + @callback + def _create_entity(key: str) -> IotaWattSensor: + """Create a sensor entity.""" + created.add(key) + return IotaWattSensor( + coordinator=coordinator, + key=key, + mac_address=coordinator.data["sensors"][key].hub_mac_address, + name=coordinator.data["sensors"][key].getName(), + entity_description=ENTITY_DESCRIPTION_KEY_MAP.get( + coordinator.data["sensors"][key].getUnit(), + IotaWattSensorEntityDescription("base_sensor"), + ), + ) + + async_add_entities(_create_entity(key) for key in coordinator.data["sensors"]) + + @callback + def new_data_received(): + """Check for new sensors.""" + entities = [ + _create_entity(key) + for key in coordinator.data["sensors"] + if key not in created + ] + if entities: + async_add_entities(entities) + + coordinator.async_add_listener(new_data_received) + + +class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): + """Defines a IoTaWatt Energy Sensor.""" + + entity_description: IotaWattSensorEntityDescription + _attr_force_update = True + + def __init__( + self, + coordinator, + key, + mac_address, + name, + entity_description: IotaWattSensorEntityDescription, + ): + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + + self._key = key + data = self._sensor_data + if data.getType() == "Input": + self._attr_unique_id = ( + f"{data.hub_mac_address}-input-{data.getChannel()}-{data.getUnit()}" + ) + self.entity_description = entity_description + + @property + def _sensor_data(self) -> Sensor: + """Return sensor data.""" + return self.coordinator.data["sensors"][self._key] + + @property + def name(self) -> str | None: + """Return name of the entity.""" + return self._sensor_data.getName() + + @property + def device_info(self) -> entity.DeviceInfo | None: + """Return device info.""" + return { + "connections": { + (CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address) + }, + "manufacturer": "IoTaWatt", + "model": "IoTaWatt", + } + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._key not in self.coordinator.data["sensors"]: + if self._attr_unique_id: + entity_registry.async_get(self.hass).async_remove(self.entity_id) + else: + self.hass.async_create_task(self.async_remove()) + return + + super()._handle_coordinator_update() + + @property + def extra_state_attributes(self): + """Return the extra state attributes of the entity.""" + data = self._sensor_data + attrs = {"type": data.getType()} + if attrs["type"] == "Input": + attrs["channel"] = data.getChannel() + + return attrs + + @property + def native_value(self) -> entity.StateType: + """Return the state of the sensor.""" + if func := self.entity_description.value: + return func(self._sensor_data.getValue()) + + return self._sensor_data.getValue() diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json new file mode 100644 index 00000000000..f21dfe0cd09 --- /dev/null +++ b/homeassistant/components/iotawatt/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "auth": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button." + } + }, + "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%]" + } + } +} diff --git a/homeassistant/components/iotawatt/translations/en.json b/homeassistant/components/iotawatt/translations/en.json new file mode 100644 index 00000000000..cbda4b41bea --- /dev/null +++ b/homeassistant/components/iotawatt/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "auth": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button." + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "title": "iotawatt" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ec2947443de..2eb4e43fe32 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -130,6 +130,7 @@ FLOWS = [ "ifttt", "insteon", "ios", + "iotawatt", "ipma", "ipp", "iqvia", diff --git a/requirements_all.txt b/requirements_all.txt index df5f1b50474..002d5a7e2c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -864,6 +864,9 @@ influxdb-client==1.14.0 # homeassistant.components.influxdb influxdb==5.2.3 +# homeassistant.components.iotawatt +iotawattpy==0.0.8 + # homeassistant.components.iperf3 iperf3==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa0cf6c526e..baeac83042c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -504,6 +504,9 @@ influxdb-client==1.14.0 # homeassistant.components.influxdb influxdb==5.2.3 +# homeassistant.components.iotawatt +iotawattpy==0.0.8 + # homeassistant.components.gogogate2 ismartgate==4.0.0 diff --git a/tests/components/iotawatt/__init__.py b/tests/components/iotawatt/__init__.py new file mode 100644 index 00000000000..3d1afe1b88b --- /dev/null +++ b/tests/components/iotawatt/__init__.py @@ -0,0 +1,21 @@ +"""Tests for the IoTaWatt integration.""" +from iotawattpy.sensor import Sensor + +INPUT_SENSOR = Sensor( + channel="1", + name="My Sensor", + io_type="Input", + unit="WattHours", + value="23", + begin="", + mac_addr="mock-mac", +) +OUTPUT_SENSOR = Sensor( + channel="N/A", + name="My WattHour Sensor", + io_type="Output", + unit="WattHours", + value="243", + begin="", + mac_addr="mock-mac", +) diff --git a/tests/components/iotawatt/conftest.py b/tests/components/iotawatt/conftest.py new file mode 100644 index 00000000000..f96201ba50e --- /dev/null +++ b/tests/components/iotawatt/conftest.py @@ -0,0 +1,27 @@ +"""Test fixtures for IoTaWatt.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.iotawatt import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def entry(hass): + """Mock config entry added to HA.""" + entry = MockConfigEntry(domain=DOMAIN, data={"host": "1.2.3.4"}) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_iotawatt(entry): + """Mock iotawatt.""" + with patch("homeassistant.components.iotawatt.coordinator.Iotawatt") as mock: + instance = mock.return_value + instance.connect = AsyncMock(return_value=True) + instance.update = AsyncMock() + instance.getSensors.return_value = {"sensors": {}} + yield instance diff --git a/tests/components/iotawatt/test_config_flow.py b/tests/components/iotawatt/test_config_flow.py new file mode 100644 index 00000000000..e028f365431 --- /dev/null +++ b/tests/components/iotawatt/test_config_flow.py @@ -0,0 +1,143 @@ +"""Test the IoTawatt config flow.""" +from unittest.mock import patch + +import httpx + +from homeassistant import config_entries, setup +from homeassistant.components.iotawatt.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.iotawatt.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + "host": "1.1.1.1", + } + + +async def test_form_auth(hass: HomeAssistant) -> None: + """Test we handle auth.""" + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "auth" + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=False, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "mock-user", + "password": "mock-pass", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["step_id"] == "auth" + assert result3["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.iotawatt.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=True, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "mock-user", + "password": "mock-pass", + }, + ) + await hass.async_block_till_done() + + assert result4["type"] == RESULT_TYPE_CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + assert result4["data"] == { + "host": "1.1.1.1", + "username": "mock-user", + "password": "mock-pass", + } + + +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.iotawatt.config_flow.Iotawatt.connect", + side_effect=httpx.HTTPError("any"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_setup_exception(hass: HomeAssistant) -> None: + """Test we handle broad exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/iotawatt/test_init.py b/tests/components/iotawatt/test_init.py new file mode 100644 index 00000000000..b43a3d9aa88 --- /dev/null +++ b/tests/components/iotawatt/test_init.py @@ -0,0 +1,31 @@ +"""Test init.""" +import httpx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.setup import async_setup_component + +from . import INPUT_SENSOR + + +async def test_setup_unload(hass, mock_iotawatt, entry): + """Test we can setup and unload an entry.""" + mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_setup_connection_failed(hass, mock_iotawatt, entry): + """Test connection error during startup.""" + mock_iotawatt.connect.side_effect = httpx.ConnectError("") + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_setup_auth_failed(hass, mock_iotawatt, entry): + """Test auth error during startup.""" + mock_iotawatt.connect.return_value = False + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py new file mode 100644 index 00000000000..556da8cc2b0 --- /dev/null +++ b/tests/components/iotawatt/test_sensor.py @@ -0,0 +1,76 @@ +"""Test setting up sensors.""" +from datetime import timedelta + +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DEVICE_CLASS_ENERGY, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_WATT_HOUR, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from . import INPUT_SENSOR, OUTPUT_SENSOR + +from tests.common import async_fire_time_changed + + +async def test_sensor_type_input(hass, mock_iotawatt): + """Test input sensors work.""" + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 0 + + # Discover this sensor during a regular update. + mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get("sensor.my_sensor") + assert state is not None + assert state.state == "23" + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_FRIENDLY_NAME] == "My Sensor" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["channel"] == "1" + assert state.attributes["type"] == "Input" + + mock_iotawatt.getSensors.return_value["sensors"].pop("my_sensor_key") + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.my_sensor") is None + + +async def test_sensor_type_output(hass, mock_iotawatt): + """Tests the sensor type of Output.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_sensor_key" + ] = OUTPUT_SENSOR + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get("sensor.my_watthour_sensor") + assert state is not None + assert state.state == "243" + assert state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Sensor" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + + mock_iotawatt.getSensors.return_value["sensors"].pop("my_watthour_sensor_key") + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.my_watthour_sensor") is None From 84f3b1514f1ca815b031690918cc65fd230ec027 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 23:32:35 +0200 Subject: [PATCH 879/903] Fix race in MQTT sensor when last_reset_topic is configured (#55463) --- homeassistant/components/mqtt/sensor.py | 100 +++++++++++++++++------- tests/components/mqtt/test_sensor.py | 46 ++++++++++- 2 files changed, 117 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index eac136d3f84..4a0ea75de21 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -53,18 +53,48 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False -PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +def validate_options(conf): + """Validate options. + + If last reset topic is present it must be same as the state topic. + """ + if ( + CONF_LAST_RESET_TOPIC in conf + and CONF_STATE_TOPIC in conf + and conf[CONF_LAST_RESET_TOPIC] != conf[CONF_STATE_TOPIC] + ): + _LOGGER.warning( + "'%s' must be same as '%s'", CONF_LAST_RESET_TOPIC, CONF_STATE_TOPIC + ) + + if CONF_LAST_RESET_TOPIC in conf and CONF_LAST_RESET_VALUE_TEMPLATE not in conf: + _LOGGER.warning( + "'%s' must be set if '%s' is set", + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_LAST_RESET_TOPIC, + ) + + return conf + + +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_LAST_RESET_TOPIC), + mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } + ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), + validate_options, +) async def async_setup_platform( @@ -127,10 +157,7 @@ class MqttSensor(MqttEntity, SensorEntity): """(Re)Subscribe to topics.""" topics = {} - @callback - @log_messages(self.hass, self.entity_id) - def message_received(msg): - """Handle new MQTT messages.""" + def _update_state(msg): payload = msg.payload # auto-expire enabled? expire_after = self._config.get(CONF_EXPIRE_AFTER) @@ -159,18 +186,8 @@ class MqttSensor(MqttEntity, SensorEntity): variables=variables, ) self._state = payload - self.async_write_ha_state() - topics["state_topic"] = { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - } - - @callback - @log_messages(self.hass, self.entity_id) - def last_reset_message_received(msg): - """Handle new last_reset messages.""" + def _update_last_reset(msg): payload = msg.payload template = self._config.get(CONF_LAST_RESET_VALUE_TEMPLATE) @@ -193,9 +210,36 @@ class MqttSensor(MqttEntity, SensorEntity): _LOGGER.warning( "Invalid last_reset message '%s' from '%s'", msg.payload, msg.topic ) + + @callback + @log_messages(self.hass, self.entity_id) + def message_received(msg): + """Handle new MQTT messages.""" + _update_state(msg) + if CONF_LAST_RESET_VALUE_TEMPLATE in self._config and ( + CONF_LAST_RESET_TOPIC not in self._config + or self._config[CONF_LAST_RESET_TOPIC] == self._config[CONF_STATE_TOPIC] + ): + _update_last_reset(msg) self.async_write_ha_state() - if CONF_LAST_RESET_TOPIC in self._config: + topics["state_topic"] = { + "topic": self._config[CONF_STATE_TOPIC], + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + } + + @callback + @log_messages(self.hass, self.entity_id) + def last_reset_message_received(msg): + """Handle new last_reset messages.""" + _update_last_reset(msg) + self.async_write_ha_state() + + if ( + CONF_LAST_RESET_TOPIC in self._config + and self._config[CONF_LAST_RESET_TOPIC] != self._config[CONF_STATE_TOPIC] + ): topics["last_reset_topic"] = { "topic": self._config[CONF_LAST_RESET_TOPIC], "msg_callback": last_reset_message_received, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 15ca9870077..46c06f0d3b3 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -208,7 +208,7 @@ async def test_setting_sensor_value_via_mqtt_json_message(hass, mqtt_mock): assert state.state == "100" -async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock): +async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock, caplog): """Test the setting of the last_reset property via MQTT.""" assert await async_setup_component( hass, @@ -228,6 +228,11 @@ async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock): async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") state = hass.states.get("sensor.test") assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" + assert "'last_reset_topic' must be same as 'state_topic'" in caplog.text + assert ( + "'last_reset_value_template' must be set if 'last_reset_topic' is set" + in caplog.text + ) @pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) @@ -306,6 +311,45 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" +@pytest.mark.parametrize("extra", [{}, {"last_reset_topic": "test-topic"}]) +async def test_setting_sensor_last_reset_via_mqtt_json_message_2( + hass, mqtt_mock, caplog, extra +): + """Test the setting of the value via MQTT with JSON payload.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + **{ + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "kWh", + "value_template": "{{ value_json.value | float / 60000 }}", + "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", + }, + **extra, + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message( + hass, + "test-topic", + '{"type":"minute","time":1629385500000,"value":947.7706166666667}', + ) + state = hass.states.get("sensor.test") + assert float(state.state) == pytest.approx(0.015796176944444445) + assert state.attributes.get("last_reset") == "2021-08-19T15:05:00+00:00" + assert "'last_reset_topic' must be same as 'state_topic'" not in caplog.text + assert ( + "'last_reset_value_template' must be set if 'last_reset_topic' is set" + not in caplog.text + ) + + async def test_force_update_disabled(hass, mqtt_mock): """Test force update option.""" assert await async_setup_component( From 275f9c8a289cf72c73bf181f61de82e69cdaef26 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 14:12:27 -0600 Subject: [PATCH 880/903] Bump pyopenuv to 2.2.0 (#55464) --- homeassistant/components/openuv/__init__.py | 1 + homeassistant/components/openuv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index d14760d6cb1..5d165c498e2 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -66,6 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation), session=websession, + logger=LOGGER, ), ) await openuv.async_update() diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index 842d4966805..24af3f3a3af 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -3,7 +3,7 @@ "name": "OpenUV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openuv", - "requirements": ["pyopenuv==2.1.0"], + "requirements": ["pyopenuv==2.2.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 002d5a7e2c4..aada554c2be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1668,7 +1668,7 @@ pyobihai==1.3.1 pyombi==0.1.10 # homeassistant.components.openuv -pyopenuv==2.1.0 +pyopenuv==2.2.0 # homeassistant.components.opnsense pyopnsense==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index baeac83042c..c955af2d5e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -966,7 +966,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.openuv -pyopenuv==2.1.0 +pyopenuv==2.2.0 # homeassistant.components.opnsense pyopnsense==0.2.0 From 0d9fbf864faf27439544c61dd066b0cb5e2adc46 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 14:12:09 -0600 Subject: [PATCH 881/903] Bump pyiqvia to 1.1.0 (#55466) --- homeassistant/components/iqvia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index da50819c9a0..e8914507657 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,7 +3,7 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.21.1", "pyiqvia==1.0.0"], + "requirements": ["numpy==1.21.1", "pyiqvia==1.1.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index aada554c2be..a3ac41e41f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1530,7 +1530,7 @@ pyipma==2.0.5 pyipp==0.11.0 # homeassistant.components.iqvia -pyiqvia==1.0.0 +pyiqvia==1.1.0 # homeassistant.components.irish_rail_transport pyirishrail==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c955af2d5e4..01f91b59bd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -879,7 +879,7 @@ pyipma==2.0.5 pyipp==0.11.0 # homeassistant.components.iqvia -pyiqvia==1.0.0 +pyiqvia==1.1.0 # homeassistant.components.isy994 pyisy==3.0.0 From f92c7b1aeae40c020e35cdf6fb5765eb7d050faf Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 15:05:28 -0600 Subject: [PATCH 882/903] Bump aioambient to 1.3.0 (#55468) --- homeassistant/components/ambient_station/__init__.py | 1 + homeassistant/components/ambient_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index d719f9b3728..68b8579f731 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -319,6 +319,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_API_KEY], config_entry.data[CONF_APP_KEY], session=session, + logger=LOGGER, ), ) hass.loop.create_task(ambient.ws_connect()) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 35b4770e872..b95f4a8f13c 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.2.6"], + "requirements": ["aioambient==1.3.0"], "codeowners": ["@bachya"], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index a3ac41e41f8..80b84f80093 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -136,7 +136,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.6 +aioambient==1.3.0 # homeassistant.components.asuswrt aioasuswrt==1.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01f91b59bd2..1ff7c137e73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,7 +78,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.6 +aioambient==1.3.0 # homeassistant.components.asuswrt aioasuswrt==1.3.4 From 8ab801a7b461fb821d3b83093a11f37797f80439 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 30 Aug 2021 16:09:41 -0400 Subject: [PATCH 883/903] Fix area_id and area_name template functions (#55470) --- homeassistant/helpers/template.py | 23 ++++++++++++++++++----- tests/helpers/test_template.py | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index a831e8d156d..ade580694c8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -957,6 +957,7 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: return area.id ent_reg = entity_registry.async_get(hass) + dev_reg = device_registry.async_get(hass) # Import here, not at top-level to avoid circular import from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel config_validation as cv, @@ -968,10 +969,14 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: pass else: if entity := ent_reg.async_get(lookup_value): - return entity.area_id + # If entity has an area ID, return that + if entity.area_id: + return entity.area_id + # If entity has a device ID, return the area ID for the device + if entity.device_id and (device := dev_reg.async_get(entity.device_id)): + return device.area_id - # Check if this could be a device ID (hex string) - dev_reg = device_registry.async_get(hass) + # Check if this could be a device ID if device := dev_reg.async_get(lookup_value): return device.area_id @@ -992,6 +997,7 @@ def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: if area: return area.name + dev_reg = device_registry.async_get(hass) ent_reg = entity_registry.async_get(hass) # Import here, not at top-level to avoid circular import from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel @@ -1004,11 +1010,18 @@ def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: pass else: if entity := ent_reg.async_get(lookup_value): + # If entity has an area ID, get the area name for that if entity.area_id: return _get_area_name(area_reg, entity.area_id) - return None + # If entity has a device ID and the device exists with an area ID, get the + # area name for that + if ( + entity.device_id + and (device := dev_reg.async_get(entity.device_id)) + and device.area_id + ): + return _get_area_name(area_reg, device.area_id) - dev_reg = device_registry.async_get(hass) if (device := dev_reg.async_get(lookup_value)) and device.area_id: return _get_area_name(area_reg, device.area_id) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 7a2776fd5b2..64b075b685a 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1828,6 +1828,16 @@ async def test_area_id(hass): assert_result_info(info, area_entry_entity_id.id) assert info.rate_limit is None + # Make sure that when entity doesn't have an area but its device does, that's what + # gets returned + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_entity_id.id + ) + + info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry_entity_id.id) + assert info.rate_limit is None + async def test_area_name(hass): """Test area_name function.""" @@ -1897,6 +1907,16 @@ async def test_area_name(hass): assert_result_info(info, area_entry.name) assert info.rate_limit is None + # Make sure that when entity doesn't have an area but its device does, that's what + # gets returned + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=None + ) + + info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry.name) + assert info.rate_limit is None + def test_closest_function_to_coord(hass): """Test closest function to coord.""" From 92b045374959b33e8038b40f1d2a41886a47a794 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 30 Aug 2021 23:32:19 +0200 Subject: [PATCH 884/903] Update frontend to 20210830.0 (#55472) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6224916246a..076420656fd 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210825.0" + "home-assistant-frontend==20210830.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c91a512fdd0..cb6d10e4084 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==3.3.2 defusedxml==0.7.1 emoji==1.2.0 hass-nabucasa==0.46.0 -home-assistant-frontend==20210825.0 +home-assistant-frontend==20210830.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 80b84f80093..611713006ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -793,7 +793,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210825.0 +home-assistant-frontend==20210830.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ff7c137e73..2ec55d5184e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -462,7 +462,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210825.0 +home-assistant-frontend==20210830.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 4c48ad91089ba2980a2430133ffdd0300f900ea8 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 30 Aug 2021 23:35:50 +0200 Subject: [PATCH 885/903] Bumped version to Bumped version to 2021.9.0b6 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8b82b13478b..6c096f06769 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From f0c0cfcac0fe909400873f1c3ef6fe31223d97f2 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Tue, 31 Aug 2021 00:32:26 -0700 Subject: [PATCH 886/903] Wemo Insight devices need polling when off (#55348) --- homeassistant/components/wemo/wemo_device.py | 21 ++++++-- tests/components/wemo/test_wemo_device.py | 56 +++++++++++++++++++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 9423d0b8d1c..1690d30e082 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta import logging -from pywemo import WeMoDevice +from pywemo import Insight, WeMoDevice from pywemo.exceptions import ActionException from pywemo.subscribe import EVENT_TYPE_LONG_PRESS @@ -81,11 +81,26 @@ class DeviceCoordinator(DataUpdateCoordinator): else: self.async_set_updated_data(None) + @property + def should_poll(self) -> bool: + """Return True if polling is needed to update the state for the device. + + The alternative, when this returns False, is to rely on the subscription + "push updates" to update the device state in Home Assistant. + """ + if isinstance(self.wemo, Insight) and self.wemo.get_state() == 0: + # The WeMo Insight device does not send subscription updates for the + # insight_params values when the device is off. Polling is required in + # this case so the Sensor entities are properly populated. + return True + + registry = self.hass.data[DOMAIN]["registry"] + return not (registry.is_subscribed(self.wemo) and self.last_update_success) + async def _async_update_data(self) -> None: """Update WeMo state.""" # No need to poll if the device will push updates. - registry = self.hass.data[DOMAIN]["registry"] - if registry.is_subscribed(self.wemo) and self.last_update_success: + if not self.should_poll: return # If an update is in progress, we don't do anything. diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index 6f3cc12a81a..e756e816a47 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -1,6 +1,7 @@ """Tests for wemo_device.py.""" import asyncio -from unittest.mock import patch +from datetime import timedelta +from unittest.mock import call, patch import async_timeout import pytest @@ -14,9 +15,12 @@ from homeassistant.core import callback from homeassistant.helpers import device_registry from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from .conftest import MOCK_HOST +from tests.common import async_fire_time_changed + asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(True)) @@ -148,3 +152,53 @@ async def test_async_update_data_subscribed( pywemo_device.get_state.reset_mock() await device._async_update_data() pywemo_device.get_state.assert_not_called() + + +class TestInsight: + """Tests specific to the WeMo Insight device.""" + + @pytest.fixture + def pywemo_model(self): + """Pywemo Dimmer models use the light platform (WemoDimmer class).""" + return "Insight" + + @pytest.fixture(name="pywemo_device") + def pywemo_device_fixture(self, pywemo_device): + """Fixture for WeMoDevice instances.""" + pywemo_device.insight_params = { + "currentpower": 1.0, + "todaymw": 200000000.0, + "state": 0, + "onfor": 0, + "ontoday": 0, + "ontotal": 0, + "powerthreshold": 0, + } + yield pywemo_device + + @pytest.mark.parametrize( + "subscribed,state,expected_calls", + [ + (False, 0, [call(), call(True), call(), call()]), + (False, 1, [call(), call(True), call(), call()]), + (True, 0, [call(), call(True), call(), call()]), + (True, 1, [call(), call(), call()]), + ], + ) + async def test_should_poll( + self, + hass, + subscribed, + state, + expected_calls, + wemo_entity, + pywemo_device, + pywemo_registry, + ): + """Validate the should_poll returns the correct value.""" + pywemo_registry.is_subscribed.return_value = subscribed + pywemo_device.get_state.reset_mock() + pywemo_device.get_state.return_value = state + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + pywemo_device.get_state.assert_has_calls(expected_calls) From b8770c395812718ac1b234d39ed0ff577f3d9937 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Aug 2021 10:45:17 +0200 Subject: [PATCH 887/903] Make new cycles for sensor sum statistics start with 0 as zero-point (#55473) --- homeassistant/components/sensor/recorder.py | 7 ++----- tests/components/sensor/test_recorder.py | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 6ab75f88dbd..cc1b6865d81 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -403,11 +403,8 @@ def compile_statistics( # ..and update the starting point new_state = fstate old_last_reset = last_reset - # Force a new cycle for STATE_CLASS_TOTAL_INCREASING to start at 0 - if ( - state_class == STATE_CLASS_TOTAL_INCREASING - and old_state is not None - ): + # Force a new cycle for an existing sensor to start at 0 + if old_state is not None: old_state = 0.0 else: old_state = new_state diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 6c4c899eb14..b3f0ab075c6 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -258,7 +258,7 @@ def test_compile_hourly_sum_statistics_amount( "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[5]), - "sum": approx(factor * 10.0), + "sum": approx(factor * 40.0), }, { "statistic_id": "sensor.test1", @@ -268,7 +268,7 @@ def test_compile_hourly_sum_statistics_amount( "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[8]), - "sum": approx(factor * 40.0), + "sum": approx(factor * 70.0), }, ] } @@ -512,7 +512,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(10.0), + "sum": approx(40.0), }, { "statistic_id": "sensor.test1", @@ -522,7 +522,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(40.0), + "sum": approx(70.0), }, ] } @@ -595,7 +595,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(10.0), + "sum": approx(40.0), }, { "statistic_id": "sensor.test1", @@ -605,7 +605,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(40.0), + "sum": approx(70.0), }, ], "sensor.test2": [ @@ -627,7 +627,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(45.0), - "sum": approx(-95.0), + "sum": approx(-65.0), }, { "statistic_id": "sensor.test2", @@ -637,7 +637,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(75.0), - "sum": approx(-65.0), + "sum": approx(-35.0), }, ], "sensor.test3": [ @@ -659,7 +659,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(50.0 / 1000), - "sum": approx(30.0 / 1000), + "sum": approx(60.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -669,7 +669,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(90.0 / 1000), - "sum": approx(70.0 / 1000), + "sum": approx(100.0 / 1000), }, ], } From ef001783393540b52b5bb5a2d40eb36491478b21 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Aug 2021 23:45:35 -0700 Subject: [PATCH 888/903] Add Eagle 200 name back (#55477) * Add Eagle 200 name back * add comment * update tests --- homeassistant/components/rainforest_eagle/sensor.py | 7 ++++--- tests/components/rainforest_eagle/test_sensor.py | 12 ++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 4b24a3abdaa..6f6b496cfca 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -38,21 +38,22 @@ _LOGGER = logging.getLogger(__name__) SENSORS = ( SensorEntityDescription( key="zigbee:InstantaneousDemand", - name="Meter Power Demand", + # We can drop the "Eagle-200" part of the name in HA 2021.12 + name="Eagle-200 Meter Power Demand", native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="zigbee:CurrentSummationDelivered", - name="Total Meter Energy Delivered", + name="Eagle-200 Total Meter Energy Delivered", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), SensorEntityDescription( key="zigbee:CurrentSummationReceived", - name="Total Meter Energy Received", + name="Eagle-200 Total Meter Energy Received", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py index a090c6dc318..e895f2ac4fc 100644 --- a/tests/components/rainforest_eagle/test_sensor.py +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -114,17 +114,17 @@ async def test_sensors_200(hass, setup_rainforest_200): """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.meter_power_demand") + demand = hass.states.get("sensor.eagle_200_meter_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.total_meter_energy_received") + received = hass.states.get("sensor.eagle_200_total_meter_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" @@ -147,17 +147,17 @@ async def test_sensors_100(hass, setup_rainforest_100): """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.meter_power_demand") + demand = hass.states.get("sensor.eagle_200_meter_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.total_meter_energy_received") + received = hass.states.get("sensor.eagle_200_total_meter_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" From a724bc21b6ffb23eeadee6bec7bc496f9cd2d3f0 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Mon, 30 Aug 2021 20:33:06 -0700 Subject: [PATCH 889/903] Assistant sensors (#55480) --- .../components/google_assistant/const.py | 1 + .../components/google_assistant/trait.py | 59 +++++++++++++++++++ .../components/google_assistant/test_trait.py | 53 +++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 2e43e20f124..d23560b85c1 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -133,6 +133,7 @@ DOMAIN_TO_GOOGLE_TYPES = { media_player.DOMAIN: TYPE_SETTOP, scene.DOMAIN: TYPE_SCENE, script.DOMAIN: TYPE_SCENE, + sensor.DOMAIN: TYPE_SENSOR, select.DOMAIN: TYPE_SENSOR, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index dda8a04c2ed..d1ed328703e 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -108,6 +108,7 @@ TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState" TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel" TRAIT_LOCATOR = f"{PREFIX_TRAITS}Locator" TRAIT_ENERGYSTORAGE = f"{PREFIX_TRAITS}EnergyStorage" +TRAIT_SENSOR_STATE = f"{PREFIX_TRAITS}SensorState" PREFIX_COMMANDS = "action.devices.commands." COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff" @@ -2286,3 +2287,61 @@ class ChannelTrait(_Trait): blocking=True, context=data.context, ) + + +@register_trait +class SensorStateTrait(_Trait): + """Trait to get sensor state. + + https://developers.google.com/actions/smarthome/traits/sensorstate + """ + + sensor_types = { + sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"), + sensor.DEVICE_CLASS_CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: ( + "VolatileOrganicCompounds", + "PARTS_PER_MILLION", + ), + } + + name = TRAIT_SENSOR_STATE + commands = [] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + return domain == sensor.DOMAIN and device_class in ( + sensor.DEVICE_CLASS_AQI, + sensor.DEVICE_CLASS_CO, + sensor.DEVICE_CLASS_CO2, + sensor.DEVICE_CLASS_PM25, + sensor.DEVICE_CLASS_PM10, + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + ) + + def sync_attributes(self): + """Return attributes for a sync request.""" + device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) + data = self.sensor_types.get(device_class) + if data is not None: + return { + "sensorStatesSupported": { + "name": data[0], + "numericCapabilities": {"rawValueUnit": data[1]}, + } + } + + def query_attributes(self): + """Return the attributes of this trait for this entity.""" + device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) + data = self.sensor_types.get(device_class) + if data is not None: + return { + "currentSensorStateData": [ + {"name": data[0], "rawValue": self.state.state} + ] + } diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 50006060f51..290aa00bb47 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -3003,3 +3003,56 @@ async def test_channel(hass): with pytest.raises(SmartHomeError, match="Unsupported command"): await trt.execute("Unknown command", BASIC_DATA, {"channelNumber": "1"}, {}) assert len(media_player_calls) == 1 + + +async def test_sensorstate(hass): + """Test SensorState trait support for sensor domain.""" + sensor_types = { + sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"), + sensor.DEVICE_CLASS_CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: ( + "VolatileOrganicCompounds", + "PARTS_PER_MILLION", + ), + } + + for sensor_type in sensor_types: + assert helpers.get_google_type(sensor.DOMAIN, None) is not None + assert trait.SensorStateTrait.supported(sensor.DOMAIN, None, sensor_type, None) + + trt = trait.SensorStateTrait( + hass, + State( + "sensor.test", + 100.0, + { + "device_class": sensor_type, + }, + ), + BASIC_CONFIG, + ) + + name = sensor_types[sensor_type][0] + unit = sensor_types[sensor_type][1] + + assert trt.sync_attributes() == { + "sensorStatesSupported": { + "name": name, + "numericCapabilities": {"rawValueUnit": unit}, + } + } + + assert trt.query_attributes() == { + "currentSensorStateData": [{"name": name, "rawValue": "100.0"}] + } + + assert helpers.get_google_type(sensor.DOMAIN, None) is not None + assert ( + trait.SensorStateTrait.supported( + sensor.DOMAIN, None, sensor.DEVICE_CLASS_MONETARY, None + ) + is False + ) From d9056c01a6891c24c9aa8323b167d39fe0c7f8f1 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Tue, 31 Aug 2021 23:30:05 +0800 Subject: [PATCH 890/903] Fix ArestSwitchBase missing is on attribute (#55483) --- homeassistant/components/arest/switch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index d20eb7a5f8d..ecbf24c23ca 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -88,6 +88,7 @@ class ArestSwitchBase(SwitchEntity): self._resource = resource self._attr_name = f"{location.title()} {name.title()}" self._attr_available = True + self._attr_is_on = False class ArestSwitchFunction(ArestSwitchBase): From e87b7e24b4192bdb56ad0a61e25297ee6da7ac18 Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 31 Aug 2021 21:24:09 +0200 Subject: [PATCH 891/903] Increase YouLess polling interval (#55490) --- homeassistant/components/youless/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index 83c8209f558..0980e451028 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER, name="youless_gateway", update_method=async_update_data, - update_interval=timedelta(seconds=2), + update_interval=timedelta(seconds=10), ) await coordinator.async_config_entry_first_refresh() From 29110fe157660d64a31dbcb6a47f69b9401ad28e Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 31 Aug 2021 19:22:00 +0200 Subject: [PATCH 892/903] Remove Youless native unit of measurement (#55492) --- homeassistant/components/youless/sensor.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 22fecfe1ec6..0b081ab15a2 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -82,14 +82,6 @@ class YoulessBaseSensor(CoordinatorEntity, SensorEntity): """Property to get the underlying sensor object.""" return None - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement for the sensor.""" - if self.get_sensor is None: - return None - - return self.get_sensor.unit_of_measurement - @property def native_value(self) -> StateType: """Determine the state value, only if a sensor is initialized.""" From 83a51f7f308c3ace368e61ef59c970a08bd5d777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 31 Aug 2021 14:45:28 +0200 Subject: [PATCH 893/903] Add cache-control headers to supervisor entrypoint (#55493) --- homeassistant/components/hassio/http.py | 10 ++++++++-- tests/components/hassio/test_http.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 4a0def62b4d..fe01cbe3197 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -10,6 +10,7 @@ import aiohttp from aiohttp import web from aiohttp.client import ClientTimeout from aiohttp.hdrs import ( + CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, @@ -51,6 +52,8 @@ NO_AUTH = re.compile( r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r"|addons/[^/]+/icon" r")$" ) +NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") + class HassIOView(HomeAssistantView): """Hass.io view to handle base part.""" @@ -104,7 +107,7 @@ class HassIOView(HomeAssistantView): # Stream response response = web.StreamResponse( - status=client.status, headers=_response_header(client) + status=client.status, headers=_response_header(client, path) ) response.content_type = client.content_type @@ -139,7 +142,7 @@ def _init_header(request: web.Request) -> dict[str, str]: return headers -def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: +def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: """Create response header.""" headers = {} @@ -153,6 +156,9 @@ def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: continue headers[name] = value + if NO_STORE.match(path): + headers[CACHE_CONTROL] = "no-store, max-age=0" + return headers diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index f411b465774..16121393170 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -185,3 +185,21 @@ async def test_stream(hassio_client, aioclient_mock): aioclient_mock.get("http://127.0.0.1/test") await hassio_client.get("/api/hassio/test", data="test") assert isinstance(aioclient_mock.mock_calls[-1][2], StreamReader) + + +async def test_entrypoint_cache_control(hassio_client, aioclient_mock): + """Test that we return cache control for requests to the entrypoint only.""" + aioclient_mock.get("http://127.0.0.1/app/entrypoint.js") + aioclient_mock.get("http://127.0.0.1/app/entrypoint.fdhkusd8y43r.js") + + resp1 = await hassio_client.get("/api/hassio/app/entrypoint.js") + resp2 = await hassio_client.get("/api/hassio/app/entrypoint.fdhkusd8y43r.js") + + # Check we got right response + assert resp1.status == 200 + assert resp2.status == 200 + + assert len(aioclient_mock.mock_calls) == 2 + assert resp1.headers["Cache-Control"] == "no-store, max-age=0" + + assert "Cache-Control" not in resp2.headers From 4045eee2e5e6e3af6341e3b86471647e79c03f4c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Sep 2021 06:30:52 +0200 Subject: [PATCH 894/903] Correct sum statistics when only last_reset has changed (#55498) Co-authored-by: Paulus Schoutsen --- homeassistant/components/sensor/recorder.py | 60 +++++++++---- tests/components/sensor/test_recorder.py | 93 +++++++++++++++++++++ 2 files changed, 138 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index cc1b6865d81..558596fbc84 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -282,7 +282,22 @@ def reset_detected( return state < 0.9 * previous_state -def compile_statistics( +def _wanted_statistics( + entities: list[tuple[str, str, str | None]] +) -> dict[str, set[str]]: + """Prepare a dict with wanted statistics for entities.""" + wanted_statistics = {} + for entity_id, state_class, device_class in entities: + if device_class in DEVICE_CLASS_STATISTICS[state_class]: + wanted_statistics[entity_id] = DEVICE_CLASS_STATISTICS[state_class][ + device_class + ] + else: + wanted_statistics[entity_id] = DEFAULT_STATISTICS[state_class] + return wanted_statistics + + +def compile_statistics( # noqa: C901 hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> dict: """Compile statistics for all entities during start-end. @@ -293,17 +308,32 @@ def compile_statistics( entities = _get_entities(hass) + wanted_statistics = _wanted_statistics(entities) + # Get history between start and end - history_list = history.get_significant_states( # type: ignore - hass, start - datetime.timedelta.resolution, end, [i[0] for i in entities] - ) + entities_full_history = [i[0] for i in entities if "sum" in wanted_statistics[i[0]]] + history_list = {} + if entities_full_history: + history_list = history.get_significant_states( # type: ignore + hass, + start - datetime.timedelta.resolution, + end, + entity_ids=entities_full_history, + significant_changes_only=False, + ) + entities_significant_history = [ + i[0] for i in entities if "sum" not in wanted_statistics[i[0]] + ] + if entities_significant_history: + _history_list = history.get_significant_states( # type: ignore + hass, + start - datetime.timedelta.resolution, + end, + entity_ids=entities_significant_history, + ) + history_list = {**history_list, **_history_list} for entity_id, state_class, device_class in entities: - if device_class in DEVICE_CLASS_STATISTICS[state_class]: - wanted_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class] - else: - wanted_statistics = DEFAULT_STATISTICS[state_class] - if entity_id not in history_list: continue @@ -336,21 +366,21 @@ def compile_statistics( # Set meta data result[entity_id]["meta"] = { "unit_of_measurement": unit, - "has_mean": "mean" in wanted_statistics, - "has_sum": "sum" in wanted_statistics, + "has_mean": "mean" in wanted_statistics[entity_id], + "has_sum": "sum" in wanted_statistics[entity_id], } # Make calculations stat: dict = {} - if "max" in wanted_statistics: + if "max" in wanted_statistics[entity_id]: stat["max"] = max(*itertools.islice(zip(*fstates), 1)) - if "min" in wanted_statistics: + if "min" in wanted_statistics[entity_id]: stat["min"] = min(*itertools.islice(zip(*fstates), 1)) - if "mean" in wanted_statistics: + if "mean" in wanted_statistics[entity_id]: stat["mean"] = _time_weighted_average(fstates, start, end) - if "sum" in wanted_statistics: + if "sum" in wanted_statistics[entity_id]: last_reset = old_last_reset = None new_state = old_state = None _sum = 0 diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index b3f0ab075c6..37a1001c4f5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -275,6 +275,77 @@ def test_compile_hourly_sum_statistics_amount( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ("energy", "Wh", "kWh", 1 / 1000), + ("monetary", "EUR", "EUR", 1), + ("monetary", "SEK", "SEK", 1), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), + ], +) +def test_compile_hourly_sum_statistics_amount_reset_every_state_change( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + "last_reset": None, + } + seq = [10, 15, 15, 15, 20, 20, 20, 10] + # Make sure the sequence has consecutive equal states + assert seq[1] == seq[2] == seq[3] + + states = {"sensor.test1": []} + one = zero + for i in range(len(seq)): + one = one + timedelta(minutes=1) + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(one), + "state": approx(factor * seq[7]), + "sum": approx(factor * (sum(seq) - seq[0])), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -1303,6 +1374,28 @@ def record_meter_states(hass, zero, entity_id, _attributes, seq): return four, eight, states +def record_meter_state(hass, zero, entity_id, _attributes, seq): + """Record test state. + + We inject a state update for meter sensor. + """ + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + attributes = dict(_attributes) + attributes["last_reset"] = zero.isoformat() + + states = {entity_id: []} + with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=zero): + states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) + + return states + + def record_states_partially_unavailable(hass, zero, entity_id, attributes): """Record some test states. From d4aadd8af06f84c4d167e8214a464f9d1c163f98 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Aug 2021 19:15:22 +0200 Subject: [PATCH 895/903] Improve log for sum statistics (#55502) --- homeassistant/components/sensor/recorder.py | 15 ++++++++++++++- tests/components/sensor/test_recorder.py | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 558596fbc84..0054b01abd2 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -406,6 +406,19 @@ def compile_statistics( # noqa: C901 and (last_reset := state.attributes.get("last_reset")) != old_last_reset ): + if old_state is None: + _LOGGER.info( + "Compiling initial sum statistics for %s, zero point set to %s", + entity_id, + fstate, + ) + else: + _LOGGER.info( + "Detected new cycle for %s, last_reset set to %s (old last_reset %s)", + entity_id, + last_reset, + old_last_reset, + ) reset = True elif old_state is None and last_reset is None: reset = True @@ -420,7 +433,7 @@ def compile_statistics( # noqa: C901 ): reset = True _LOGGER.info( - "Detected new cycle for %s, zero point set to %s (old zero point %s)", + "Detected new cycle for %s, value dropped from %s to %s", entity_id, fstate, new_state, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 37a1001c4f5..115473c23de 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -273,6 +273,9 @@ def test_compile_hourly_sum_statistics_amount( ] } assert "Error while processing event StatisticsTask" not in caplog.text + assert "Detected new cycle for sensor.test1, last_reset set to" in caplog.text + assert "Compiling initial sum statistics for sensor.test1" in caplog.text + assert "Detected new cycle for sensor.test1, value dropped" not in caplog.text @pytest.mark.parametrize("state_class", ["measurement"]) @@ -424,6 +427,9 @@ def test_compile_hourly_sum_statistics_total_increasing( ] } assert "Error while processing event StatisticsTask" not in caplog.text + assert "Detected new cycle for sensor.test1, last_reset set to" not in caplog.text + assert "Compiling initial sum statistics for sensor.test1" in caplog.text + assert "Detected new cycle for sensor.test1, value dropped" in caplog.text @pytest.mark.parametrize( From 05cf223146205699091aa03bca4e1fa77395e384 Mon Sep 17 00:00:00 2001 From: muppet3000 Date: Wed, 1 Sep 2021 06:18:20 +0100 Subject: [PATCH 896/903] Added trailing slash to US growatt URL (#55504) --- homeassistant/components/growatt_server/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 0b11e9994ca..e0297de5eff 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -7,7 +7,7 @@ DEFAULT_NAME = "Growatt" SERVER_URLS = [ "https://server.growatt.com/", - "https://server-us.growatt.com", + "https://server-us.growatt.com/", "http://server.smten.com/", ] From 22f745b17cf1928c8502edf4f7738ed38c70f3ac Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel <41558831+felipediel@users.noreply.github.com> Date: Wed, 1 Sep 2021 02:49:56 -0300 Subject: [PATCH 897/903] Fix BroadlinkSwitch._attr_assumed_state (#55505) --- homeassistant/components/broadlink/switch.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 9fb7215e2a9..5ed1e424f53 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -142,9 +142,6 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): super().__init__(device) self._command_on = command_on self._command_off = command_off - - self._attr_assumed_state = True - self._attr_device_class = DEVICE_CLASS_SWITCH self._attr_name = f"{device.name} Switch" async def async_added_to_hass(self): From ba9ef004c89a37bd7034d1c82a45f1752f9244df Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 1 Sep 2021 15:50:32 +1000 Subject: [PATCH 898/903] Add missing device class for temperature sensor in Advantage Air (#55508) --- homeassistant/components/advantage_air/sensor.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 5912101fd65..4f3258e824e 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -1,7 +1,11 @@ """Sensor platform for Advantage Air integration.""" import voluptuous as vol -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv, entity_platform @@ -138,11 +142,11 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): - """Representation of Advantage Air Zone wireless signal sensor.""" + """Representation of Advantage Air Zone temperature sensor.""" _attr_native_unit_of_measurement = TEMP_CELSIUS + _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_icon = "mdi:thermometer" _attr_entity_registry_enabled_default = False def __init__(self, instance, ac_key, zone_key): From a315fd059aa1b6ac4dba411ab40216c3a1fa1e65 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 Aug 2021 22:57:33 -0700 Subject: [PATCH 899/903] Bumped version to 2021.9.0b7 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6c096f06769..e4e84ccadca 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 3b9859940f857f646d09d2e989302370f4433e0e Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Wed, 1 Sep 2021 10:03:41 +0200 Subject: [PATCH 900/903] ESPHome light color mode use capabilities (#55206) Co-authored-by: Oxan van Leeuwen --- homeassistant/components/esphome/light.py | 195 +++++++++++++----- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 150 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 73339769121..9e7f544f610 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any, cast -from aioesphomeapi import APIVersion, LightColorMode, LightInfo, LightState +from aioesphomeapi import APIVersion, LightColorCapability, LightInfo, LightState from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -34,12 +34,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -59,20 +54,81 @@ async def async_setup_entry( ) -_COLOR_MODES: EsphomeEnumMapper[LightColorMode, str] = EsphomeEnumMapper( - { - LightColorMode.UNKNOWN: COLOR_MODE_UNKNOWN, - LightColorMode.ON_OFF: COLOR_MODE_ONOFF, - LightColorMode.BRIGHTNESS: COLOR_MODE_BRIGHTNESS, - LightColorMode.WHITE: COLOR_MODE_WHITE, - LightColorMode.COLOR_TEMPERATURE: COLOR_MODE_COLOR_TEMP, - LightColorMode.COLD_WARM_WHITE: COLOR_MODE_COLOR_TEMP, - LightColorMode.RGB: COLOR_MODE_RGB, - LightColorMode.RGB_WHITE: COLOR_MODE_RGBW, - LightColorMode.RGB_COLOR_TEMPERATURE: COLOR_MODE_RGBWW, - LightColorMode.RGB_COLD_WARM_WHITE: COLOR_MODE_RGBWW, - } -) +_COLOR_MODE_MAPPING = { + COLOR_MODE_ONOFF: [ + LightColorCapability.ON_OFF, + ], + COLOR_MODE_BRIGHTNESS: [ + LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + # for compatibility with older clients (2021.8.x) + LightColorCapability.BRIGHTNESS, + ], + COLOR_MODE_COLOR_TEMP: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_RGB: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB, + ], + COLOR_MODE_RGBW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE, + ], + COLOR_MODE_RGBWW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_WHITE: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.WHITE + ], +} + + +def _color_mode_to_ha(mode: int) -> str: + """Convert an esphome color mode to a HA color mode constant. + + Choses the color mode that best matches the feature-set. + """ + candidates = [] + for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items(): + for caps in cap_lists: + if caps == mode: + # exact match + return ha_mode + if (mode & caps) == caps: + # all requirements met + candidates.append((ha_mode, caps)) + + if not candidates: + return COLOR_MODE_UNKNOWN + + # choose the color mode with the most bits set + candidates.sort(key=lambda key: bin(key[1]).count("1")) + return candidates[-1][0] + + +def _filter_color_modes( + supported: list[int], features: LightColorCapability +) -> list[int]: + """Filter the given supported color modes, excluding all values that don't have the requested features.""" + return [mode for mode in supported if mode & features] # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property @@ -95,10 +151,17 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" data: dict[str, Any] = {"key": self._static_info.key, "state": True} + # The list of color modes that would fit this service call + color_modes = self._native_supported_color_modes + try_keep_current_mode = True + # rgb/brightness input is in range 0-255, but esphome uses 0-1 if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None: data["brightness"] = brightness_ha / 255 + color_modes = _filter_color_modes( + color_modes, LightColorCapability.BRIGHTNESS + ) if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None: rgb = tuple(x / 255 for x in rgb_ha) @@ -106,8 +169,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = LightColorMode.RGB + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + try_keep_current_mode = False if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: # pylint: disable=invalid-name @@ -117,8 +180,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["white"] = w data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = LightColorMode.RGB_WHITE + color_modes = _filter_color_modes( + color_modes, LightColorCapability.RGB | LightColorCapability.WHITE + ) + try_keep_current_mode = False if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: # pylint: disable=invalid-name @@ -126,14 +191,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): color_bri = max(rgb) # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) - modes = self._native_supported_color_modes - if ( - self._supports_color_mode - and LightColorMode.RGB_COLD_WARM_WHITE in modes - ): + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + if _filter_color_modes(color_modes, LightColorCapability.COLD_WARM_WHITE): + # Device supports setting cwww values directly data["cold_white"] = cw data["warm_white"] = ww - target_mode = LightColorMode.RGB_COLD_WARM_WHITE + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) else: # need to convert cw+ww part to white+color_temp white = data["white"] = max(cw, ww) @@ -142,11 +207,13 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): max_ct = self.max_mireds ct_ratio = ww / (cw + ww) data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) - target_mode = LightColorMode.RGB_COLOR_TEMPERATURE + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.WHITE, + ) + try_keep_current_mode = False data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = target_mode if (flash := kwargs.get(ATTR_FLASH)) is not None: data["flash_length"] = FLASH_LENGTHS[flash] @@ -156,12 +223,15 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: data["color_temperature"] = color_temp - if self._supports_color_mode: - supported_modes = self._native_supported_color_modes - if LightColorMode.COLOR_TEMPERATURE in supported_modes: - data["color_mode"] = LightColorMode.COLOR_TEMPERATURE - elif LightColorMode.COLD_WARM_WHITE in supported_modes: - data["color_mode"] = LightColorMode.COLD_WARM_WHITE + if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLOR_TEMPERATURE + ) + else: + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) + try_keep_current_mode = False if (effect := kwargs.get(ATTR_EFFECT)) is not None: data["effect"] = effect @@ -171,7 +241,30 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # HA only sends `white` in turn_on, and reads total brightness through brightness property data["brightness"] = white_ha / 255 data["white"] = 1.0 - data["color_mode"] = LightColorMode.WHITE + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE, + ) + try_keep_current_mode = False + + if self._supports_color_mode and color_modes: + # try the color mode with the least complexity (fewest capabilities set) + # popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671 + color_modes.sort(key=lambda mode: bin(mode).count("1")) + data["color_mode"] = color_modes[0] + if self._supports_color_mode and color_modes: + if ( + try_keep_current_mode + and self._state is not None + and self._state.color_mode in color_modes + ): + # if possible, stay with the color mode that is already set + data["color_mode"] = self._state.color_mode + else: + # otherwise try the color mode with the least complexity (fewest capabilities set) + # popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671 + color_modes.sort(key=lambda mode: bin(mode).count("1")) + data["color_mode"] = color_modes[0] await self._client.light_command(**data) @@ -198,7 +291,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return None return next(iter(supported)) - return _COLOR_MODES.from_esphome(self._state.color_mode) + return _color_mode_to_ha(self._state.color_mode) @esphome_state_property def rgb_color(self) -> tuple[int, int, int] | None: @@ -227,9 +320,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return the rgbww color value [int, int, int, int, int].""" rgb = cast("tuple[int, int, int]", self.rgb_color) - if ( - not self._supports_color_mode - or self._state.color_mode != LightColorMode.RGB_COLD_WARM_WHITE + if not _filter_color_modes( + self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE ): # Try to reverse white + color temp to cwww min_ct = self._static_info.min_mireds @@ -262,7 +354,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return self._state.effect @property - def _native_supported_color_modes(self) -> list[LightColorMode]: + def _native_supported_color_modes(self) -> list[int]: return self._static_info.supported_color_modes_compat(self._api_version) @property @@ -272,7 +364,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # All color modes except UNKNOWN,ON_OFF support transition modes = self._native_supported_color_modes - if any(m not in (LightColorMode.UNKNOWN, LightColorMode.ON_OFF) for m in modes): + if any(m not in (0, LightColorCapability.ON_OFF) for m in modes): flags |= SUPPORT_TRANSITION if self._static_info.effects: flags |= SUPPORT_EFFECT @@ -281,7 +373,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property def supported_color_modes(self) -> set[str] | None: """Flag supported color modes.""" - return set(map(_COLOR_MODES.from_esphome, self._native_supported_color_modes)) + supported = set(map(_color_mode_to_ha, self._native_supported_color_modes)) + if COLOR_MODE_ONOFF in supported and len(supported) > 1: + supported.remove(COLOR_MODE_ONOFF) + if COLOR_MODE_BRIGHTNESS in supported and len(supported) > 1: + supported.remove(COLOR_MODE_BRIGHTNESS) + if COLOR_MODE_WHITE in supported and len(supported) == 1: + supported.remove(COLOR_MODE_WHITE) + return supported @property def effect_list(self) -> list[str]: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 96ac632d990..a78d2efb763 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==7.0.0"], + "requirements": ["aioesphomeapi==8.0.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index 611713006ff..ee9f09d69ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==7.0.0 +aioesphomeapi==8.0.0 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ec55d5184e..e83fdc13817 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==7.0.0 +aioesphomeapi==8.0.0 # homeassistant.components.flo aioflo==0.4.1 From 576cece7a9f7daceef13086e25544e50f7e78f8e Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Wed, 1 Sep 2021 02:26:09 -0400 Subject: [PATCH 901/903] Fix None support_color_modes TypeError (#55497) * Fix None support_color_modes TypeError https://github.com/home-assistant/core/issues/55451 * Update __init__.py --- homeassistant/components/light/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 6865ae165bc..4a0025126c8 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -445,7 +445,11 @@ async def async_setup(hass, config): # noqa: C901 ) # If both white and brightness are specified, override white - if ATTR_WHITE in params and COLOR_MODE_WHITE in supported_color_modes: + if ( + supported_color_modes + and ATTR_WHITE in params + and COLOR_MODE_WHITE in supported_color_modes + ): params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE]) # Remove deprecated white value if the light supports color mode From af68802c17121257b404068236553c38d192a394 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Sep 2021 04:18:50 -0700 Subject: [PATCH 902/903] Tweaks for the iotawatt integration (#55510) --- homeassistant/components/iotawatt/sensor.py | 9 +++++++-- tests/components/iotawatt/test_sensor.py | 8 ++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 8a8c92a8c51..1b4c166eb27 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -8,7 +8,6 @@ from iotawattpy.sensor import Sensor from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -47,12 +46,14 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_CURRENT, + entity_registry_enabled_default=False, ), "Hz": IotaWattSensorEntityDescription( "Hz", native_unit_of_measurement=FREQUENCY_HERTZ, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "PF": IotaWattSensorEntityDescription( "PF", @@ -60,6 +61,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_POWER_FACTOR, value=lambda value: value * 100, + entity_registry_enabled_default=False, ), "Watts": IotaWattSensorEntityDescription( "Watts", @@ -70,7 +72,6 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { "WattHours": IotaWattSensorEntityDescription( "WattHours", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, ), "VA": IotaWattSensorEntityDescription( @@ -78,24 +79,28 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { native_unit_of_measurement=POWER_VOLT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "VAR": IotaWattSensorEntityDescription( "VAR", native_unit_of_measurement=VOLT_AMPERE_REACTIVE, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "VARh": IotaWattSensorEntityDescription( "VARh", native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "Volts": IotaWattSensorEntityDescription( "Volts", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_VOLTAGE, + entity_registry_enabled_default=False, ), } diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index 556da8cc2b0..a5fc2250b84 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -1,11 +1,7 @@ """Test setting up sensors.""" from datetime import timedelta -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DEVICE_CLASS_ENERGY, - STATE_CLASS_TOTAL_INCREASING, -) +from homeassistant.components.sensor import ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -37,7 +33,7 @@ async def test_sensor_type_input(hass, mock_iotawatt): state = hass.states.get("sensor.my_sensor") assert state is not None assert state.state == "23" - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert ATTR_STATE_CLASS not in state.attributes assert state.attributes[ATTR_FRIENDLY_NAME] == "My Sensor" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY From 493309daa74214eb2b3bb41d5cce73c6da5a56bc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Sep 2021 19:40:48 +0200 Subject: [PATCH 903/903] Bumped version to 2021.9.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e4e84ccadca..9cd5ecc4fec 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0)